@adhd/backlog 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +194 -39
  3. package/api.d.ts +87 -0
  4. package/api.ir.json +1 -1
  5. package/citation.d.ts +176 -0
  6. package/envelope.d.ts +36 -2
  7. package/index.d.ts +23 -3
  8. package/index.js +105 -57
  9. package/index.mjs +11450 -6758
  10. package/ir-artifact.d.ts +7 -3
  11. package/lifecycle.d.ts +49 -0
  12. package/package.json +5 -5
  13. package/query/canonical.d.ts +16 -0
  14. package/query/card.d.ts +116 -4
  15. package/query/get.d.ts +8 -1
  16. package/query/index.d.ts +1 -0
  17. package/query/query.d.ts +32 -22
  18. package/query/redirect.d.ts +30 -0
  19. package/query/resolve.d.ts +75 -0
  20. package/query/similar-clusters.d.ts +8 -0
  21. package/query/similarity-signals.d.ts +47 -0
  22. package/query/spec-staleness.d.ts +21 -0
  23. package/query/types.d.ts +249 -34
  24. package/query/verdict-core.d.ts +21 -0
  25. package/query/verdict.d.ts +22 -0
  26. package/query/views/catalog.d.ts +47 -0
  27. package/query/views/registry.d.ts +27 -7
  28. package/query/views/report.d.ts +49 -0
  29. package/query/views/semantic.d.ts +28 -5
  30. package/query/views/stats.d.ts +38 -2
  31. package/readiness.d.ts +29 -0
  32. package/retry-policy.d.ts +44 -0
  33. package/serve.d.ts +1 -1
  34. package/server.d.ts +71 -3
  35. package/service-config.d.ts +109 -0
  36. package/service-errors.d.ts +51 -0
  37. package/skill/SKILL.md +688 -81
  38. package/store/catalog-invariant-guard.d.ts +13 -7
  39. package/vocabulary.d.ts +48 -0
  40. package/write/anchor-check.d.ts +139 -0
  41. package/write/attestation.d.ts +64 -0
  42. package/write/catalog-merge.d.ts +15 -8
  43. package/write/catalog.d.ts +72 -2
  44. package/write/citation-path.d.ts +31 -0
  45. package/write/citation.d.ts +157 -0
  46. package/write/create-issue.d.ts +143 -29
  47. package/write/errors.d.ts +196 -1
  48. package/write/gate.d.ts +67 -0
  49. package/write/merge-project.d.ts +54 -0
  50. package/write/obligation.d.ts +133 -0
  51. package/write/relate.d.ts +1 -1
  52. package/write/revision.d.ts +27 -0
  53. package/write/similarity-scan.d.ts +84 -0
  54. package/write/spec-revision.d.ts +131 -0
  55. package/write/spec-revision.reconcile.d.ts +48 -0
  56. package/write/transition.d.ts +13 -2
  57. package/write/tx.d.ts +44 -2
  58. package/write/uid-prefix.d.ts +78 -0
  59. package/write/update.d.ts +23 -1
package/skill/SKILL.md CHANGED
@@ -19,40 +19,68 @@ with a `body` mints a successor with a fresh `uid` and joins the two with a
19
19
  may no longer be the _live_ one — addressing it returns `conflict` and names
20
20
  the successor. Never treat a stored uid as immutable across edits.
21
21
 
22
- Every example below was run against `entrypoint/backlog/dist/index.js` — the
23
- baseline examples on revision `9df2a5c7`, the §8 stats/rollup examples on the
24
- build that first mounted those ops — and its exact output is what is shown. A
22
+ Every example below was run against `entrypoint/backlog/dist/index.js` and its
23
+ exact output is what is shown. The obligations/verdict (§4), spec-pointer (§5),
24
+ and catalog/order (§6) examples were run on the build that mounts all 29 verbs;
25
+ the §11 stats/rollup examples on the build that first mounted those ops. A
25
26
  _globally installed_ `adhd-backlog` may be an older build: in particular
26
- `gitContext` on `create`/`transition` (§6) exists in the `9df2a5c7` build but
27
- an older installed build rejects it with `invalid_argument`, and the §8 stats
28
- ops (`priority-matrix`/`part-of-rollup`/`open-curve`) exist only in a build at
29
- or after the one that mounted them. Compare the `backlog create` and `backlog
30
- priority-matrix` lines of `adhd-backlog --help` with §1 before relying on a
31
- field.
32
-
33
- ## 1. Command surface — 17 verbs (plus `batch`), one calling convention
34
-
35
- **Every verb takes a single `--input` flag carrying one JSON object.** There
36
- are no per-field flags.
27
+ `gitContext` on `create`/`transition` (§9) exists in the `9df2a5c7` build but
28
+ an older installed build rejects it with `invalid_argument`, and the §11 stats
29
+ ops (`priority-matrix`/`part-of-rollup`/`open-curve`/`report`) exist only in a build at
30
+ or after the one that mounted them. Compare the `backlog create`, `backlog
31
+ priority-matrix`, and `backlog obligate` lines of `adhd-backlog --help` with §1
32
+ before relying on a field.
33
+
34
+ ## 1. Command surface — 29 verbs (plus `batch`), one calling convention
35
+
36
+ **Every verb except `embedding-status` takes a single `--input` flag carrying
37
+ one JSON object** (`embedding-status` takes no options at all). There
38
+ are no per-field flags: a per-field option (`get --uid …`, `query --view
39
+ list`, `create --title …`, `batch action --operation …`) is rejected with
40
+ `invalid_argument` (exit 2) and the message
41
+ `Unknown option: --<field>. Available: --input`. The generated `--help`
42
+ footer's "per-field flags are also accepted" line is boilerplate that does
43
+ **not** hold for these commands — `--input` is the only option any verb
44
+ accepts, verified by running it. (The special commands `serve`,
45
+ `install-skill`, and `search` are the exception: they take argv flags and no
46
+ `--input`.)
47
+
48
+ Any verb that takes a `uid` also accepts a **unique uid prefix** (8+ hex
49
+ characters — the first UUID block, e.g. `4fc3704e`). An exact uid always wins;
50
+ a prefix matching two or more live nodes is refused with `ambiguous_reference`
51
+ (exit 1) naming every candidate, and an unmatched prefix is `item_not_found`.
52
+ A prefix shorter than 8 characters is refused as too short.
37
53
 
38
54
  ```
55
+ adhd-backlog backlog add-citation --input '<IAddCitationInput json>'
56
+ adhd-backlog backlog attest --input '<IAttestInput json>'
57
+ adhd-backlog backlog claim --input '<IClaimInput json>'
58
+ adhd-backlog backlog create --input '<ICreateIssueInput json>'
59
+ adhd-backlog backlog delete --input '<IDeleteIssueInput json>'
60
+ adhd-backlog backlog embedding-status (no options — takes neither --input nor any flag)
39
61
  adhd-backlog backlog get --input '<IIssueGetInput json>'
40
- adhd-backlog backlog query --input '<IIssueQueryInput json>'
41
- adhd-backlog backlog priority-matrix --input '<IPriorityMatrixInput json>'
42
- adhd-backlog backlog part-of-rollup --input '<IPartOfRollupInput json>'
62
+ adhd-backlog backlog lookup --input '<ILookupInput json>'
63
+ adhd-backlog backlog merge-project --input '<IMergeProjectInput json>'
64
+ adhd-backlog backlog move --input '<IMoveIssueInput json>'
65
+ adhd-backlog backlog obligate --input '<IObligateInput json>'
43
66
  adhd-backlog backlog open-curve --input '<IOpenCurveInput json>'
44
- adhd-backlog backlog lookup --input '{"q": "<tool, file path, or URL>"}'
45
- adhd-backlog backlog create --input '<ICreateIssueInput json>'
46
- adhd-backlog backlog update --input '<IUpdateIssueInput json>'
47
- adhd-backlog backlog transition --input '<ITransitionInput json>'
48
- adhd-backlog backlog claim --input '<IClaimInput json>'
67
+ adhd-backlog backlog part-of-rollup --input '<IPartOfRollupInput json>'
68
+ adhd-backlog backlog priority-matrix --input '<IPriorityMatrixInput json>'
69
+ adhd-backlog backlog query --input '<IIssueQueryInput json>'
70
+ adhd-backlog backlog recheck --input '<IRecheckInput json>'
49
71
  adhd-backlog backlog relate --input '<IRelateInput json>'
50
- adhd-backlog backlog move --input '<IMoveIssueInput json>'
51
- adhd-backlog backlog upsert-project --input '<IUpsertProjectInput json>'
72
+ adhd-backlog backlog remove-citation --input '<IRemoveCitationInput json>'
73
+ adhd-backlog backlog report --input '<IReportInput json>'
74
+ adhd-backlog backlog rm-location --input '<IRmLocationInput json>'
75
+ adhd-backlog backlog rm-project --input '<IRmProjectInput json>'
76
+ adhd-backlog backlog spec-append --input '<ISpecAppendInput json>'
77
+ adhd-backlog backlog spec-check --input '<ISpecCheckInput json>'
78
+ adhd-backlog backlog transition --input '<ITransitionInput json>'
79
+ adhd-backlog backlog unobligate --input '<IUnobligateInput json>'
80
+ adhd-backlog backlog update --input '<IUpdateIssueInput json>'
52
81
  adhd-backlog backlog upsert-component --input '<IUpsertComponentInput json>'
53
82
  adhd-backlog backlog upsert-location --input '<IUpsertLocationInput json>'
54
- adhd-backlog backlog rm-location --input '<IRmLocationInput json>'
55
- adhd-backlog backlog delete --input '<IDeleteIssueInput json>'
83
+ adhd-backlog backlog upsert-project --input '<IUpsertProjectInput json>'
56
84
  adhd-backlog batch action --input '<IBatchActionInput json>'
57
85
  ```
