@clankie/work-items
Work items in the repo's own tracking convention (ADR 0191).
discoverConvention(root, run)reads what a repo already does: a Linear project and issue keys, GitHub issues in use, a one-file-per-item Markdown directory, a singleTODO.md, decision records. It never writes.resolveTracker(root, deps, { record })returns the backend for the recorded convention (.clankie/tracking.json), or for an unambiguous discovery, which it records only when a write is about to happen. Ambiguity raisesConventionNeededErrorwith the owner's one question.- Backends:
createFilesBackend(defaultat.clankie/work/, ormarkdownin the repo's directory),createGithubBackend(the owner'sgh, orgithubRestApiwith the body's GitHub account connection, ADR 0232), andcreateLinearBackend(the service supplies Clankie's connected account through its registered API adapter or the separately configured Linear MCP connection). parseBody/patchBody: the shared Markdown. Criteria are a checklist under## Acceptance Criteria, evidence a captioned link list under## Evidence. Patches touch only the sections they name, so owner-written issue bodies keep their other sections and order.
The service (apps/clankie/src/work-items.ts) owns which repos a paired device
may read or write; clankie work and the device API remain compatibility callers.
The wire shapes live in @clankie/protocol/work-items. Owner tracker settings
use POST /v1/operator/work-tracker: one new-project default, or an explicit
project convention change after preview and confirmation. Existing items stay
in their original tracker. See the CLI reference.
The canonical tracker surface is the Linear-shaped subset in tracker-tools.ts
(ADR 0226). createLocalTracker
provides that catalog and call(name, args) over a durable store (storage), with
atomic locked writes, stable UUIDs and human identifiers. The service exposes
these as linear_* through the same MCP host and fleet directory used by
connected Linear. No connection selects local storage; connected provider
failures retain their errors and never replay locally. clankie doctor reports
selection and reason. Repository adapters keep GitHub/Markdown issue storage
and carry ancillary records in durable local metadata within that scope.
The local store also records the host-stamped actor (TrackerToolCallOptions.actor)
on every write, keeps per-actor idempotencyKey receipts and a hash-chained
audit log in the same atomic replacement as the effect, and refuses stale
ifUpdatedAt updates. get_write_receipt and list_audit_events read them.
Other backends refuse these inputs explicitly (ADR 0226 amendment, VUH-1916).
Every local read checks the store's checkpoint/journal generation, including changes from other processes. Backends share the validated committed snapshot; external changes receive schema and hash-chain validation before reuse. Reads return isolated results, and writes (including cycle rollover on a read) use an isolated store under the existing lock. Corruption remains a hard error.
Evidence bundles hold validated store references and gaps on items and runs.
Completion past landed requires the current item bundle and a bundle_checked
event from an actor who did not do the work; the owner's verification counts.
The host supplies validateEvidence from its evidence store. Item asks and run
gates use the existing ADR 0245 mailbox; tracker records hold only its references.
A gate blocks a run immediately until the authenticated owner answer approves it.
Owner/lead steer, pause, resume and stop are events. See the
VUH-1919 amendment
for tools, recovery and scope.
Hosted registered OAuth selects the service's in-process GraphQL adapter with
broker entry linear-api (ADR 0232).
It retains account/repository/fleet fences and mutation receipts, and never
passes that token to MCP. Disconnect clears both API and legacy MCP credentials.
Optional customer-needs/releases expansions and unsupported provider mutations
refuse explicitly. Description patches reread the stored body but cannot provide
provider-wide compare-and-set; media-bearing description edits refuse, so add
evidence in a comment instead.
Outcome reads for Auto rounds (ADR 0270)
live in outcomes.ts: closures in a window, started issues now, milestones and
status updates, and optionally state history. collectLinearOutcomes reads a
connected Linear project through the caller's query function with server-side
filters; the local tracker's readOutcomes rebuilds the same shape from its
event stream, where created and stage-family events carry the resulting
status name. localDays and startedAtDayEnds turn history into per-day
started counts in an IANA zone. Membership is current, which the result's
caveats state.
Priority is native Linear 0..4, Markdown priority: front matter and reserved
GitHub priority labels. Open-work listing sorts Urgent→High→Medium→Low→none
before limits and pagination. CLI create/update accept --priority 0..4.
Export/import execution is deferred; the ADR specifies the identity mapping and
uncertain-write reconciliation needed to avoid duplicate imports.
update accepts addLabels, removeLabels and addDependsOn deltas. Each
backend reads the item afresh and merges against its complete native metadata;
the 20-label WorkItem.labels display projection never supplies a replacement
label set. Existing labels keep their names and order, removals win, and repeated
additions are ignored case-insensitively. Dependencies merge with existing
prerequisites. owner is the shared body/front-matter work owner, including
null to clear it; it does not reassign the tracker issue's native assignee.
An update prepares all changes before one provider mutation or atomic file rename.
Its returned item retains the backend's parent metadata.
scopedWrites: true opts into the owner-device write boundary. Linear resolves
the recorded team/project through connected reads and compares canonical UUIDs
with the fresh item's teamId/projectId; display names alone never prove scope.
Delegated connected-Linear tools additionally set linearWriteScope: "team":
the saved team's canonical UUID remains the write boundary, while an issue may
belong to any project (or none) in that team. An explicitly named project must
resolve to a real project associated with that team by UUID. New issues still
default to the repo's saved project. Owner-device writes keep the default
team-and-project boundary; built-in tracker scopes are unchanged.
GitHub issues stay in the recorded repository, and scoped label deltas cannot
manipulate labels recognized by its status parser, including doing/wip aliases.
Files reject symlinked work paths, including
parents and items, and reobserve the destination immediately before publication.
Existing generic agent backends keep their unrestricted behavior unless this
option is enabled.
Backend options and TrackerDeps expose synchronous beforeWrite, onDispatch
and effectConfirmed callbacks. Files run the final fence after temporary-file
IO and before rename. Successful provider writes report confirmation before
follow-up reads, so a failed parent/result read cannot erase proof of the effect.
When a connected adapter awaits credential or admission checks, the service
places its final fence and dispatch observation at that adapter's actual send
boundary; it does not infer dispatch from an earlier backend callback. These
hooks are service-owned authority and receipt integration, not caller-supplied
permissions or an automatic retry path.
Linear lists use cursor pages of at most 50 issues, stopping at the requested
number of matching items (default 100, maximum 250) or the final page. Missing
or repeated continuation cursors fail with invalid_pagination instead of
looping or silently returning an incomplete list.
A saved Linear convention may include linear.label, an existing label that
scopes the repo's board within its team/project. Every provider page receives
that filter; an ad-hoc role label is intersected against the provider's full
labels before the item's 20-label display projection. Status and owner filters
still apply. Omitting the saved label keeps the team/project-wide board.
Creates carry that label; edits and attachments preserve existing labels.
Direct known-item reads remain available: the scope is a board filter, not an
authorization boundary. Unknown labels fail at the provider; none are created.
Attachments beside uploaded media insert only new evidence, refusing ambiguous
Evidence headings instead of replacing upload nodes.
The service requests MCP resultMode: "data": complete text up to 8 MiB in
UTF-8, with typed result_too_large failure above that ceiling. The default
model-facing 50,000-character cap is unchanged. The data limit is checked on
the decoded tool result; it is not a network transport streaming limit.
Built-in sync
LocalTrackerBackend.sync adds project-scoped full/partial bootstrap, lazy
batch hydration, resumable pushed commits and keyed atomic transactions.
Its journal shares the store's write/receipt boundary.
Each syncId identifies an entire commit; updates carry field deltas and
project membership before/after a move. Existing stores bootstrap at cursor zero.
See the ADR 0226 sync amendment
for model fields, groups, hydration ordering and cursor recovery. This changes
neither connected Linear nor repository adapters.
Enrolled devices read saved filters using tracker_sync commands
list_issue_views and list_issue_view_issues. Both require the exact project
UUID and current storeId; apply also requires the saved view id. The backend
delegates to the normal saved-view tools under the store lock and checks live
access before dispatch and return. Results carry outcome: issue_views or
issue_view_issues, scope IDs, canonical views or issues, hasNextPage and
optional cursor. limit (1–250, default 50), cursor and orderBy use canonical
pagination. Definitions are never evaluated in the app or stored as snapshots.
Existing sync commits, including saved-definition writes with empty deltas, let
clients refresh their selected read without a separate poller.
clankie work project-details --repo REPO reads the saved tracker project’s summary, description, status, priority, lead, dates, teams, resources and latest 200 authored status updates. The latest 20 updates each carry up to 200 replies, flattened oldest first with author, time and parent identity. Reads use at most four concurrent comment calls and one page per update. replyCount is a lower bound when repliesTruncated is true; a failed thread read omits its reply fields without hiding the project. These fields are optional for legacy-service compatibility. Replies use the existing save_comment tool with statusUpdateId (or parentId for a reply to a reply), through the same account and authorization as project comments. Paired devices use the additive work_project_details operation. Linear and the built-in tracker share the canonical project/status-update tools. Unsupported or failed reads answer unavailable; an empty feed means a successful read with no updates. The read never infers health, author or delivery from issue state.
Linear mirror import
clankie work import linear --project UUID --scratch NAME copies a single-team
project through Clankie's connected account into a separate built-in store.
Linear remains authoritative. It retains provider UUIDs and VUH identifiers,
archived records, comments/replies, state history and source actors, relations,
labels/priorities, projects, milestones, documents, updates and team cycles.
Milestones/documents participate in the existing sync protocol. Uploads enter the
evidence store and their links become clankie://evidence.
The importer is read-only on Linear, obeys the shared background request budget,
and compares records by provider ID/content. An unchanged rerun writes no tracker
records or events. It does not run local cycle rollover or completion gates.
Reports live in the scratch directory as import-report.json. External attachment
URLs are retained and reported; failed authenticated downloads abort before the
tracker replacement. Multi-team/project-to-team mappings are currently refused.
Linear webhook mirror
planLinearMirrorEvent turns one signed Linear data-change event into a partial
import snapshot; importLinear applies it with mirrorEvent, so webhooks and
imports share one mapper. The store records each applied Linear event id (the
last 5,000) and returns duplicate on replay. Changes carry the original Linear
actor and via: linear_mirror on item events and enter the sync journal as
mirror_linear. An event naming an unknown issue, label, state or milestone plans
as drift; collectLinearScoped re-reads just those records with the import's
field selections. A store holding a Linear mirror refuses every built-in write
(mirror_read_only) until cutover.
Cutover
promoteLinearImport replaces a live store with an imported copy, under both
store locks. It backs up the live bytes and marks the copy's linearMirror.cutover,
so the copy is writable, refuses further imports, and keeps its Linear records as
provenance. restoreTrackerStore puts the exact pre-cutover bytes back and keeps
the pilot store. Both keep whole stores, sync history included, and set a
replaced journal aside. readTrackerStoreSummary reads counts, provider record ids with
Linear's updatedAt, and non-Linear writes, without seeding a store.
Storage
A store under 2 MiB is one tracker.json, rewritten atomically per commit. A larger
one keeps tracker.json as a checkpoint and appends each commit, with one fsync, to
JSONL segments in tracker.journal/. A background checkpoint folds the journal back
in, and the host's start-up checkpoint() moves an existing large file over, keeping
its bytes as tracker.pre-journal-<ms>.json. Open is the checkpoint plus the
journal tail, with every chain proven. Cut-over reference routing uses the backend's
host-only referenceProjects against its shared validated committed snapshot
(LOCAL-VUH-50). Same-process commits update that snapshot without a disk reread;
metadata readers wait for disk publication and the shared snapshot update, including
failed appends; the fence starts after authorization callbacks to avoid deadlock.
External changes still trigger validation, and missing or damaged stores refuse.
An external journal append hashes the checkpoint before decoding: an unchanged
digest replays only the tail. A changed checkpoint or failed continuation reopens
and proves the whole store.
If another snapshot publishes during a refresh, the reader restarts the disk
generation check before choosing a snapshot. It reuses a publication that matches
disk or refreshes again, so a writer never drafts from an older reader's base.
Commits also check the snapshot's generation under the lock before appending,
including after authorization callbacks; a stale base refuses before dispatch.
Reference lookups reuse the committed-array indexes, extended by changed records
and appended tails. A reference rename or external replacement rebuilds the affected
metadata index; ordinary commits never reparse the checkpoint for routing. Other
host readers use readTrackerStoreSnapshot
(validated), readTrackerStoreRecords (for routing; the tracker refuses a broken
store itself) and trackerStoreVersion. exportTrackerStore writes the whole store
as one legacy file for a rollback. Design and numbers:
ADR 0226, writes cost the change.
A write costs what it changed, not the store's size (LOCAL-VUH-47). Its draft remembers
which committed array each collection was copied from and which records it replaced
(own, replaceRecord). Settling the snapshot, the journal record and the sync
candidates read only those records and the appended tail. Receipts by key, an issue's
current bundle and owner asks, and records by id are looked up through indexes. One
index serves each line of committed arrays, and every commit extends it. Under Vitest,
every commit is also checked against whole-collection comparisons.
Damaged journal lines. Each line names the byte length of its record:
{"syncId":N,"length":L,…}. Lines without a length end at their newline. They are
older lines, or lines that a build from before framing appended after a rollback, such
as clankie update restarting the previous commit, so they can follow framed ones. An
unframed line after a framed one counts as a record only if it parses. Open reads
records in order:
- A final line with no newline is an interrupted append. It is left out.
- A final line whose bytes are not a whole parseable record at its stated length is also left out. A power cut can persist the line's size and newline but not all of its pages.
- The next append cuts either kind off, but only after reading what it cuts. If those bytes hold a whole parseable record anywhere, framed or not, they are another writer's acknowledged commit seen from a stale base, and the append refuses with nothing cut (LOCAL-VUH-54). Over 64 MiB of unexpected bytes, more than one interrupted append, is refused unread. Both refusals come before dispatch, so the write provably changed nothing. If an append's fsync fails and cutting its own line back fails too, that whole line stays. The caller saw an error, yet a reopen keeps the record, and the same process refuses every later append until it restarts.
- The same damage anywhere else refuses the open, and so does a damaged final line that a whole record follows.
- A live process refuses to drop a record it already acknowledged.
- A final line that still parses as a well-formed record but breaks the event, audit or sync chain is refused, not skipped. Its bytes are a record, so it may have been acknowledged.
exportTrackerStore validates too, so it cannot export a refused store. Recovery is by
hand:
- Stop the service and copy the whole store directory aside.
- If the refused line is the live segment's last, truncate the segment to the end of the line before it. That loses only that record. The open then proves everything else.
- If the damage is earlier, restore
tracker.pre-journal-<ms>.jsonwith the journal set aside, as in the rollback below. That loses every write since the move. - Once the store opens,
exportTrackerStorewrites it as one file for the rollback steps below.
Checkpoint size. Large checkpoint opens stream the top-level JSON arrays one record at a time (LOCAL-VUH-49). Files up to 8 MiB use the native whole-file parser. The JSON layout is unchanged, and both paths run the same schema and event, audit and sync chain checks before publishing a snapshot. Host snapshot and routing readers use the same decoder. No events, audit entries, receipts or journal history are expired. Checkpoint writing already streams records too.
The whole store still lives in memory, and an individual record or non-array field must fit in one JavaScript string. Streaming removes the whole-checkpoint string ceiling, not all memory or response-size limits. The unrelated FastJsonStringifier abort in the retained Zone OOM finding was not isolated to sealing and is not claimed fixed. Decision, synthetic 60/90/180-day measurements and real previous-build rollback proof: LOCAL-VUH-49.
clankie doctor still reports checkpoint size against the previous runtime's
single-string limit (about 537 MB), warning at half for rollback headroom. This
build streams the open; a previous runtime can read and write the unchanged format
below its own string ceiling. Above that ceiling it refuses before writing and
leaves the store unchanged. Keep a streaming build available when rolling back a
large store. The legacy export/cutover helpers still assemble a whole-file string;
their old size ceiling remains, and they refuse rather than produce a partial export.
Rolling back to a journal build from before framing (f59461d17 up to
LOCAL-VUH-47) needs nothing. That build reads framed lines and appends unframed ones,
and this build reads both again.
Rolling back to a build without the journal. That build refuses a checkpoint, so it needs the store exported as one legacy file. This keeps every write, including those made since the move. It was drilled on a migrated copy of the live store: the older build opened all of it and wrote.
-
Stop the service, so nothing else writes the store.
-
From a checkout of the journal build, export the whole store (
truemeans written):node --input-type=module -e ' const { exportTrackerStore } = await import("./packages/work-items/src/index.ts"); console.log(await exportTrackerStore(process.argv[1], process.argv[2])); ' ~/.clankie/tracker ~/.clankie/tracker-rollback.json -
Set the journal and checkpoint aside, and put the export in place:
stamp=$(date +%s) mv ~/.clankie/tracker/tracker.journal ~/.clankie/tracker/tracker.journal.rollback-$stamp mv ~/.clankie/tracker/tracker.json ~/.clankie/tracker/tracker.checkpoint-$stamp.json mv ~/.clankie/tracker-rollback.json ~/.clankie/tracker/tracker.json -
Install and start the older build.
The faster alternative, tracker.pre-journal-<ms>.json restored as tracker.json
with the journal set aside, loses every write since the move. Either single file
stops working before 60 days of fleet volume, the old format's own limit.
A builtin convention routes the canonical tools to the host's built-in store
through TrackerDeps.builtIn. That scope is the bound tracker project UUID and
its team. With scoped writes, an issue outside the project is refused.
