packages/work-items/README.md

@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 single TODO.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 raises ConventionNeededError with the owner's one question.
  • Backends: createFilesBackend (default at .clankie/work/, or markdown in the repo's directory), createGithubBackend (the owner's gh, or githubRestApi with the body's GitHub account connection, ADR 0232), and createLinearBackend (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 tracker.json, 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 rereads the file, including changes from other processes. Each backend reuses its last validated snapshot only when the content is identical; changed content receives full schema and hash-chain validation. 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 tracker.json and the existing 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. readTrackerStoreSummary reads counts, provider record ids with Linear's updatedAt, and non-Linear writes, without seeding a store.

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.