58
86
 
@@ -67,24 +95,39 @@ accepts both `adhd-backlog get --input …` and
67
95
  $ adhd-backlog --help
68
96
  Available commands:
69
97
 
98
+ Namespaced verbs (namespace + verb, per-field flags):
99
+ batch action { input: { operation: 'backlog/get', items: object[], concurrency?: number, mode?: 'parallel'|'serial'|'chained', onItemError?: 'continue'|'abort', itemTimeoutMs?: number } }
100
+
101
+ Verbs (one-token, --input JSON envelope):
102
+ backlog add-citation { input: { uid: string, citation: object, by: string } }
103
+ backlog attest { input: { subject: object, claim: object, anchor: object, by: string } }
70
104
  backlog claim { input: { uid: string, by: string, action: 'claim'|'release'|'renew', force?: boolean } }
71
- backlog create { input: { title: string, body: string, project: string, component?: string, kind?: string, status?: string, priority?: string, citations?: object[], author?: string, assignee?: string, gitContext?: string, by: string, duplicateAction?: 'abort'|'force'|'comment', awaitEmbed?: boolean } }
105
+ backlog create { input: { title: string, body: string, project: string, component?: string, kind?: string, status?: string, priority?: string, citations?: object[], author?: string, assignee?: string, gitContext?: string, dedupeExcludeUid?: string, by: string, duplicateAction?: 'abort'|'force'|'comment', awaitEmbed?: boolean } }
72
106
  backlog delete { input: { uid: string, reason: string, by: string, awaitEmbed?: boolean } }
73
- backlog get { input: { uid: string, fields?: union[] } | { registry: 'project'|'component'|'location', name: string, filter?: object } }
74
- backlog lookup { input: { q: string } }
107
+ backlog embedding-status
108
+ backlog get { input: { uid: string, fields?: union[], lastN?: number, after?: string, deriveThrough?: 1|2|3|4|5 } | { registry: 'project'|'component'|'location', name: string, filter?: object } }
109
+ backlog lookup { input: { q: string, kind?: string } }
110
+ backlog merge-project { input: { fromUid: string, toUid: string, by: string } }
75
111
  backlog move { input: { uid: string, toProject?: string, toComponent?: string, by: string } }
112
+ backlog obligate { input: { uid: string, applies_to: object, requirement: union, on_fail: 'block'|'warn', override?: object, by: string } }
76
113
  backlog open-curve { input: { filter?: object, at: string[] } }
77
- backlog part-of-rollup { input: { uid: string } }
114
+ backlog part-of-rollup { input: { uid: string, countOnly?: boolean, limit?: number, after?: string } }
78
115
  backlog priority-matrix { input: { filter?: object } }
79
- backlog query { input: { text?: string, filter?: object, fields?: union[], sort?: 'priority'|'updated'|'created'|'relevance'|'textMatch', direction?: 'asc'|'desc', limit?: number, offset?: number, after?: string, view?: 'list'|'ready'|'graph'|'order'|'stale'|'similar'|'overlap'|'projects'|'components'|'locations', format?: 'json'|'markdown', overlapAxis?: 'file'|'project'|'component'|'author', overlapUids?: string[], staleAfterMin?: number } }
80
- backlog relate { input: { sourceUid: string, targetUid: string, rel: 'relates_to'|'supersedes'|'blocks'|'duplicate_of'|'part_of', action: 'add'|'remove', by: string } }
116
+ backlog query { input: { text?: string, filter?: object, fields?: union[], sort?: 'priority'|'updated'|'created'|'relevance'|'textMatch', direction?: 'asc'|'desc', limit?: number, offset?: number, after?: string, view?: 'list'|'ready'|'graph'|'order'|'stale'|'similar'|'overlap'|'projects'|'components'|'locations'|'kinds'|'catalogs', format?: 'json'|'markdown', overlapAxis?: 'file'|'project'|'component'|'author', overlapUids?: string[], staleAfterMin?: number, catalog?: 'kind'|'status'|'priority'|'relation'|'field'|'error_code'|'location_type'|'verb' } }
117
+ backlog recheck { input: { attestationUid: string, by: string } }
118
+ backlog relate { input: { sourceUid: string, targetUid: string, rel: 'relates_to'|'supersedes'|'blocks'|'duplicate_of'|'part_of'|'similar_to', action: 'add'|'remove', by: string } }
119
+ backlog remove-citation { input: { uid: string, by: string, reason?: string } }
120
+ backlog report { input: { filter?: object } }
81
121
  backlog rm-location { input: { uid: string, by: string, reason?: string } }
82
- backlog transition { input: { uid: string, by: string, toStatus: string, note?: string, citations?: object[], gitContext?: string } }
83
- backlog update { input: { uid: string, by: string, title?: string, body?: string, kind?: string, priority?: string, assignee?: string, author?: string, awaitEmbed?: boolean } }
122
+ backlog rm-project { input: { uid: string, reason: string, by: string } }
123
+ backlog spec-append { input: { uid: string, fragment: string, anchor?: object, base_revision: string, by: string } }
124
+ backlog spec-check { input: { uid: string, token?: string } }
125
+ backlog transition { input: { uid: string, by: string, toStatus: string, note?: string, citations?: object[], gitContext?: string, override?: object } }
126
+ backlog unobligate { input: { obligationUid: string, by: string } }
127
+ backlog update { input: { uid: string, by: string, title?: string, body?: string, kind?: string, priority?: string, assignee?: string, author?: string, citations?: object[], awaitEmbed?: boolean } }
84
128
  backlog upsert-component { input: { project: string, name: string, path?: string, description?: string, by: string } }
85
129
  backlog upsert-location { input: { component: string, project?: string, locType: 'path'|'url'|'tool', value: string, by: string } }
86
130
  backlog upsert-project { input: { name: string, path?: string, repoUrl?: string, monorepo?: boolean, description?: string, by: string } }
87
- batch action { input: { operation: 'backlog/get', items: object[], concurrency?: number, mode?: 'parallel'|'serial'|'chained', onItemError?: 'continue'|'abort', itemTimeoutMs?: number } }
88
131
  ```
89
132
 
90
133
  Trust that output over anything hardcoded here — it is the live schema, not
@@ -114,6 +157,23 @@ Combined with the global `--namespace sandbox` flag (valid before any
114
157
  command) it reports the throwaway store that flag would mint, so you can
115
158
  check isolation without creating anything.
116
159
 
160
+ The **`adhdRoot` key is present only when a root was explicitly resolved** —
161
+ the `ADHD_ROOT` env var is set, or `--namespace sandbox` minted one — and is
162
+ **omitted** for the default `production`/`test` namespaces. Verified: plain
163
+ `sandbox-path` prints
164
+ `{"namespace":"production","dbPath":"/…/backlog-v2.db","embeddingEnabled":true}`
165
+ with no `adhdRoot`, while the same call with `ADHD_ROOT=/tmp/foreign-root` (or
166
+ `--namespace sandbox`) adds the key.
167
+
168
+ `--namespace sandbox` also **ignores a foreign `ADHD_ROOT`** rather than
169
+ writing to it: if the variable names a path this tool did not mint as a
170
+ sandbox, it warns
171
+ `[backlog] --namespace sandbox: ADHD_ROOT=<path> is set but is not a sandbox this tool created — ignoring it and minting a fresh isolated store instead, so --namespace sandbox never writes into an unrecognized (possibly production) location.`
172
+ and mints a fresh throwaway store. So a stray `ADHD_ROOT` can never redirect a
173
+ sandboxed run into an unrecognized location — but it also means a sandbox you
174
+ want to *reuse* must be the exact path printed when it was created, not any
175
+ directory you set `ADHD_ROOT` to.
176
+
117
177
  Two conventions apply to every transcript below. **Uids are truncated with
118
178
  `…` for readability** — always pass the FULL value the previous call
119
179
  returned, never the ellipsis form. And **every invocation prints warnings on
@@ -189,13 +249,23 @@ Success is always exit `0`.
189
249
  ### MCP tool names
190
250
 
191
251
  Each verb is also an MCP tool once `.mcp.json` wires the server, named
192
- `backlog_<verb>` with the verb's own words snake_cased: `backlog_get`,
193
- `backlog_query`, `backlog_priority_matrix`, `backlog_part_of_rollup`,
194
- `backlog_open_curve`, `backlog_lookup`, `backlog_create`, `backlog_update`,
195
- `backlog_transition`, `backlog_claim`, `backlog_relate`, `backlog_move`,
196
- `backlog_upsert_project`, `backlog_upsert_component`,
197
- `backlog_upsert_location`, `backlog_rm_location`, `backlog_delete`, plus the
198
- un-namespaced `batch_action`.
252
+ `backlog_<verb>` with the verb's own words snake_cased: `backlog_attest`,
253
+ `backlog_claim`, `backlog_create`, `backlog_delete`,
254
+ `backlog_embedding_status`,
255
+ `backlog_get`, `backlog_lookup`, `backlog_merge_project`, `backlog_move`,
256
+ `backlog_obligate`, `backlog_open_curve`, `backlog_part_of_rollup`,
257
+ `backlog_priority_matrix`, `backlog_query`, `backlog_recheck`, `backlog_relate`,
258
+ `backlog_report`, `backlog_rm_location`, `backlog_rm_project`,
259
+ `backlog_spec_append`, `backlog_spec_check`, `backlog_transition`,
260
+ `backlog_unobligate`, `backlog_update`, `backlog_upsert_component`,
261
+ `backlog_upsert_location`, `backlog_upsert_project`, plus the un-namespaced
262
+ `batch_action` — 29 verbs + `batch`.
263
+
264
+ The tool list a session sees is the MCP **server process's** build, which can
265
+ lag the CLI: a long-lived `serve` started before a verb was mounted does not
266
+ expose it until restarted. If a verb works on the CLI but its
267
+ `mcp__backlog__*` tool is absent, restart the server rather than assuming the
268
+ verb is unmounted.
199
269
 
200
270
  ## 3. Issue verbs — worked examples
201
271
 
@@ -205,9 +275,9 @@ missing/blank `by` is rejected with `invalid_argument` before any write
205
275
  runs.
206
276
 
207
277
  **File a new issue.** `project` is RESOLVE-ONLY — `create` never mints one;
208
- register it first with `upsert-project` (§4). `component` is also resolve-only
278
+ register it first with `upsert-project` (§7). `component` is also resolve-only
209
279
  and defaults to the project's reserved `(root)` component when omitted — pass
210
- it, or the item is invisible to component-scoped scans (§4, "The filing rule"):
280
+ it, or the item is invisible to component-scoped scans (§7, "The filing rule"):
211
281
 
212
282
  ```
213
283
  $ adhd-backlog backlog create --input '{
@@ -220,8 +290,29 @@ $ adhd-backlog backlog create --input '{
220
290
  {"ok":true,"data":{"created":true,"uid":"a61ff0b6-a0f1-4189-9923-671f6cbacd4e","item":{"uid":"a61ff0b6-a0f1-4189-9923-671f6cbacd4e","title":"Flaky test in auth module","kind":"issue","status":"open","priority":"HIGH","project":"020e87f2-…","component":"dcf134ab-…","createdAt":"2026-09-17T01:22:56.056Z","author":"claude:1"}}}
221
291
  ```
222
292
 
293
+ `data` carries three more fields beyond `created`/`uid`/`item`:
294
+
295
+ - **`placementResolved`** — `'explicit'` when a caller-supplied `component`
296
+ resolved, or `'default-root'` when the omitted `component` fell back to the
297
+ project's reserved `(root)` component. A supplied component that does NOT
298
+ resolve still throws before this field is reached, so `'default-root'` is
299
+ the only signal that an item was silently filed on `(root)` (and is thus
300
+ invisible to component-scoped scans — §7's filing rule).
301
+ - **`duplicateScanDegraded` / `duplicateScanDegradedReason`** — present iff
302
+ the pre-write dedupe scan could not run a calibrated comparison, on EVERY
303
+ outcome (even a successful write). The reason is a concrete
304
+ `'no-search-backend' | 'no-embed-query' | 'no-vector-scores'`, never a
305
+ generic "unavailable". Verified: with embeddings off, a `create` against a
306
+ non-empty store returns
307
+ `"duplicateScanDegraded":true,"duplicateScanDegradedReason":"no-search-backend"`.
308
+ Both fields are **absent on a healthy scan**. When the scan is degraded in
309
+ the never-resolvable `'no-embed-query'` way AND `duplicateAction` is the
310
+ default `'abort'`, nothing is written and the result is
311
+ `{created:false, reason:"duplicate-scan-degraded", duplicateScanDegraded:true,
312
+ duplicateScanDegradedReason:"no-embed-query"}` instead.
313
+
223
314
  Filing more than a handful of similar issues in a row? Use `batch action`
224
- (§5) instead of repeating this call.
315
+ (§8) instead of repeating this call.
225
316
 
226
317
  `create` runs a dedupe scan (FTS + semantic, when embeddings are configured)
227
318
  BEFORE writing. `duplicateAction` (default `'abort'`) controls what happens
@@ -250,13 +341,29 @@ $ adhd-backlog backlog get --input '{"uid":"a61ff0b6-…","fields":["body","cita
250
341
  registry entry by name directly, e.g.
251
342
  `{"registry":"project","name":"demo-project"}` returns the project's
252
343
  `{uid, name, path, components, locations}` — the same data `query`'s
253
- `view:"projects"`/`"components"`/`"locations"` list in bulk (§4).
344
+ `view:"projects"`/`"components"`/`"locations"` list in bulk (§7).
254
345
 
255
346
  The full field vocabulary is `uid, title, kind, status, priority, project,
256
- component, createdAt, updatedAt, assignee, author, closedAt` (cheap/plain)
257
- plus `body, citations, notes, auditTrail, blockers, related, _score,
347
+ component, createdAt, updatedAt, assignee, author, closedAt, gitContext`
348
+ (cheap/plain) plus `body, citations, notes, auditTrail, blockers, related,
349
+ blocksOut, dependents, partOf, obligations, verdict, spec, similar, _score,
258
350
  _vector` (opt-in only — each costs a genuine extra read, so none is in the
259
- default card).
351
+ default card). `similar` is the reviewed `similar_to` links touching an item,
352
+ both directions (`{similarTo:[…], similarFrom:[…]}`) — a different relation
353
+ from the reserved `duplicate_of`, so a card that does not ask for it is
354
+ unchanged. `blocksOut` is the outbound `blocks` refs; `dependents` is the
355
+ transitive count of nodes that reach this one via `blocks`; `partOf` is the
356
+ single `part_of` parent (or `null`); `obligations` is the declared obligation
357
+ views (§4); `verdict` is the derived actionability verdict (§4); `spec` is the
358
+ spec-revision pointer (§5). `_score_kind` is **not** a requestable field — it
359
+ is a provenance tag emitted automatically alongside `_score` whenever a score
360
+ is requested (`'rrf'` for a fused semantic rank, `'bm25'` for a grep-only FTS
361
+ score, `'cosine'`/`'rank'`/`'priority'` otherwise); passing it in `fields`
362
+ rejects as an unknown field. A rank-derived score is ordinal, never a
363
+ similarity. `get { fields:["auditTrail"], lastN:5 }` bounds the
364
+ sub-collection to its newest tail; `after` continues from the last returned
365
+ uid of a bounded sub-collection, and `deriveThrough:1..5` caps the verdict
366
+ ladder rung (§4).
260
367
 
261
368
  **Search/filter/page issues:**
262
369
 
@@ -267,13 +374,27 @@ $ adhd-backlog backlog query --input '{"filter":{"project":"demo-project","statu
267
374
 
268
375
  `query.view` (default `'list'`) selects the result shape: `list` · `ready` ·
269
376
  `graph` · `order` · `stale` · `similar` · `overlap` · `projects` · `components`
270
- · `locations` (the last three are the registry LIST views — §4). `text` is the
377
+ · `locations` (the last three are the registry LIST views — §7). **`ready` is
378
+ NOT an actionability filter** — it selects the `open` issues that are
379
+ **unclaimed** and whose every live incoming `blocks` blocker is **terminal**
380
+ (a pure claim/`blocks` predicate; it never evaluates obligations, evidence, or
381
+ attestations). An item whose `block`-severity obligation is unsatisfied **and
382
+ scoped to a non-terminal transition** — `fields:["verdict"]` returns
383
+ `actionable:false` — still appears in `ready` (a *close*-scoped obligation does
384
+ not gate claimability and leaves the item `actionable:true`), while an item
385
+ with an open `blocks` source does not. **To find actionable
386
+ work, read `fields:["verdict"]`; do not send an actionable-work query to
387
+ `view:"ready"`.** `text` is the
271
388
  natural-language form — routed to `filter.semantic` when a populated vector
272
389
  space can rank it, or `filter.grep` (keyword FTS) otherwise; never set
273
390
  `text` alongside `filter.semantic`/`filter.grep` yourself. Pagination is
274
391
  truthful: `meta.total` is the count before `limit`/`offset`, `meta.returned`
275
392
  is `data.items.length`, and a page cut short for any reason other than your
276
- own `limit` sets `meta.truncated`.
393
+ own `limit` sets `meta.truncated`. The four item-list views
394
+ (`list`/`ready`/`stale`/`similar`) also carry `meta.has_more`; where a true
395
+ pre-limit total is unknowable at bounded cost (`ready`/`stale`/`similar`),
396
+ `meta.total_relation: 'gte'` labels `meta.total` as a lower bound.
397
+ `graph`/`order`/`overlap` carry no `meta` by design.
277
398
 
278
399
  **Edit an existing issue.** Every field edits in place **except `body`**: a
279
400
  `body` change SUPERSEDES the issue, minting a successor node with a fresh
@@ -299,6 +420,38 @@ $ adhd-backlog backlog get --input '{"uid":"a61ff0b6-…"}'
299
420
  {"ok":false,"error":{"code":"conflict","message":"Issue \"a61ff0b6-…\" was superseded by a body edit and is no longer the live issue; it now lives under \"e3b32183-…\"","details":{"retryable":false}}}
300
421
  ```
301
422
 
423
+ **Add or remove a single citation.** `add-citation` attaches one citation to an
424
+ existing issue (a `citation` node + a `has_citation` edge + an audit row, in one
425
+ transaction) and returns the citation's own `uid`. `remove-citation` retires one
426
+ by that `uid` (never by re-specifying `(file, lines)`) and leaves the issue's
427
+ `uid` untouched:
428
+
429
+ ```
430
+ $ adhd-backlog backlog add-citation --input '{"uid":"a61ff0b6-…","by":"claude:1","citation":{"file":"packages/auth/src/index.ts","lines":"1-1"}}'
431
+ {"ok":true,"data":{"uid":"c1e2f3a4-…","issueUid":"a61ff0b6-…","citation":{"uid":"c1e2f3a4-…","file":"packages/auth/src/index.ts","lines":"1-1","sha":"9f2c…","at":"…"}}}
432
+
433
+ $ adhd-backlog backlog remove-citation --input '{"uid":"c1e2f3a4-…","by":"claude:1","reason":"superseded by a rewrite"}'
434
+ {"ok":true,"data":{"uid":"c1e2f3a4-…","invalidated":true}}
435
+ ```
436
+
437
+ `update` carries a **desired citation set** as a diff: it adds what's new and
438
+ removes what's gone against the live set, inside one transaction. It never mints
439
+ a new issue `uid` — only a `body` edit supersedes:
440
+
441
+ ```
442
+ $ adhd-backlog backlog update --input '{"uid":"a61ff0b6-…","by":"claude:1","citations":[{"file":"packages/auth/src/index.ts","lines":"1-1"}]}'
443
+ {"ok":true,"data":{"uid":"a61ff0b6-…","changed":["citations"]}}
444
+ ```
445
+
446
+ **Cite a branch-only file with `revision`.** A citation whose `file` exists only
447
+ on an unmerged branch is unreadable from the working tree; pin it to the branch
448
+ and the sha is resolved from `git show <revision>:<path>` instead:
449
+
450
+ ```
451
+ $ adhd-backlog backlog add-citation --input '{"uid":"a61ff0b6-…","by":"claude:1","citation":{"file":"packages/auth/src/index.ts","revision":"feat/auth"}}'
452
+ {"ok":true,"data":{"uid":"c1e2f3a4-…","issueUid":"a61ff0b6-…","citation":{"uid":"c1e2f3a4-…","file":"packages/auth/src/index.ts","revision":"feat/auth","sha":"9f2c…","at":"…"}}}
453
+ ```
454
+
302
455
  **Move an issue to a new status.** A terminal `toStatus` REQUIRES `citations`
303
456
  only when the project's policy turns citation enforcement ON — `citationRequired`
304
457
  defaults to `false`, so out of the box no citation is required. When a project
@@ -348,7 +501,7 @@ $ adhd-backlog backlog claim --input '{"uid":"a61ff0b6-…","by":"claude:1","act
348
501
  ```
349
502
 
350
503
  **Link two issues.** `rel` is one of `relates_to`, `supersedes`, `blocks`,
351
- `duplicate_of`, `part_of` — NOT the bare word `"related"`:
504
+ `duplicate_of`, `part_of`, `similar_to` — NOT the bare word `"related"`:
352
505
 
353
506
  ```
354
507
  $ adhd-backlog backlog relate --input '{"sourceUid":"777c5e33-…","targetUid":"a61ff0b6-…","rel":"relates_to","action":"add","by":"claude:1"}'
@@ -360,6 +513,21 @@ found none — no edge was written and no audit row produced; never assume
360
513
  every call was a fresh write. `targetUid` may belong to a different project
361
514
  than `sourceUid`.
362
515
 
516
+ **Similarity (advisory scan, reviewed link).** Cross-project similarity is a
517
+ two-step, deliberately non-automatic flow:
518
+
519
+ - The scan surfaces **candidates**; it never writes a link. `create` reports
520
+ them on `similarCandidates` (scope-controlled by the project policy's
521
+ `similarityScope`: `same-project` default · `multi-project` · `store-wide`),
522
+ and `query {view:"similar", filter:{project}}` returns a `clusters` block
523
+ (`candidate` = advisory scan output, `linked` = existing links).
524
+ - A reviewed link is the **existing `relate`** verb with
525
+ `rel:"similar_to"` (there is **no** `link-duplicate` verb). `similar_to` is
526
+ `n:m` issue→issue. `duplicate_of` stays **reserved** for the reviewed
527
+ actual-same judgement and is never written by a scan.
528
+ - Filter reads with `filter.similarTo` (items linked `similar_to` X) or
529
+ `filter.hasSimilar:true` (items with ≥1 incoming link).
530
+
363
531
  **Move an issue to a different project/component.** `toProject`/
364
532
  `toComponent` are RESOLVE-ONLY, never minted — register the destination
365
533
  first with `upsert-project`/`upsert-component` if it doesn't exist yet:
@@ -378,7 +546,404 @@ $ adhd-backlog backlog delete --input '{"uid":"777c5e33-…","reason":"duplicate
378
546
  {"ok":true,"data":{"uid":"777c5e33-…","invalidated":true}}
379
547
  ```
380
548
 
381
- ## 4. Registry — project / component / location
549
+ **Attest anchored evidence.** `attest` creates a SEPARATE `attestation` node
550
+ keyed to an issue — it never changes the issue's `uid` or revision. The anchor
551
+ is a `locator` (`path:<file>[:<line>]`, `url:<url>`, `query:<cql>`,
552
+ `registry:<ref>`) plus a `digest`; a `path:` anchor is checked against the
553
+ project's git tree by a cheap-first ladder and its `check.state` is one of
554
+ `verified` / `stale` / `unknown` / `unverified` (always present, with a
555
+ `reason`). `recheck` APPENDS a fresh check to the record's `checks[]` history:
556
+
557
+ ```
558
+ $ adhd-backlog backlog attest --input '{"subject":{"id":"777c5e33-…","revision":0},"claim":{"kind":"published-artifact"},"anchor":{"locator":"path:dist/index.js","digest":"<sha256>"},"by":"claude:1"}'
559
+ {"ok":true,"data":{"attestationUid":"…","subject":{"id":"777c5e33-…","revision":0},"check":{"state":"verified","method":"changed_since","checked_at":"…","checked_by":"claude:1"}}}
560
+ $ adhd-backlog backlog recheck --input '{"attestationUid":"<attestationUid>","by":"claude:1"}'
561
+ {"ok":true,"data":{"attestationUid":"…","checks":[{"state":"verified",…},{"state":"stale",…}]}}
562
+ ```
563
+
564
+ **Prerequisite for `path:` anchors — the subject project's registered `path`
565
+ must be a git work tree.** The anchor ladder resolves a `path:` locator by
566
+ running git against that project path, so `check.state:"verified"` is produced
567
+ only when the path is a real git work tree containing the file. With a
568
+ **non-git** registered project path the real result is
569
+ `{"state":"unknown","method":"none","reason":"no git work tree at \"<path>\""}`
570
+ — `verified` is never produced, so a `block`-severity `evidence` obligation of
571
+ that kind **stays unsatisfied and the close stays refused**. A `url:` anchor is
572
+ likewise only `unverified`
573
+ (`reason:"no mechanical checker is wired for \"url:\" anchors yet"`), and a
574
+ `revision:` anchor (a spec-revision annotation, recorded through `attest`) is
575
+ `unverified` too. Without a git
576
+ work tree to verify against, satisfy such an obligation another way: register
577
+ the project with a `path` that **is** the real git work tree, use
578
+ `on_fail:"warn"` (a warn obligation never gates a transition), or list your
579
+ actor in the obligation's `override.actors` and pass a recorded
580
+ `override.reason` (§4 "Overriding a refusing obligation").
581
+
582
+ ## 4. Obligations, attestations & the actionability verdict
583
+
584
+ An **obligation** is a typed, stored requirement on an issue, scoped to a
585
+ transition. `obligate` declares one; the write gate REFUSES a transition that
586
+ would violate it; `fields:["verdict"]` predicts that gate ON READ. Nothing is
587
+ evaluated at `obligate` time — the gate and the verdict do the evaluating.
588
+
589
+ ### Declaring an obligation
590
+
591
+ `obligate { uid, applies_to, requirement, on_fail, override?, by }`:
592
+
593
+ - `applies_to.to` — REQUIRED. Scopes a TRANSITION into that status: a concrete
594
+ status name, or `'*'` for "any terminal transition". `applies_to.from`
595
+ optionally narrows the source status; absent ⇒ any from-status.
596
+ - `requirement` — the CLOSED predicate core (six productions; no CEL, no
597
+ dynamic leaf, no timeout). It is validated recursively BEFORE the write lock,
598
+ so a malformed predicate never holds it:
599
+ - `{ "op": "evidence", "kind": <string>, "min"?: <int ≥1> }` — at least
600
+ `min ?? 1` distinct **verified** attestations of `kind` (a `check.state` of
601
+ `verified`).
602
+ - `{ "op": "blockers_terminal" }` — every incoming live `blocks` source is
603
+ terminal (a missing status is non-terminal, fail-closed).
604
+ - `{ "op": "relation", "type": <edge kind>, "direction": "in"|"out" }` — a
605
+ live edge of `type` touches the subject (`in` = subject is `dst`, `out` =
606
+ `src`).
607
+ - `{ "op": "all_of", "of": [ … ] }` — empty list ⇒ `true`.
608
+ - `{ "op": "any_of", "of": [ … ] }` — empty list ⇒ `false`.
609
+ - `{ "op": "not", "of": { … } }`.
610
+ - `on_fail` — `'block'` refuses the transition; `'warn'` records the shortfall
611
+ but allows it. A `warn` obligation never affects `actionable`.
612
+ - `override` — `{ "actors": [<identity>, …] }`: the identities permitted to
613
+ override THIS obligation. An override ALWAYS requires a recorded reason
614
+ (below); it is never a configurable boolean.
615
+
616
+ Read the obligations back with `get … fields:["obligations"]` — the STORED
617
+ predicate, never an evaluation:
618
+
619
+ ```
620
+ $ adhd-backlog get --input '{"uid":"1d9b77e5-…","fields":["obligations"]}'
621
+ {"ok":true,"data":{"uid":"1d9b77e5-…","obligations":[{"uid":"fbcee200-…","applies_to":{"to":"closed"},"requirement":{"op":"evidence","kind":"published-artifact","min":1},"on_fail":"block"}]}}
622
+ ```
623
+
624
+ ### The verdict (derived on read, never stored)
625
+
626
+ `fields:["verdict"]` returns the actionability verdict:
627
+
628
+ ```jsonc
629
+ { "actionable": true | false | "unknown", // TRI-STATE — never a bare boolean
630
+ "evaluated_at": "…",
631
+ "revision": 0, // the issue's content revision
632
+ "conditions": [ // block-severity first, then warn
633
+ { "type": "Evidence", "status": "True", "severity": "block",
634
+ "code": "EvidenceUnverified", "subject": "<obligationUid>", "message": "…" } ] }
635
+ ```
636
+
637
+ `actionable` is `false` iff at least one `block` condition is `True`;
638
+ `"unknown"` iff a `block` condition is `Unknown` and none is `True` (the honest
639
+ "could not decide" — **never a green light**); otherwise `true`. `status` says
640
+ whether the NAMED condition HOLDS (`{type:"Blocked",status:"True"}` ⇒ the item
641
+ IS blocked). `type` ∈ `Blocked | Obligation | Evidence | Claim | Reference |
642
+ Budget`; `severity` ∈ `block | warn`; the governed `code`s are `BlockedBy`,
643
+ `MissingObligation`, `EvidenceUnverified`, `EvidenceStale`, `ClaimStale`,
644
+ `ReferenceUnresolved`, `Unknown`, plus a `<domain>/<Code>` extension namespace.
645
+ An obligation-free item reports a `warn` `MissingObligation` — reported, never
646
+ blocking.
647
+
648
+ The verdict runs a cheap-first ladder with a per-read budget (`deriveThrough`
649
+ on `get`, 1–5). `get` defaults to rung 3; the list views always run rung 2:
650
+
651
+ | rung | what runs | produces |
652
+ | ---- | ------------------------------------------------------------ | ---------------------------------------------- |
653
+ | 1 | incoming live `blocks` (indexed in-degree) | `Blocked`/`BlockedBy` per non-terminal blocker |
654
+ | 2 | obligation presence + close-predictor skip + predicate evaluation + claim staleness | `Obligation`/`Evidence`/`Claim` |
655
+ | 3 | anchor existence at HEAD | `EvidenceStale` when absent |
656
+ | 4 | changed-since-filing | `EvidenceStale` when moved since filing |
657
+ | 5 | full anchor re-resolve (digest match) | `EvidenceStale` on digest mismatch |
658
+
659
+ Where a rung was not run the condition is `Unknown`, so the list and `get`
660
+ paths can never disagree `true` vs `false` for the same item.
661
+
662
+ ### End-to-end: declare → read → refuse → attest → close
663
+
664
+ Declare the obligation, then read it. An obligation is scoped by
665
+ `applies_to.to` to the **transition** it guards — a close-scoped obligation
666
+ gates the CLOSE, not claimability — so `get` reports the item actionable (the
667
+ same answer `claim` gives), and the close is what gets refused:
668
+
669
+ ```
670
+ $ adhd-backlog obligate --input '{"uid":"1d9b77e5-…","applies_to":{"to":"closed"},"requirement":{"op":"evidence","kind":"published-artifact","min":1},"on_fail":"block","by":"agent:worker-1"}'
671
+ {"ok":true,"data":{"uid":"1d9b77e5-…","obligationUid":"fbcee200-…"}}
672
+
673
+ $ adhd-backlog get --input '{"uid":"1d9b77e5-…","fields":["verdict","obligations"]}'
674
+ {"ok":true,"data":{"uid":"1d9b77e5-…","obligations":[{"uid":"fbcee200-…","applies_to":{"to":"closed"},"requirement":{"op":"evidence","kind":"published-artifact","min":1},"on_fail":"block"}],"verdict":{"actionable":true,"evaluated_at":"…","revision":0,"conditions":[]}}}
675
+ ```
676
+
677
+ An obligation scoped to a **non-terminal** transition (e.g.
678
+ `applies_to:{to:"in_progress"}`) IS due at claim time and appears as a rung-2
679
+ block condition instead.
680
+
681
+ Attempt the close — the gate refuses with `precondition_failed` and NOTHING is
682
+ written (the throw rolls the transaction back; a re-read shows the status
683
+ unchanged):
684
+
685
+ ```
686
+ $ adhd-backlog transition --input '{"uid":"1d9b77e5-…","by":"agent:worker-1","toStatus":"closed","note":"attempted close before evidence"}'
687
+ {"ok":false,"error":{"code":"precondition_failed","message":"Transition refused: EvidenceUnverified (requires \"published-artifact\") — no verified attestation of kind \"published-artifact\" satisfies this obligation","details":{"retryable":false}}}
688
+ ```
689
+
690
+ Attest the evidence. `attest` mints a SEPARATE `attestation` node — the issue's
691
+ `uid` and revision are untouched. The anchor is `locator + digest`; a `path:`
692
+ anchor is resolved against the subject project's registered `path` by the
693
+ cheap-first anchor ladder, and its `check.state` is one of
694
+ `verified | stale | unknown | unverified` with a `reason` when not verified.
695
+ `recheck { attestationUid, by }` APPENDS a fresh check to the record's
696
+ `checks[]` history (`attest` itself returns the single `check`):
697
+
698
+ ```
699
+ $ adhd-backlog attest --input '{"subject":{"id":"1d9b77e5-…","revision":0},"claim":{"kind":"published-artifact"},"anchor":{"locator":"path:README.md","digest":"<sha256 hex of the HEAD blob>"},"by":"agent:worker-1"}'
700
+ {"ok":true,"data":{"attestationUid":"8f3604b9-…","subject":{"id":"1d9b77e5-…","revision":0},"check":{"state":"verified","method":"changed_since","checked_at":"…","checked_by":"agent:worker-1"}}}
701
+ ```
702
+
703
+ That `verified` requires the subject project's registered `path` to be a **git
704
+ work tree** containing the file. Against a non-git project path the same call
705
+ returns `state:"unknown"`, `method:"none"`,
706
+ `reason:"no git work tree at \"<path>\""`, and the transition below stays
707
+ refused — see §3's `attest` note for the remedies.
708
+
709
+ Now the close succeeds:
710
+
711
+ ```
712
+ $ adhd-backlog transition --input '{"uid":"1d9b77e5-…","by":"agent:worker-1","toStatus":"closed","note":"evidence published"}'
713
+ {"ok":true,"data":{"uid":"1d9b77e5-…","fromStatus":"open","toStatus":"closed","closedAt":"…","transitionUid":"f42dd635-…"}}
714
+ ```
715
+
716
+ For an obligation scoped to a **non-terminal** transition — which IS evaluated
717
+ at rung 2 — the default `get` rung (3) finds the predicate satisfied but does
718
+ not re-resolve the anchor's freshness, so `actionable` is honestly `"unknown"`;
719
+ raise `deriveThrough:5` to re-resolve it to `true`:
720
+
721
+ ```
722
+ $ adhd-backlog get --input '{"uid":"1d9b77e5-…","fields":["verdict"]}'
723
+ {"ok":true,"data":{"uid":"1d9b77e5-…","verdict":{"actionable":"unknown","evaluated_at":"…","revision":0,"conditions":[{"type":"Evidence","status":"Unknown","severity":"block","code":"Unknown","subject":"fbcee200-…","message":"the \"published-artifact\" anchor presence was confirmed but freshness was not re-resolved at this rung"}]}}}
724
+
725
+ $ adhd-backlog get --input '{"uid":"1d9b77e5-…","fields":["verdict"],"deriveThrough":5}'
726
+ {"ok":true,"data":{"uid":"1d9b77e5-…","verdict":{"actionable":true,"evaluated_at":"…","revision":1,"conditions":[]}}}
727
+ ```
728
+
729
+ ### Overriding a refusing obligation
730
+
731
+ If the obligation lists your identity in `override.actors`, the transition is
732
+ allowed with a recorded `override.reason` (a reason is ALWAYS required):
733
+
734
+ ```
735
+ $ adhd-backlog obligate --input '{"uid":"34219cf9-…","applies_to":{"to":"closed"},"requirement":{"op":"evidence","kind":"never-produced","min":1},"on_fail":"block","override":{"actors":["agent:worker-1"]},"by":"agent:worker-1"}'
736
+ {"ok":true,"data":{"uid":"34219cf9-…","obligationUid":"7dbcc8b6-…"}}
737
+
738
+ $ adhd-backlog transition --input '{"uid":"34219cf9-…","by":"agent:worker-1","toStatus":"closed","note":"override recorded","override":{"reason":"reviewed by owner"}}'
739
+ {"ok":true,"data":{"uid":"34219cf9-…","fromStatus":"open","toStatus":"closed","closedAt":"…","transitionUid":"9a006480-…"}}
740
+ ```
741
+
742
+ A non-listed actor (or a blank reason) is refused:
743
+
744
+ ```
745
+ $ adhd-backlog transition --input '{"uid":"c66f7368-…","by":"agent:other","toStatus":"closed","note":"not listed","override":{"reason":"trying anyway"}}'
746
+ {"ok":false,"error":{"code":"precondition_failed","message":"Override not permitted: actor \"agent:other\" may not override obligation \"f85373dd-…\" (the actor is not listed in override.actors, or no override reason was recorded — an override always requires a reason)","details":{"retryable":false}}}
747
+ ```
748
+
749
+ ### Retiring an obligation
750
+
751
+ `unobligate { obligationUid, by }` soft-invalidates the obligation
752
+ (bi-temporal, never a hard delete): it stops appearing on the card and the gate
753
+ stops refusing.
754
+
755
+ ```
756
+ $ adhd-backlog unobligate --input '{"obligationUid":"fbcee200-…","by":"agent:worker-1"}'
757
+ {"ok":true,"data":{"obligationUid":"fbcee200-…","invalidated":true}}
758
+ ```
759
+
760
+ ## 5. Spec revisions — the spec pointer
761
+
762
+ A work product is a REVISION of its ticket (DESIGN §12). `spec-append` mints a
763
+ new immutable `SPEC` node holding a fragment and advances the ticket's
764
+ `meta.spec_revision` pointer IN PLACE — the ticket's `uid` is preserved and no
765
+ prior revision is rewritten. `spec-check` tells a reader whether the token it
766
+ holds is still current.
767
+
768
+ ### `spec-append { uid, fragment, anchor?, base_revision, by }`
769
+
770
+ - `uid` — the ticket; any uid on its `SUPERSEDES` chain is resolved forward.
771
+ - `fragment` — the delta appended at this revision. The base document is the
772
+ base revision's own fragment; the document is the fold over the chain.
773
+ - `anchor?` — `{ locator, digest }` for the long-form file export (a
774
+ `revision:<uid>` locator addresses the revision itself).
775
+ - `base_revision` — REQUIRED CAS token: the current `spec_revision` uid the
776
+ caller read, or `''` when the ticket has no spec yet. A stale value is
777
+ refused with `precondition_failed` and NOTHING is written.
778
+
779
+ Outcome: `{ uid (UNCHANGED), spec_revision (the NEW revision uid),
780
+ spec_revision_token ('sha256:<hex>'), revision_seq }`.
781
+
782
+ ### `spec-check { uid, token? }`
783
+
784
+ - `state` ∈ `fresh | stale | unknown`; `method` ∈
785
+ `token | content_hash | ancestry | none`.
786
+ - **An ABSENT `token` is `stale`** (`method:"none"`,
787
+ `reason:"no-token-supplied"`) — never `fresh`. A reader that holds no token
788
+ must not treat the spec as current.
789
+ - A non-matching token is `stale` (`method:"token"`, `reason:"older-token"`).
790
+
791
+ ### The `spec` field
792
+
793
+ `get … fields:["spec"]` projects the pointer onto the card — the revision uid,
794
+ its `'sha256:<hex>'` token, and the sequence; never the revision body. Absent
795
+ when the item has no spec.
796
+
797
+ ### Worked example
798
+
799
+ A ticket with no spec yet passes `base_revision:""` (the appended fragment is
800
+ `# Design\nFirst cut of the design.`):
801
+
802
+ ```
803
+ $ adhd-backlog spec-append --input '{"uid":"c79b52b0-…","fragment":"# Design\nFirst cut of the design.","base_revision":"","by":"agent:worker-1"}'
804
+ {"ok":true,"data":{"uid":"c79b52b0-…","spec_revision":"58b5dfd8-…","spec_revision_token":"sha256:d0bcba4a…","revision_seq":1}}
805
+
806
+ $ adhd-backlog get --input '{"uid":"c79b52b0-…","fields":["spec"]}'
807
+ {"ok":true,"data":{"uid":"c79b52b0-…","spec":{"spec_revision":"58b5dfd8-…","spec_revision_token":"sha256:d0bcba4a…","revision_seq":1}}}
808
+ ```
809
+
810
+ Check it, with the token and then without:
811
+
812
+ ```
813
+ $ adhd-backlog spec-check --input '{"uid":"c79b52b0-…","token":"sha256:d0bcba4a…"}'
814
+ {"ok":true,"data":{"current_revision":"58b5dfd8-…","current_token":"sha256:d0bcba4a…","state":"fresh","method":"token"}}
815
+
816
+ $ adhd-backlog spec-check --input '{"uid":"c79b52b0-…"}'
817
+ {"ok":true,"data":{"current_revision":"58b5dfd8-…","current_token":"sha256:d0bcba4a…","state":"stale","method":"none","reason":"no-token-supplied"}}
818
+ ```
819
+
820
+ Appending against a stale base is refused; appending against the current
821
+ revision advances the pointer to `revision_seq:2` while the ticket `uid` stays
822
+ the same:
823
+
824
+ ```
825
+ $ adhd-backlog spec-append --input '{"uid":"c79b52b0-…","fragment":"# Design\nSecond cut.","base_revision":"stale-rev-uid","by":"agent:worker-1"}'
826
+ {"ok":false,"error":{"code":"precondition_failed","message":"spec revision conflict: base_revision \"stale-rev-uid\" does not match the current revision \"58b5dfd8-…\" — re-read the ticket's spec_revision and retry","details":{"retryable":false}}}
827
+
828
+ $ adhd-backlog spec-append --input '{"uid":"c79b52b0-…","fragment":"# Design\nSecond cut.","base_revision":"58b5dfd8-…","by":"agent:worker-1"}'
829
+ {"ok":true,"data":{"uid":"c79b52b0-…","spec_revision":"20efdc96-…","spec_revision_token":"sha256:149a2200…","revision_seq":2}}
830
+ ```
831
+
832
+ ### Annotating a revision (through `attest`)
833
+
834
+ There is no dedicated `annotate` verb: a comment on a spec REVISION is a plain
835
+ `attest` call whose `subject.id` is the revision uid, whose `subject.revision`
836
+ is its opaque `'sha256:<hex>'` token, and whose `claim.kind` is
837
+ `"spec-annotation"`. The `anchor` is `{ locator: "revision:<revision uid>",
838
+ digest: <token> }`. The comment is a separate `attestation` node keyed to the
839
+ exact revision and is never written into any body, so a comment on revision _n_
840
+ can never drift onto _n+1_.
841
+
842
+ ```
843
+ $ adhd-backlog attest --input '{"subject":{"id":"20efdc96-…","revision":"sha256:149a2200…"},"claim":{"kind":"spec-annotation","body":"Reviewed: add the failure-mode section."},"anchor":{"locator":"revision:20efdc96-…","digest":"sha256:149a2200…"},"by":"agent:worker-1"}'
844
+ {"ok":true,"data":{"attestationUid":"0fa85138-…","subject":{"id":"20efdc96-…","revision":"sha256:149a2200…"},"check":{"state":"unverified","method":"none","checked_at":"…","checked_by":"agent:worker-1","reason":"no mechanical checker is wired for \"revision:\" anchors yet"}}}
845
+ ```
846
+
847
+ No mechanical checker is wired for `revision:` anchors yet, so `check.state` is
848
+ `unverified` — the token is RECORDED, not validated.
849
+
850
+ ## 6. Read views — catalogs, registry lists, and dependency order
851
+
852
+ `query.view` (default `'list'`) selects the result shape: `list` · `ready` ·
853
+ `graph` · `order` · `stale` · `similar` · `overlap` · `projects` · `components`
854
+ · `locations` · `kinds` · `catalogs`. The registry list views
855
+ (`projects`/`components`/`locations`) are §7. The catalog views and `order` are
856
+ below. (`ready`'s exact predicate — `open` ∧ unclaimed ∧ every live incoming
857
+ `blocks` blocker terminal — is defined in §3; it is **not** actionability, for
858
+ which read `fields:["verdict"]`.)
859
+
860
+ ### Catalogs — the live vocabularies
861
+
862
+ `view:"kinds"` returns EVERY catalog: the catalog names, a `terms[]` array, and
863
+ a case-collision flag:
864
+
865
+ ```
866
+ $ adhd-backlog query --input '{"view":"kinds"}'
867
+ {"ok":true,"data":{"view":"kinds","catalogs":{"catalogs":["kind","status","priority","relation","field","error_code","location_type","verb"],"terms":[{"name":"issue","uid":"fcfc79f5-…","catalog":"kind","source":"store","lifecycle":"active","usageCount":6},{"name":"closed","catalog":"status","source":"reserved_terminal_status_names","lifecycle":"active"},{"name":"blocks","catalog":"relation","source":"edge_kind_table","lifecycle":"active"},{"name":"verdict","catalog":"field","source":"issue_field_union","lifecycle":"active"},{"name":"precondition_failed","catalog":"error_code","source":"error_code_union","lifecycle":"active"},{"name":"path","catalog":"location_type","source":"valid_location_types","lifecycle":"active"},{"name":"obligate","catalog":"verb","source":"mounted_verb_surface","lifecycle":"active"}, …],"hasCaseCollisions":false}}}
868
+ ```
869
+
870
+ `view:"catalogs"` returns the same `terms[]` at the top level, and the
871
+ `catalog` selector narrows to ONE vocabulary (`kind` | `status` | `priority` |
872
+ `relation` | `field` | `error_code` | `location_type` | `verb`):
873
+
874
+ ```
875
+ $ adhd-backlog query --input '{"view":"catalogs","catalog":"relation"}'
876
+ {"ok":true,"data":{"view":"catalogs","terms":[{"name":"attests","catalog":"relation","source":"edge_kind_table","lifecycle":"active"}, …]}}
877
+ ```
878
+
879
+ Each term is `{ name, uid?, catalog, source, lifecycle, replacedBy?,
880
+ usageCount? }`. `source` names the validating source it was generated from at
881
+ call time (`store` for `kind`/`status`/`priority` — live rows with a
882
+ `usageCount`; `edge_kind_table`, `reserved_terminal_status_names`,
883
+ `issue_field_union`, `error_code_union`, `valid_location_types`,
884
+ `mounted_verb_surface` for the in-code vocabularies). A `deprecated` term
885
+ carries `replacedBy`.
886
+
887
+ > **`kind` is an OPEN, free-form vocabulary — the `kind` catalog is a usage
888
+ > CENSUS, not an allowlist.** `create`/`update` accept ANY `kind` string with
889
+ > no validation: it is stored verbatim and the name then appears as a `kind`
890
+ > term with `source:"store"` and a `usageCount` (verified: creating with
891
+ > `"kind":"totally-made-up-kind"` succeeds and the term shows up on the next
892
+ > `view:"catalogs"`). On a **fresh store the `kind` catalog is EMPTY**
893
+ > (`{"terms":[]}`) — it reflects what has been filed, not what is permitted.
894
+ > The conventional names are `issue` (the default, and the read path's item
895
+ > scope) and `plan` (a plan is an ordinary issue with `kind:"plan"`; see
896
+ > "Dependency order" below). Unlike `create`, **`filter.kind` IS validated
897
+ > against this live census**: filtering by a name that has never been used is
898
+ > a `validation` error naming the existing values
899
+ > (`existing kind values: …`), so file one item with a kind before filtering
900
+ > by it. This mirrors §3's note that `status` is an open catalog — neither is
901
+ > a fixed enum.
902
+
903
+ > **Trap — `catalog` is silently ignored without `view:"catalogs"`.** The
904
+ > selector is honoured ONLY on `view:"catalogs"`. `{"catalog":"status"}` with
905
+ > no `view` falls back to the default `list` view — the ordinary issue page,
906
+ > filtered by nothing — at exit 0, with no `terms` and no error. It is not a
907
+ > "no matches" result, and the list contents are whatever your store holds:
908
+
909
+ ```
910
+ $ adhd-backlog query --input '{"catalog":"status"}'
911
+ {"ok":true,"data":{"view":"list","items":[{"uid":"1d9b77e5-…","title":"Rebuild backlog agent docs","kind":"issue","status":"closed","priority":"HIGH"}, …],"hasMore":false},"meta":{"total":7,"returned":7,"limit":50}}
912
+ ```
913
+
914
+ > Always pass `view:"catalogs"`. (`catalog` is likewise ignored on
915
+ > `view:"kinds"`, which always returns every catalog.)
916
+
917
+ ### Dependency order — a plan's members
918
+
919
+ A "plan" is not a separate node kind: it is an `issue` (commonly
920
+ `kind:"plan"`) that members attach to with
921
+ `relate(childUid, planUid, "part_of", "add")`. Three reads answer plan
922
+ questions:
923
+
924
+ - **Members:** `filter.plan` (uid or name) returns every issue with a live
925
+ `part_of` edge into the plan.
926
+ - **Dependency order:** `view:"order"` + `filter.plan` returns the members
927
+ topologically ordered by `blocks` — `{order:{ok:true,order:[uid,…]}}`, or
928
+ `{ok:false,cycle:[…]}` when the `blocks` edges form a cycle.
929
+ - **Transitive rollup:** `part-of-rollup` (§11) counts every transitive
930
+ `part_of` descendant, not just the direct members.
931
+
932
+ ```
933
+ $ adhd-backlog query --input '{"filter":{"plan":"850f1921-…"}}'
934
+ {"ok":true,"data":{"view":"list","items":[{"uid":"7b21007d-…","title":"Write SKILL.md inventory","kind":"issue","status":"open"},{"uid":"d56fa59f-…","title":"Write README table","kind":"issue","status":"open"}],"hasMore":false},"meta":{"total":2,"returned":2,"limit":50}}
935
+
936
+ $ adhd-backlog query --input '{"view":"order","filter":{"plan":"850f1921-…"}}'
937
+ {"ok":true,"data":{"view":"order","order":{"ok":true,"order":["7b21007d-…","d56fa59f-…"]}}}
938
+
939
+ $ adhd-backlog part-of-rollup --input '{"uid":"850f1921-…"}'
940
+ {"ok":true,"data":{"uid":"850f1921-…","childrenTotal":2,"childrenOpen":2,"childrenClosed":0,"childrenOpenUids":["7b21007d-…","d56fa59f-…"],"hasMore":false}}
941
+ ```
942
+
943
+ (In the example above `7b21007d` `blocks` `d56fa59f`, so the order is
944
+ `[7b21007d, d56fa59f]`.)
945
+
946
+ ## 7. Registry — project / component / location
382
947
 
383
948
  The registry answers **"where does this live, and what do I file the bug
384
949
  against?"** in one call, before you `rg`/search for it.
@@ -423,9 +988,13 @@ A component-scoped scan is how a repo's own work is found (e.g.
423
988
  | `upsert-component` | registering/updating a path _inside_ an already-registered project | `(project, name)` |
424
989
  | `upsert-location` | pointing a tool/file/URL at its owning component so `lookup` resolves it | `(component, locType, value)` |
425
990
  | `rm-location` | retiring a location (soft-invalidate) | `uid` |
991
+ | `merge-project` | collapsing a DUPLICATE project into the canonical one (reviewed, one-way) | `(fromUid, toUid)` |
992
+ | `rm-project` | retiring a project with no survivor (reviewed, one-way) | `uid` |
426
993
 
427
- All four are create-or-update by that key — never a duplicate row — and all
428
- require `by`.
994
+ The four `upsert*`/`rm-location` rows are create-or-update by that key — never a
995
+ duplicate row — and all require `by`. The two project-retirement verbs are
996
+ explicit, reviewed, one-way operations (see "Consolidating duplicate projects"
997
+ below).
429
998
 
430
999
  **Register or update a project** (create-or-update by `name`; also mints the
431
1000
  project's reserved default component `(root)` on first creation):
@@ -460,12 +1029,37 @@ $ adhd-backlog backlog lookup --input '{"q":"packages/auth/src/index.ts"}'
460
1029
  {"ok":true,"data":{"project":{"uid":"020e87f2-…","name":"demo-project","path":"/tmp/demo"},"component":{"uid":"41a61c6d-…","name":"auth-service","path":"packages/auth"},"location":{"uid":"a4b0dd6b-…","locType":"path","value":"packages/auth/src/index.ts"}}}
461
1030
  ```
462
1031
 
463
- `lookup` classifies `q` automatically as a tool name, file path, or URL —
464
- it only resolves against LOCATIONS already registered via `upsert-location`,
465
- never against a bare project/component name. An unregistered value returns
466
- `not_found` (exit 4); a path miss falls back to a suffix/prefix scan before
467
- giving up, and reports a `hint` when only a partial match was found — never
468
- a silent empty result.
1032
+ `lookup` routes `q` by shape, in order: (1) a **uid or unique uid prefix**
1033
+ returns `{"redirect":{"verb":"get","uid":…}}`; (2) an **issue title** with
1034
+ exactly one match returns the same `get` redirect, and a title matching two or
1035
+ more issues is refused (`ambiguous_reference`); (3) a **location** is
1036
+ classified as a tool name / file path / URL and resolved as before; (4) a
1037
+ **project name or `repoUrl`** resolves to its project. Pass an optional
1038
+ `"kind":"location"|"project"|"component"|"issue"` to force one branch; the
1039
+ mounted schema now types `kind` as a plain `string` (it was a closed enum), and
1040
+ only those four names select a branch. Only an
1041
+ unregistered location is still a bare `not_found` (exit 4); a path miss falls
1042
+ back to a suffix/prefix scan before giving up, and reports a `hint` when only a
1043
+ partial match was found — never a silent empty result.
1044
+
1045
+ **Consolidating duplicate projects** — two rows registered for the same repo
1046
+ (same `repoUrl`, different `name`) split every `owns_project` read. Merge the
1047
+ duplicate INTO the canonical survivor (one `BEGIN IMMEDIATE`; every component
1048
+ is re-pointed, the duplicate is soft-retired with a one-hop redirect so its old
1049
+ NAME still resolves, and its id is never reused):
1050
+
1051
+ ```
1052
+ $ adhd-backlog backlog merge-project --input '{"fromUid":"020e87f2-…","toUid":"41a61c6d-…","by":"claude:1"}'
1053
+ {"ok":true,"data":{"survivorUid":"41a61c6d-…","retiredUid":"020e87f2-…","movedIssues":7,"retired":true}}
1054
+ ```
1055
+
1056
+ A second identical `merge-project` is an idempotent no-op. Retire a project
1057
+ with no survivor (its old name then resolves to nothing) with:
1058
+
1059
+ ```
1060
+ $ adhd-backlog backlog rm-project --input '{"uid":"020e87f2-…","reason":"stale duplicate","by":"claude:1"}'
1061
+ {"ok":true,"data":{"uid":"020e87f2-…","retired":true}}
1062
+ ```
469
1063
 
470
1064
  **Remove a location** (soft-invalidate by `uid`):
471
1065
 
@@ -514,7 +1108,7 @@ $ adhd-backlog backlog query --input '{"view":"locations","filter":{"component":
514
1108
  just a missing row (filed as 49ce83b8). Run `sandbox-path` before a write
515
1109
  you care about, and file through the production CLI only.
516
1110
 
517
- ## 5. Batch — N-way fan-out over one operation
1111
+ ## 8. Batch — N-way fan-out over one operation
518
1112
 
519
1113
  `batch action` runs the SAME operation over many items. `operation` is the
520
1114
  mounted operation id, namespaced as `backlog/<verb>` (not the bare verb
@@ -538,12 +1132,11 @@ status:'rejected', reason}` — `value`/`reason` is the SAME outcome envelope
538
1132
  `error.code` still applies. `mode` (`'parallel'` default · `'serial'` ·
539
1133
  `'chained'`), `onItemError` (`'continue'` default · `'abort'`), and
540
1134
  `concurrency`/`itemTimeoutMs` govern how the fan-out runs. The valid
541
- `operation` values are exactly the 17 mounted verbs above (the nine issue
542
- verbs, the four registry verbs, and the three stats reads of §8), each
543
- prefixed `backlog/` — passing a bare verb name (`"create"`) is rejected with
544
- `invalid_argument` naming the full list.
1135
+ `operation` values are exactly the 28 mounted verbs above, each prefixed
1136
+ `backlog/` — passing a bare verb name (`"create"`) is rejected with
1137
+ `invalid_argument` naming the full list (the live error enumerates all 28).
545
1138
 
546
- ## 6. Citations — structured, not hand-typed markdown
1139
+ ## 9. Citations — structured, not hand-typed markdown
547
1140
 
548
1141
  A citation is `{ file, lines?, context?, symbol? }`. Pass `citations` on
549
1142
  `create` or on the `transition` that moves an issue into a terminal status.
@@ -583,7 +1176,7 @@ citation lines). Omit it and nothing is stored and no output changes. A
583
1176
  citation's own `context` is free-text prose and is never rendered by the
584
1177
  markdown projection — it cannot carry the git context.
585
1178
 
586
- ## 7. Verify writes from a NEW process
1179
+ ## 10. Verify writes from a NEW process
587
1180
 
588
1181
  An MCP `backlog_get` served by a long-lived `serve` process can answer out
589
1182
  of that process's own in-memory/uncheckpointed state. After a write you
@@ -591,13 +1184,14 @@ care about, verify by running the `adhd-backlog` CLI in a fresh shell — a
591
1184
  genuinely new process — rather than re-reading through the same live MCP
592
1185
  session.
593
1186
 
594
- ## 8. Stats & rollups
1187
+ ## 11. Stats & rollups
595
1188
 
596
- Three aggregate read views are first-class mounted ops — `backlog
597
- priority-matrix`, `backlog part-of-rollup`, `backlog open-curve` (MCP:
598
- `backlog_priority_matrix` / `backlog_part_of_rollup` / `backlog_open_curve`).
599
- All three are read-only and take no `by`, and each returns its own shape — a
600
- matrix, a rollup tree, a time series — so none is a `query.view` member.
1189
+ Four aggregate read ops are first-class mounted ops — `backlog
1190
+ priority-matrix`, `backlog part-of-rollup`, `backlog open-curve`, and `backlog
1191
+ report` (MCP: `backlog_priority_matrix` / `backlog_part_of_rollup` /
1192
+ `backlog_open_curve` / `backlog_report`). All four are read-only and take no
1193
+ `by`, and each returns its own shape — a matrix, a rollup tree, a time series,
1194
+ a grouped aggregate — so none is a `query.view` member.
601
1195
 
602
1196
  **Priority matrix** — per-priority counts, scoped by
603
1197
  `project`/`component`/`kind`/`status`. An omitted `filter.status` scopes to
@@ -612,11 +1206,13 @@ $ adhd-backlog backlog priority-matrix --input '{}'
612
1206
 
613
1207
  **Part-of rollup** — every TRANSITIVE `part_of` descendant of the root issue
614
1208
  (not just direct children), counted once each regardless of chain depth, split
615
- into `childrenOpen`/`childrenClosed` (plus the open descendants' uids):
1209
+ into `childrenOpen`/`childrenClosed` (plus the open descendants' uids).
1210
+ `countOnly:true` omits `childrenOpenUids` entirely (counts only); `limit`/`after`
1211
+ page the uid list (`nextCursor`/`hasMore`; default 50, max 1000):
616
1212
 
617
1213
  ```
618
1214
  $ adhd-backlog backlog part-of-rollup --input '{"uid":"74c22c35-…"}'
619
- {"ok":true,"data":{"uid":"74c22c35-…","childrenTotal":1,"childrenOpen":1,"childrenClosed":0,"childrenOpenUids":["db4587ba-…"]}}
1215
+ {"ok":true,"data":{"uid":"74c22c35-…","childrenTotal":1,"childrenOpen":1,"childrenClosed":0,"childrenOpenUids":["db4587ba-…"],"hasMore":false}}
620
1216
  ```
621
1217
 
622
1218
  **Open curve** — for each sampled ISO-8601 instant, how many in-scope issues
@@ -628,13 +1224,24 @@ $ adhd-backlog backlog open-curve --input '{"at":["2020-01-01T00:00:00.000Z"]}'
628
1224
  {"ok":true,"data":{"points":[{"at":"2020-01-01T00:00:00.000Z","existed":0,"open":0,"closed":0}]}}
629
1225
  ```
630
1226
 
631
- The same three functions are also exported from the package's query layer
632
- (`src/query/views/stats.ts`, re-exported by `src/query/index.ts`) for
633
- in-process consumers — `priorityMatrix(handle, { filter? })`,
634
- `partOfRollup(handle, { uid })`, `openCurve(handle, { filter?, at })`. That
1227
+ **Report** — a grouped rollup whose every number is computed from the store in
1228
+ that call: `byKind`, `byPriority` (composed from `priority-matrix`), `byStatus`,
1229
+ and `avgAgeDays` of open in-scope items, with `statusScope` naming what was
1230
+ counted (`'open'` when `filter.status` is omitted):
1231
+
1232
+ ```
1233
+ $ adhd-backlog backlog report --input '{}'
1234
+ {"ok":true,"data":{"byKind":[{"kind":"issue","count":2}],"byPriority":[{"priority":"HIGH","rank":0,"count":1}],"byStatus":[{"status":"open","terminal":false,"count":2}],"avgAgeDays":0.01,"statusScope":"open","computedAt":"…"}}
1235
+ ```
1236
+
1237
+ The same functions are also exported from the package's query layer
1238
+ (`src/query/views/stats.ts` + `src/query/views/report.ts`, re-exported by
1239
+ `src/query/index.ts`) for in-process consumers — `priorityMatrix(handle, {
1240
+ filter? })`, `partOfRollup(handle, { uid, countOnly?, limit?, after? })`,
1241
+ `openCurve(handle, { filter?, at })`, `report(handle, { filter? })`. That
635
1242
  in-process surface is described in `README.md` → "Library API".
636
1243
 
637
- ## 9. Hard rule — file a feature request when the tool is the friction
1244
+ ## 12. Hard rule — file a feature request when the tool is the friction
638
1245
 
639
1246
  **Never silently work around the tool.** If a value or grouping you were asked for had no verb to produce it — you got it by reshaping raw output yourself (a client-side group-by, join, filter, count, or field-extract) — you MUST file a feature request before you finish. Two or more such reshapes in one dispatch, even inside a single command, is already more than enough. No task scope overrides this: a read-only task, "only add links", or "do not create items" does NOT exempt you.
640
1247
  Inside the adhd repo: `create` a `FEAT` on project `adhd`, component `entrypoint/backlog`, with `duplicateAction:"comment"` (attaches your reproduction when it is already filed — never force); body = the exact command, the exact output, the workaround, and the outcome you wanted.