unknown-knowledge 2.1.0

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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,1678 @@
1
+ /**
2
+ * Resolver (KK-06) — the runtime loop's RESOLVE step and the ACT step's
3
+ * pre-commit reverse lookup (PRD §4, §7). Plain CLI so any agent that can run
4
+ * a shell command gets resolution — no MCP required.
5
+ *
6
+ * node payload/engine/resolve.js <query terms...> [--json] [--root <dir>]
7
+ * node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>]
8
+ * node payload/engine/resolve.js --doc <document> [--json] [--root <dir>]
9
+ *
10
+ * ONE ENTRY POINT, THREE INPUT SHAPES (UCS-1156). A query, a set of repo paths,
11
+ * and a whole document all enter here, and the pipeline is SIZE- AND
12
+ * FORMAT-INVARIANT because a query is processed as a ONE-BLOCK DOCUMENT through
13
+ * the same code: `resolveQuery` and `--doc` both reach the store through
14
+ * `joinText`, so a query and its equivalent one-block document produce
15
+ * identical joins by construction rather than by two implementations agreeing.
16
+ * A second document-shaped matcher is how two surfaces come to disagree about
17
+ * what a store contains, and the disagreement would be invisible — both would
18
+ * return plausible results.
19
+ *
20
+ * --doc mode emits a COVERAGE MAP (lib/coverage.js): per-section joins and
21
+ * candidates, a gather rollup with verdicts and scope-mismatch flags, and
22
+ * ranked candidates each carrying a section locator for just-in-time reads. Its
23
+ * size grows with content RICHNESS, not document length — a long redundant
24
+ * document repeats vocabulary that joins nothing new, so it produces a smaller
25
+ * map than a short dense one. An agent's context cost is the map plus the
26
+ * sections it chooses to open, never the document.
27
+ *
28
+ * An unsupported format, or content outside an adapter's envelope, is a HARD
29
+ * ERROR WITH CONDUCT and exits 2 — a parse that never ran is a failure, never a
30
+ * silent partial that would report a document as covered when half of it was
31
+ * never read (PRD §5.1).
32
+ *
33
+ * Query mode — scored term matching over the ontology. The query is the terms
34
+ * joined by single spaces, lowercased. A concept scores on the HIGHEST rung it
35
+ * reaches (rungs never add up); the scoring is pinned so ranking is stable:
36
+ *
37
+ * 100 exact-term query == term (case-insensitive)
38
+ * 80 exact-alias query == an alias
39
+ * 60 term-match term starts with the query, or every query word is a
40
+ * whole word of the term
41
+ * 50 alias-match an alias starts with the query, or every query word is
42
+ * a whole word of an alias — aliases are the synonyms
43
+ * recorded to cure retrieval-struggle findings, so they
44
+ * get the same rung treatment as terms (slightly lower)
45
+ * 40 summary-match every query word is a whole word of the summary
46
+ *
47
+ * -30 draft/proposed concepts are downranked (floor 1) — §3.5: the resolver
48
+ * downranks, preflight verdicts them unknown. Deprecated concepts keep
49
+ * their score but are surfaced flagged (status travels with the result).
50
+ *
51
+ * Each result carries: id/term/summary/status, score + matched rung, the
52
+ * concept's source-of-truth pointers (GATHER follows these), knowledge entry
53
+ * points (leaves whose `terms` name the concept's term or an alias — the
54
+ * knowledge-catalog descent, PRD §4), and confusable-with surfaced with each
55
+ * referenced concept's term so disambiguation needs no second lookup.
56
+ *
57
+ * Each knowledge entry point publishes `id`, the accession (L-NNNNNN) that IS
58
+ * the leaf's identity and the only spelling anything cites it by (UCS-1147),
59
+ * and `notation`, the optional legacy display label, null when the leaf carries
60
+ * none. Two fields because they answer two questions — which leaf this is, and
61
+ * what it was once filed as — and only the first is an identity.
62
+ *
63
+ * Frontmatter v2 adds three more (UCS-1149), all stable keys that may be null
64
+ * rather than fields that come and go:
65
+ *
66
+ * stage the leaf's promotion stage (facets.stage), or null
67
+ * excerpt the first sentence of the BODY, derived — v2 retired the
68
+ * authored `description`, so display prose is read back from the
69
+ * content and cannot drift from it
70
+ * provenance { author, skill-version }, carried through untouched
71
+ *
72
+ * Typed edges add two more (UCS-1151), on every leaf the resolver publishes in
73
+ * either mode:
74
+ *
75
+ * via how this leaf was reached — `declared` (it names the concept in
76
+ * its `concepts` edge) or `terms` (its term text matched), and in
77
+ * --paths mode `direct` (it names the path) or `concept` (the path
78
+ * is under a concept it declares). Two joins of different strength,
79
+ * so the result says which one fired rather than leaving a reader
80
+ * to assume the stronger one
81
+ * relates the leaf's ONE-HOP neighborhood, keyed by edge kind (depends-on /
82
+ * see-also / contradicts / supersedes), each neighbor a minimal
83
+ * reference {id, notation, heading, file}. Outgoing edges only —
84
+ * what this leaf's author asserted. Exactly one hop: a neighbor's
85
+ * neighbors are absent, because the hop exists to show what sits
86
+ * immediately around a hit, and depth 2 is most of the store
87
+ * arriving unranked
88
+ *
89
+ * Knowledge entry points now join STRUCTURALLY as well as textually: a leaf
90
+ * that declares a concept surfaces under it whether or not any term text
91
+ * matches, so knowledge stops depending on two authors choosing the same words.
92
+ *
93
+ * A leaf at a pre-promotion stage is DOWNRANKED: flagged `downranked: true` and
94
+ * sorted below every promoted entry point. The flag comes off the same
95
+ * `isPrePromotionStatus` predicate preflight verdicts on, so the resolver's
96
+ * demotion and preflight's unknown verdict cannot disagree about which leaves
97
+ * are provisional.
98
+ *
99
+ * The Time facet adds two more published fields and a second demotion
100
+ * (UCS-1150):
101
+ *
102
+ * time the leaf's freshness verdict — {verdict, stale, volatility,
103
+ * verified, age, limit, reason}, from the shared
104
+ * lib/time-verdicts.js that preflight and the derived layer also
105
+ * read. Verdict classes: trusted, stale, skipped (no --today was
106
+ * injected), exempt (the leaf declares no volatility), undated
107
+ * (it declares one but carries no usable date)
108
+ * demotions every demotion that fired, each with its reason — `stage` for a
109
+ * pre-promotion leaf, `time` for a stale one. Never a bare flag:
110
+ * a demotion a reader cannot explain is one they cannot act on
111
+ *
112
+ * `downranked` is now the UNION of both demotions, so a stale leaf sorts below
113
+ * the fresh ones through the comparator the draft demotion already used. It is
114
+ * still a demotion and never a filter — a stale leaf is still the best answer
115
+ * when it is the only answer, and hiding it would send the reader to invent one.
116
+ *
117
+ * `--today <YYYY-MM-DD>` injects the date verdicts are measured against, and
118
+ * the engine never reads the wall clock (D-012). WITHOUT it, time verdicts
119
+ * report themselves `skipped` and the output SAYS so on a `time-check` line
120
+ * present in every payload: a run that computed no freshness verdicts must not
121
+ * read like one that checked and found everything fresh.
122
+ *
123
+ * QUERY DECOMPOSITION (UCS-1152) turns the query itself into joins against the
124
+ * governed vocabularies, and says what did not join. Three axes, three
125
+ * vocabularies, and none of them guessing:
126
+ *
127
+ * verb -> the `knowledge/operations` registry "add a token" -> add-token
128
+ * noun -> concept terms and aliases "token" -> K-101
129
+ * place -> the `knowledge/jurisdictions` registry "eu eaa" -> eu-eaa
130
+ *
131
+ * Four sections join the payload, every one a STABLE key that may be empty:
132
+ *
133
+ * decomposition what each axis resolved to, the tokens it consumed, the
134
+ * near-misses, the residue, and the resolved context residue
135
+ * should be recorded alongside
136
+ * scoring the signal→score table the ranking was computed with, so a
137
+ * consumer reproducing it never vendors a copy that goes stale
138
+ * leaves leaves as FIRST-CLASS SCORED RESULTS, each carrying the
139
+ * signals that scored it. Before this ticket a leaf could only
140
+ * appear as an attachment to a concept, which made an entire
141
+ * class of correct answer unreachable: "add a token" resolves a
142
+ * VERB, and the leaf declaring that operation is the answer
143
+ * whether or not any concept matched
144
+ * exclusions leaves the query's scope excluded, each with its REASON —
145
+ * excluded, never silently absent
146
+ *
147
+ * EXTENSION, NOT REPLACEMENT. `results` and its concept-attached `knowledge`
148
+ * lists are untouched, and concept scores are exactly what they were: the
149
+ * ladder still decides them, and a concept the ask merely NAMED (reached by the
150
+ * token-level phrase test but not the whole-query ladder) appears in
151
+ * `decomposition.concepts` without being given an invented rung in `results`.
152
+ * Every pre-1152 consumer keeps working; a new one can read leaves directly.
153
+ *
154
+ * SCOPE EXCLUSION is the criterion that most needs saying out loud: a leaf
155
+ * whose `applies.jurisdictions` is non-empty and excludes the query's
156
+ * jurisdiction is published in `exclusions` with the reason, never dropped.
157
+ * "No knowledge about theming tokens for us-ca" and "the knowledge about theming
158
+ * tokens is eu-eaa-only" demand opposite conduct, and a filtered-away leaf
159
+ * makes them indistinguishable. An empty `applies` is UNIVERSAL and never
160
+ * excluded; a query naming no jurisdiction excludes nothing.
161
+ *
162
+ * RESIDUE is the unconsumed non-stopword tokens — the store's own record of
163
+ * what it does not yet know — emitted with `resolved-context` so the gap is
164
+ * localized enough to act on. The stopword list is pinned and shipped in the
165
+ * engine (lib/decomposition.js), never configurable per run.
166
+ *
167
+ * --paths mode — reverse lookup over BOTH pointer families: "which concepts
168
+ * point at these files, and which leaves govern them" (UCS-1151). The join runs
169
+ * over concept source-of-truth pointers and over leaf `paths` declarations, so
170
+ * a diff-shaped input surfaces the knowledge that governs the files before an
171
+ * edit rather than stopping at the concept and leaving the reader to make that
172
+ * hop by hand. A path matches a pointer when equal to it or
173
+ * nested under a FOLDER pointer (§3.1). Folder-ness is read from the
174
+ * filesystem, not from the name — `src/api.v2` is a directory whose extname is
175
+ * ".v2" — and from the name only when the pointer is gone, since a diff names
176
+ * deleted paths; see folderPointerTest. Paths are normalized with path.posix
177
+ * semantics (dots resolved, separators collapsed, backslashes converted,
178
+ * absolute paths relativized against the store root) so attribution survives
179
+ * the forms real tooling emits. An entry naming the repo root is a usage
180
+ * error, never a silently dropped lookup. A lookup, not subset validation (no
181
+ * D-012 conflict). Paths are deduped and sorted ascending.
182
+ *
183
+ * Zero resolution is a NORMAL outcome (PRD §7 — common in month one): exit 0
184
+ * with an explicit empty result plus the fallback conduct (search within
185
+ * survey-scope.yaml; append a retrieval-miss finding only if the topic
186
+ * plausibly should be mapped). Exit codes (PRD §5): 0 = the lookup ran (hits
187
+ * or none), 2 = usage/engine failure — a lookup that never ran is a failure,
188
+ * never a silent empty result. The resolver emits no findings, so it never
189
+ * exits 1; gating on store health is preflight's job. Store health is still
190
+ * surfaced (single health model), and resolution runs on whatever loaded.
191
+ *
192
+ * JSON output is deterministic and stable-sorted — results by score desc then
193
+ * id asc; paths/pointers/entry points lexicographic — with no timestamps.
194
+ */
195
+ import process from 'node:process';
196
+ import { readFileSync, statSync } from 'node:fs';
197
+ import { join, posix, resolve as resolvePath } from 'node:path';
198
+ import {
199
+ LEAF_PATHS_FIELD, RELATES_FIELD, RELATES_KINDS, healthSummary, isPrePromotionStatus,
200
+ leafIdentityOf, leafStage, loadStores, storeHealth,
201
+ } from '../lib/load-stores.js';
202
+ import { locateKitRoot } from '../lib/kit-root.js';
203
+ import { EXIT_CODES } from '../lib/exit-codes.js';
204
+ import { UsageError, parseArgs as parseFlags, rethrowIfBug } from '../lib/cli.js';
205
+ import { compare } from '../lib/validate-record.js';
206
+ // Query decomposition (UCS-1152) — the joins against the governed vocabularies,
207
+ // and the scoring table those joins are weighed by. Both extracted into lib so
208
+ // the signal→score mapping is one declaration rather than arithmetic spread
209
+ // through the matcher.
210
+ import {
211
+ STOPWORDS, mintedValues, phraseHit, phraseOverlap, phraseWords, tokenize, valuePhrases,
212
+ } from '../lib/decomposition.js';
213
+ import { conceptScore, leafScore, scoringTable } from '../lib/scoring.js';
214
+ // The Time facet (UCS-1150). The resolver computes no verdict of its own — one
215
+ // implementation, shared with preflight and the derived layer, so a leaf ranked
216
+ // stale here is never verdicted trusted there.
217
+ import { timeCheckStatus, timeVerdict } from '../lib/time-verdicts.js';
218
+ import { isCalendarDate } from '../lib/iso-date.js';
219
+ // The document coverage map (UCS-1156) and the adapter seam it reads. The
220
+ // coverage module owns the MAP; this command owns the JOINS, and passes its own
221
+ // joiner in — so the document path cannot grow a second matcher.
222
+ import { buildCoverageMap } from '../lib/coverage.js';
223
+ import { AdaptError, UnsupportedFormatError, adapt, adapterFor } from '../lib/format-adapters.js';
224
+ import { loadSuppressions } from '../lib/suppressions.js';
225
+
226
+ export const USAGE = `usage: node payload/engine/resolve.js <query terms...> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
227
+ node payload/engine/resolve.js --paths <file1,file2> [--json] [--root <dir>] [--today <YYYY-MM-DD>]
228
+ node payload/engine/resolve.js --doc <document> [--json] [--root <dir>] [--today <YYYY-MM-DD>]`;
229
+
230
+ // The concept ladder and the draft downrank moved to lib/scoring.js (UCS-1152)
231
+ // — one signal→score table, so a reader asking "what is a score of 70 made of"
232
+ // has one place to look and `explain`-style reproduction is possible at all.
233
+ // The NUMBERS are unchanged and pinned by the existing goldens: the extraction
234
+ // moved this arithmetic, it did not renegotiate it.
235
+
236
+ /** Registry keys the query's verb and place axes join through (UCS-1148). */
237
+ const OPERATIONS_REGISTRY = 'knowledge/operations';
238
+ const JURISDICTIONS_REGISTRY = 'knowledge/jurisdictions';
239
+
240
+ /** The leaf front-matter fields the structured joins read. */
241
+ const OPERATIONS_FIELD = 'operations';
242
+ const APPLIES_FIELD = 'applies';
243
+ const JURISDICTIONS_FIELD = 'jurisdictions';
244
+
245
+ /**
246
+ * The first sentence of a leaf's body, or null when it has none (UCS-1149).
247
+ *
248
+ * Frontmatter v2 retired the free-prose `description` field, so display prose
249
+ * is DERIVED rather than authored: a leaf opens its body with a topic sentence
250
+ * and this reads it back. The point is that the summary cannot drift from the
251
+ * content — an authored one-liner is a second copy of the claim, and the copy
252
+ * is what goes stale when the body is edited and the frontmatter is not.
253
+ *
254
+ * Deliberately literal about what a "first sentence" is, because a clever
255
+ * extractor that guesses wrong is worse than a plain one that occasionally
256
+ * returns a long line:
257
+ *
258
+ * - Markdown structure is skipped LINE BY LINE, not block by block. That is
259
+ * the whole subtlety: structure in markdown is a property of a line, and a
260
+ * heading needs no blank line after it, so `# Title\nThe real opening.` is
261
+ * one block whose first line is a heading and whose second is the prose.
262
+ * Discarding the block would blank the excerpt for a perfectly ordinary
263
+ * body; discarding just the heading line finds the sentence underneath.
264
+ * - Fenced code is skipped WHOLE, tracked as state across lines rather than
265
+ * matched line by line, in both CommonMark spellings (``` and ~~~).
266
+ * Matching only the fence markers would leave the code between them
267
+ * looking like ordinary prose, which is how `code();` ends up published as
268
+ * a leaf's excerpt. An unclosed fence runs to the end of the body.
269
+ * - Prose then runs to the next blank line or structural line, with internal
270
+ * newlines collapsed to single spaces: bodies are hard-wrapped, so a
271
+ * sentence routinely spans two lines and a line-based reader would truncate
272
+ * it mid-clause.
273
+ * - A sentence ends at `.`/`!`/`?` followed by whitespace or end-of-text.
274
+ * `§4.2` and `v3.` do not end a sentence mid-token, which is why the
275
+ * following character must be whitespace rather than anything at all.
276
+ * - Prose with no terminator IS the excerpt — a body whose opening line is a
277
+ * fragment still has display prose, and returning null there would silently
278
+ * blank the surface rather than show what the author wrote.
279
+ *
280
+ * @param {string|undefined} body the markdown below the front matter
281
+ * @returns {string|null}
282
+ */
283
+ export function firstSentence(body) {
284
+ if (typeof body !== 'string') return null;
285
+ // Headings, list items, ordered items, block quotes, and table rows.
286
+ const structural = /^(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|\|)/;
287
+ // Both fence spellings — CommonMark allows ~~~ as well as ```.
288
+ const fence = /^(```|~~~)/;
289
+ // A fence has to be tracked as STATE, not matched as a line: skipping the
290
+ // fence markers alone would leave the code BETWEEN them looking like
291
+ // ordinary prose, and `code();` would be published as a leaf's excerpt.
292
+ // So everything from an opening fence to its closing one is skipped whole.
293
+ const prose = [];
294
+ let fenced = false;
295
+ for (const raw of body.split('\n')) {
296
+ const line = raw.trim();
297
+ if (fence.test(line)) {
298
+ // An unclosed fence runs to the end of the body — which is the honest
299
+ // reading of a malformed block, and never leaves code in the excerpt.
300
+ fenced = !fenced;
301
+ if (prose.length) break; // prose already collected; a fence ends it
302
+ continue;
303
+ }
304
+ if (fenced) continue;
305
+ if (line === '' || structural.test(line)) {
306
+ // Prose stops AT structure rather than swallowing it, so an excerpt
307
+ // never shows markup; before any prose, structure is just skipped.
308
+ if (prose.length) break;
309
+ continue;
310
+ }
311
+ prose.push(line);
312
+ }
313
+ if (!prose.length) return null;
314
+ const flat = prose.join(' ').replace(/\s+/g, ' ').trim();
315
+ const stop = flat.search(/[.!?](\s|$)/);
316
+ return stop === -1 ? flat : flat.slice(0, stop + 1);
317
+ }
318
+
319
+ const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
320
+ const words = (s) => norm(s).split(/[^a-z0-9]+/).filter(Boolean);
321
+ const strings = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
322
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
323
+
324
+ // ---------------------------------------------------------------- query mode
325
+
326
+ /** Prefix or whole-word rung shared by terms and aliases. */
327
+ const rungMatch = (query, queryWords, name) =>
328
+ norm(name).startsWith(query) || queryWords.every((w) => words(name).includes(w));
329
+
330
+ /** Highest scoring rung the concept reaches for this query, or null. */
331
+ function matchConcept(query, queryWords, record) {
332
+ const term = typeof record.term === 'string' ? record.term : '';
333
+ const aliases = strings(record.aliases);
334
+ if (norm(term) === query) return 'exact-term';
335
+ if (aliases.some((alias) => norm(alias) === query)) return 'exact-alias';
336
+ if (rungMatch(query, queryWords, term)) return 'term-match';
337
+ if (aliases.some((alias) => rungMatch(query, queryWords, alias))) return 'alias-match';
338
+ const summaryWords = words(typeof record.summary === 'string' ? record.summary : '');
339
+ if (queryWords.every((w) => summaryWords.includes(w))) return 'summary-match';
340
+ return null;
341
+ }
342
+
343
+ /**
344
+ * A leaf's declared operations (UCS-1152) — the single reader of the
345
+ * `operations` spelling, for the same reason `leafConcepts` is for `concepts`.
346
+ *
347
+ * Non-strings are dropped rather than coerced: the schema already diagnoses the
348
+ * wrong type, and a coerced value would join a leaf to an operation nobody
349
+ * declared.
350
+ *
351
+ * @param {object} record a leaf's front-matter record
352
+ * @returns {string[]}
353
+ */
354
+ const leafOperations = (record) => strings(record?.[OPERATIONS_FIELD]);
355
+
356
+ /**
357
+ * The jurisdictions a leaf declares itself applicable to (UCS-1152).
358
+ *
359
+ * An EMPTY list is the universal case and is load-bearing: a leaf that declares
360
+ * no jurisdictions applies everywhere and is never scope-excluded. The
361
+ * distinction between "applies to nowhere" and "applies everywhere" is
362
+ * precisely the one an empty array has to carry, and it reads as universal
363
+ * because that is what an author who wrote no jurisdiction meant — the
364
+ * alternative would silently hide every leaf in every store that has not yet
365
+ * adopted the facet.
366
+ *
367
+ * @param {object} record a leaf's front-matter record
368
+ * @returns {string[]}
369
+ */
370
+ function leafJurisdictions(record) {
371
+ const applies = record?.[APPLIES_FIELD];
372
+ return isObject(applies) ? strings(applies[JURISDICTIONS_FIELD]) : [];
373
+ }
374
+
375
+ /** confusable-with ids, each resolved to its term for one-lookup disambiguation. */
376
+ function confusables(model, record) {
377
+ return strings(record['confusable-with'])
378
+ .sort(compare)
379
+ .map((id) => ({ id, term: model.concepts.get(id)?.record.term ?? null }));
380
+ }
381
+
382
+ /**
383
+ * Knowledge entry points: leaves whose `terms` name the concept term/alias.
384
+ *
385
+ * Ordered promoted-first (UCS-1149): a pre-promotion leaf sorts BELOW every
386
+ * promoted one, and ties keep the loader's id order so output stays byte-stable.
387
+ * That ordering is the resolver's half of the draft-stage contract — an agent
388
+ * reading the list top-down reaches certified knowledge before provisional
389
+ * knowledge — and it is deliberately a demotion rather than a filter: a draft
390
+ * leaf is still the best answer when it is the only answer, and hiding it would
391
+ * send the reader to invent one instead.
392
+ */
393
+ /**
394
+ * One leaf's one-hop `relates` neighborhood, typed and labeled by edge kind
395
+ * (UCS-1151).
396
+ *
397
+ * The shape is a MAP keyed by edge kind, not a flat list with a `kind` field,
398
+ * because the kinds are not interchangeable: `contradicts` and `see-also` ask
399
+ * an agent to do different things, and a flat list invites reading the first
400
+ * few entries as if the kind were incidental. Every declared kind is present as
401
+ * a key even when empty, for the reason every other v2 field is a stable key
402
+ * that may be null — one result shape, so a consumer never needs a presence
403
+ * check to tell "no contradictions" from "this engine predates contradicts".
404
+ *
405
+ * Exactly ONE hop, and that is a deliberate boundary rather than a first
406
+ * increment. A neighbor's neighbors are absent: the point of the hop is to show
407
+ * an agent what sits immediately around a hit so it can decide what to read
408
+ * next, and two hops would put material in front of it that nothing it asked
409
+ * for actually touches — the relates graph is dense enough that depth 2 is most
410
+ * of the store, arriving unranked and unexplained. Each neighbor carries what
411
+ * it takes to decide whether to follow it (`heading`) and to actually go
412
+ * (`file`, and both ids); following IS the second hop, and that is the caller's
413
+ * call to make.
414
+ *
415
+ * OUTGOING edges only. The ticket says the resolver expands over relates edges
416
+ * FROM a hit, and outgoing is what this leaf's author asserted: a leaf declares
417
+ * what IT depends on, what IT contradicts. An incoming edge is somebody else's
418
+ * claim about this leaf, which is a genuinely useful thing to see and a
419
+ * different question — it belongs to whatever surface presents "what cites
420
+ * this", where it can be labeled as such rather than blended into the leaf's
421
+ * own assertions.
422
+ *
423
+ * Neighbors resolve through `leafIdentityOf`, the one lookup every surface
424
+ * asks — so an edge citing a leaf by its retired notation reaches nothing here
425
+ * for the same reason it fails validation (UCS-1147), rather than through a
426
+ * second rule this function spells itself. An edge that resolves to nothing is
427
+ * DROPPED here rather than
428
+ * published as a stub: the loader has already raised the unresolved-ref finding
429
+ * against it, and a neighborhood entry naming a leaf that does not exist would
430
+ * send a reader after a file nobody can open.
431
+ *
432
+ * @param {object} model the loaded store model
433
+ * @param {object} record the leaf's front-matter record
434
+ * @returns {Record<string, Array<{id, notation, heading, file}>>}
435
+ */
436
+ function relatesNeighborhood(model, record) {
437
+ const declared = isObject(record[RELATES_FIELD]) ? record[RELATES_FIELD] : {};
438
+ const neighborhood = {};
439
+ for (const kind of RELATES_KINDS) {
440
+ const seen = new Set();
441
+ const neighbors = [];
442
+ for (const cited of strings(declared[kind])) {
443
+ const identity = leafIdentityOf(model, cited);
444
+ // Unresolvable: the loader already reported it as unresolved-ref. A stub
445
+ // here would be a second report of one defect, wearing the shape of a
446
+ // real neighbor.
447
+ if (identity === undefined || seen.has(identity)) continue;
448
+ seen.add(identity);
449
+ const entry = model.leaves.get(identity);
450
+ neighbors.push({
451
+ id: typeof entry.id === 'string' ? entry.id : null,
452
+ notation: typeof entry.notation === 'string' ? entry.notation : null,
453
+ heading: entry.record?.heading ?? null,
454
+ file: entry.file,
455
+ });
456
+ }
457
+ // Sorted by identity, so a neighborhood is byte-stable regardless of the
458
+ // order the author happened to list the citations in.
459
+ neighborhood[kind] = neighbors.sort((a, b) => compare(a.id ?? a.notation, b.id ?? b.notation));
460
+ }
461
+ return neighborhood;
462
+ }
463
+
464
+ /**
465
+ * The published shape of one knowledge leaf — every surface that surfaces a
466
+ * leaf builds it HERE.
467
+ *
468
+ * Extracted in UCS-1151 because a leaf now reaches the caller three ways: as a
469
+ * concept's knowledge entry point (by term text or by declared `concepts`
470
+ * edge), and as a governing leaf in reverse path lookup. Three call sites
471
+ * spelling this object out would be three chances for a field to be published
472
+ * one way in one mode and another way in the next — and the fields most at risk
473
+ * are exactly the ones whose whole contract is that they are STABLE keys that
474
+ * may be null.
475
+ */
476
+ function publishLeaf(model, entry, today) {
477
+ const { file, record: leaf } = entry;
478
+ const stage = leafStage(leaf);
479
+ const provenance = leaf.provenance;
480
+ // The Time facet's verdict (UCS-1150), computed once per published leaf and
481
+ // carried on it. Published on EVERY leaf including the exempt and the
482
+ // skipped ones, for the reason every other v2 field is a stable key: a
483
+ // consumer must never need a presence check to tell "this leaf is fresh"
484
+ // from "this run never asked what day it is".
485
+ const time = timeVerdict(leaf, today);
486
+ return {
487
+ // `notation` and `id` are this command's PUBLISHED field names (§4), so
488
+ // they are spelled here on purpose; the VALUES come from the loader's
489
+ // indexed entry, which is what an id-space change moves (UCS-1142).
490
+ // Wire name and storage field are two different decisions.
491
+ //
492
+ // `id` is the accession — the leaf's IDENTITY (UCS-1147), and the only
493
+ // spelling anything may cite it by. Placed first for that reason;
494
+ // JSON.stringify preserves insertion order, so this fixes the field's
495
+ // position in the byte-stable output for good.
496
+ //
497
+ // `notation` is the OPTIONAL LEGACY display label, published alongside and
498
+ // never as identity. It stays because it is still a fact about the leaf a
499
+ // reader may want to see, and because a published field that vanished would
500
+ // break consumers as surely as one that changed meaning — but nothing
501
+ // resolves through it, and a store keying on it is keying on a label.
502
+ //
503
+ // Both are published as STRING-OR-NULL, tested with `typeof` rather than
504
+ // `??`: `??` only catches null/undefined, so an unquoted YAML `id: 12345` —
505
+ // a number, not an accession — would travel into the JSON as a number and
506
+ // break the field's published type for every consumer. `id` is null only
507
+ // for a leaf no check has approved: the schema requires an accession, but
508
+ // the resolver never gates on store health (a lookup runs on whatever
509
+ // loaded, §4), so it is the one surface that can be asked to publish an id
510
+ // the store should not have had. A stable key whose value is null is what
511
+ // it says then, rather than a key that disappears.
512
+ // Same `typeof` test the loader and the orphan check use.
513
+ id: typeof entry.id === 'string' ? entry.id : null,
514
+ notation: typeof entry.notation === 'string' ? entry.notation : null,
515
+ heading: leaf.heading ?? null,
516
+ // `stage`, `excerpt`, and `provenance` are frontmatter v2 (UCS-1149).
517
+ // Like `id` they are stable keys whose value may be null, never omitted
518
+ // keys: a store mid-migration must emit ONE result shape, or every
519
+ // consumer needs a presence check to tell "this leaf declares no stage"
520
+ // from "this engine predates stages".
521
+ //
522
+ // `excerpt` is DERIVED from the body, not read from a field — v2 retired
523
+ // the authored `description` precisely so display prose cannot drift from
524
+ // the content it summarizes.
525
+ //
526
+ // `provenance` travels verbatim: no registry governs it, so the resolver
527
+ // has no judgement to apply and passing it through unchanged is the whole
528
+ // contract. Spelled field by field rather than spread, so a later
529
+ // provenance field cannot leak into published output before anyone
530
+ // decided it should be public.
531
+ stage,
532
+ excerpt: firstSentence(entry.body),
533
+ provenance: isObject(provenance)
534
+ ? {
535
+ author: typeof provenance.author === 'string' ? provenance.author : null,
536
+ 'skill-version': typeof provenance['skill-version'] === 'string' ? provenance['skill-version'] : null,
537
+ }
538
+ : null,
539
+ // The SAME predicate preflight verdicts on (UCS-1149). A leaf whose
540
+ // stage is pre-promotion is downranked here and verdicted unknown
541
+ // there; reading the two off one predicate is what stops the surfaces
542
+ // disagreeing about which leaves are provisional.
543
+ //
544
+ // `downranked` is now the UNION of two independent demotions (UCS-1150):
545
+ // a pre-promotion stage and a stale time verdict. It stays a single
546
+ // boolean because it answers a single question — does this leaf sort
547
+ // below the promoted ones — and every consumer already reads it that way.
548
+ // WHICH demotions fired is `demotions`, so a leaf that is both draft and
549
+ // stale reports both rather than having one silently absorb the other.
550
+ downranked: isPrePromotionStatus(stage) || time.stale,
551
+ // Never a bare flag: the ticket's demand is that a demotion is never
552
+ // silent, so the reason travels with it. Empty for a leaf that was not
553
+ // demoted — a stable key whose value is an empty array, like every other
554
+ // v2 field that may be absent-but-present.
555
+ demotions: [
556
+ ...(isPrePromotionStatus(stage)
557
+ ? [{ reason: 'stage', detail: `stage "${stage}" is pre-promotion — no moderator has certified this leaf's citations (UCS-1149)` }]
558
+ : []),
559
+ ...(time.stale ? [{ reason: 'time', detail: time.reason }] : []),
560
+ ],
561
+ // The full time verdict (UCS-1150) — verdict class, the declared facts it
562
+ // was computed from, and the reason. Carried on every leaf so a projection
563
+ // can show WHY without recomputing, which is what keeps every surface
564
+ // reading one answer.
565
+ time,
566
+ file,
567
+ // The one-hop structural neighborhood (UCS-1151) — every leaf the resolver
568
+ // publishes carries it, so "any hit carries its relates neighborhood" is
569
+ // true by construction rather than by remembering to attach it per mode.
570
+ [RELATES_FIELD]: relatesNeighborhood(model, leaf),
571
+ };
572
+ }
573
+
574
+ /**
575
+ * How a leaf came to be attached to a concept (UCS-1151).
576
+ *
577
+ * Published on every entry point, because the two joins answer differently and
578
+ * a reader deserves to know which one fired. `declared` means the leaf names
579
+ * this concept in its `concepts` edge — a curatorial claim, checked by the ref
580
+ * graph. `terms` means the leaf's `terms` text matched the concept's term or an
581
+ * alias, which is the pre-1151 join and is exactly as reliable as the two
582
+ * authors' vocabularies happening to agree.
583
+ *
584
+ * A leaf that does both reads `declared`: the structural edge is the stronger
585
+ * claim, and it is the one that survives a concept being renamed.
586
+ */
587
+ const VIA_DECLARED = 'declared';
588
+ const VIA_TERMS = 'terms';
589
+
590
+ function knowledgeEntryPoints(model, record, conceptId, today) {
591
+ const names = new Set(
592
+ [record.term, ...strings(record.aliases)]
593
+ .filter((s) => typeof s === 'string')
594
+ .map(norm),
595
+ );
596
+ // The STRUCTURAL half of the join (UCS-1151): leaves that declared this
597
+ // concept, read off the reverse index the loader derived at load. This is
598
+ // what makes a concept's knowledge reachable without term luck — the leaf
599
+ // said which concept it is about, so renaming the concept's term, or writing
600
+ // the leaf in different words, cannot silently sever them.
601
+ const declaring = new Set(model.leavesByConcept.get(conceptId) ?? []);
602
+ const out = [];
603
+ for (const entry of model.leaves.values()) {
604
+ const { record: leaf } = entry;
605
+ const declared = declaring.has(entry.identity);
606
+ if (declared || strings(leaf.terms).some((t) => names.has(norm(t)))) {
607
+ // `via` records WHICH join fired, because the two are not equally
608
+ // reliable and a reader should not have to guess. The structural edge
609
+ // wins a tie: it is the claim that survives a concept rename.
610
+ out.push({ via: declared ? VIA_DECLARED : VIA_TERMS, ...publishLeaf(model, entry, today) });
611
+ }
612
+ }
613
+ // Stable by construction: model.leaves is already sorted by leaf id, and a
614
+ // boolean comparator moves only the downranked ones, so ties never reorder.
615
+ // `downranked` now folds in the stale verdict (UCS-1150), so a stale leaf
616
+ // sorts below the fresh ones through the SAME comparator the draft demotion
617
+ // already used — one ordering rule, not two competing ones.
618
+ return out.sort((a, b) => Number(a.downranked) - Number(b.downranked));
619
+ }
620
+
621
+ /**
622
+ * Decompose the query against the governed vocabularies (UCS-1152).
623
+ *
624
+ * Three axes, three vocabularies, no guessing — the module header of
625
+ * lib/decomposition.js argues the semantics; this is the join itself. Each axis
626
+ * records the TOKENS it consumed, because residue is defined as what no join
627
+ * consumed and that is only computable if every join says what it took.
628
+ *
629
+ * Concept matching runs the pre-1152 ladder (`matchConcept`), NOT the phrase
630
+ * test, so published concept scores stay exactly what they were. The phrase
631
+ * test is used only to learn which tokens the concept consumed — a concept that
632
+ * matched on `summary-match` consumed nothing nameable, and claiming otherwise
633
+ * would delete residue the store should have reported.
634
+ *
635
+ * @returns {{operations, concepts, jurisdictions, tokens, consumed, nearMiss}}
636
+ */
637
+ function decompose(model, query, queryWords, tokens) {
638
+ const consumed = new Set();
639
+ const consume = (taken) => { for (const t of taken) consumed.add(t); };
640
+
641
+ /** Join one registry's minted values, recording the spelling that matched. */
642
+ const joinRegistry = (key) => {
643
+ const hits = [];
644
+ for (const value of mintedValues(model, key)) {
645
+ for (const { spelling, words: phrase } of valuePhrases(value)) {
646
+ const taken = phraseHit(phrase, tokens);
647
+ if (!taken) continue;
648
+ consume(taken);
649
+ hits.push({ value, matched: spelling, tokens: [...taken] });
650
+ break; // one value resolves once; the first (identifier) spelling wins
651
+ }
652
+ }
653
+ return hits;
654
+ };
655
+
656
+ const operations = joinRegistry(OPERATIONS_REGISTRY);
657
+ const jurisdictions = joinRegistry(JURISDICTIONS_REGISTRY);
658
+
659
+ // The NOUN axis. Two joins are asked, and they answer different questions:
660
+ //
661
+ // ladder `matchConcept` — the pre-1152 whole-query ladder that produces
662
+ // the published concept `score`. It is deliberately strict: it
663
+ // tests the query AS A WHOLE against a term, so "add a token" does
664
+ // NOT reach "Token", and the concept result list stays exactly what
665
+ // consumers already rank on.
666
+ // phrase the token-level phrase test — does the concept's name appear IN
667
+ // the ask at all? "add a token" does contain "token", and the leaf
668
+ // declaring K-101 is a correct answer to it.
669
+ //
670
+ // Before this ticket only the ladder existed, so a concept the ask genuinely
671
+ // named went unjoined whenever the ask said anything else as well — which is
672
+ // every real query. Running both, and keeping them separate, is what lets the
673
+ // decomposition find the noun without renegotiating the published ranking:
674
+ // `match` is the ladder's verdict and is null when only the phrase test
675
+ // fired, so a reader can always tell which join reached the concept.
676
+ const concepts = [];
677
+ for (const { id, file, record } of model.concepts.values()) {
678
+ const match = matchConcept(query, queryWords, record);
679
+ // Which tokens this concept's own vocabulary accounts for. Only the term
680
+ // and aliases are consulted — a `summary-match` consumes nothing, because
681
+ // a summary is prose about the concept, not a name for it, and treating it
682
+ // as one would silently absorb tokens the store cannot actually resolve.
683
+ const taken = [];
684
+ for (const name of [record.term, ...strings(record.aliases)]) {
685
+ if (typeof name !== 'string') continue;
686
+ const hit = phraseHit(phraseWords(name), tokens);
687
+ if (hit) taken.push(...hit);
688
+ }
689
+ if (match === null && !taken.length) continue;
690
+ consume(taken);
691
+ concepts.push({ id, file, record, match, tokens: [...new Set(taken)] });
692
+ }
693
+
694
+ return { operations, concepts, jurisdictions, tokens, consumed, nearMiss: nearMisses(model, tokens, operations, concepts, jurisdictions) };
695
+ }
696
+
697
+ /**
698
+ * Vocabulary entries that share tokens with the query but did NOT match
699
+ * (UCS-1152).
700
+ *
701
+ * The answer that was nearly right, reported with the overlap that carried it.
702
+ * A reader whose query returned nothing useful needs to see these more than
703
+ * anyone: the near-miss is where a store's vocabulary and its users' vocabulary
704
+ * are visibly drifting apart, and a search that reports only its hits lets that
705
+ * drift run silently until the store stops being usable.
706
+ *
707
+ * All three axes are swept, not just concepts, because a verb or a place can
708
+ * near-miss exactly as a noun can — an ask that half-names a place should say
709
+ * which jurisdiction it was a token away from rather than reporting a bare zero.
710
+ *
711
+ * Sorted by kind then id, so the section is byte-stable regardless of the order
712
+ * the vocabularies happened to load in.
713
+ */
714
+ function nearMisses(model, tokens, operations, concepts, jurisdictions) {
715
+ const out = [];
716
+ const matchedValues = (hits) => new Set(hits.map((h) => h.value));
717
+
718
+ const sweepRegistry = (kind, key, hits) => {
719
+ const already = matchedValues(hits);
720
+ for (const value of mintedValues(model, key)) {
721
+ if (already.has(value)) continue;
722
+ // The widest spelling decides the overlap: `add-token` opened into
723
+ // ["add","token"] shares a token with "token" that the closed spelling
724
+ // never would, and reporting the narrower answer would hide the miss.
725
+ let overlap = [];
726
+ for (const { words: phrase } of valuePhrases(value)) {
727
+ const shared = phraseOverlap(phrase, tokens);
728
+ if (shared.length > overlap.length) overlap = shared;
729
+ }
730
+ if (overlap.length) out.push({ kind, id: value, overlap });
731
+ }
732
+ };
733
+
734
+ sweepRegistry('operation', OPERATIONS_REGISTRY, operations);
735
+ sweepRegistry('jurisdiction', JURISDICTIONS_REGISTRY, jurisdictions);
736
+
737
+ const matchedConcepts = new Set(concepts.map((c) => c.id));
738
+ for (const { id, record } of model.concepts.values()) {
739
+ if (matchedConcepts.has(id)) continue;
740
+ let overlap = [];
741
+ for (const name of [record.term, ...strings(record.aliases)]) {
742
+ if (typeof name !== 'string') continue;
743
+ const shared = phraseOverlap(phraseWords(name), tokens);
744
+ if (shared.length > overlap.length) overlap = shared;
745
+ }
746
+ if (overlap.length) out.push({ kind: 'concept', id, overlap });
747
+ }
748
+
749
+ return out.sort((a, b) => compare(a.kind, b.kind) || compare(a.id, b.id));
750
+ }
751
+
752
+ /**
753
+ * The leaves a decomposed query reaches, as FIRST-CLASS SCORED RESULTS
754
+ * (UCS-1152).
755
+ *
756
+ * Before this ticket a leaf could only appear as an attachment to a concept
757
+ * result, which made an entire class of correct answer unreachable: "add a
758
+ * token" resolves a VERB, and the leaf declaring that operation is the answer
759
+ * whether or not any concept matched. Hanging it off a concept meant either
760
+ * guessing a noun to hang it on or losing it.
761
+ *
762
+ * Scored additively over the structured joins — see lib/scoring.js for why this
763
+ * family adds where the concept ladder does not. Every leaf carries its
764
+ * `signals`, so the score is reproducible: `sum(signals[].score) === score`.
765
+ *
766
+ * Concept-declaration joins read the loader's reverse index (`leavesByConcept`),
767
+ * the same structural edge UCS-1151 built, so a leaf reaches its concept without
768
+ * term luck here exactly as it does there.
769
+ */
770
+ function scoreLeaves(model, decomposition, today) {
771
+ const { operations, concepts, tokens } = decomposition;
772
+ const declaringConcept = new Map(); // leaf identity -> concept ids it declares
773
+ for (const { id } of concepts) {
774
+ for (const identity of model.leavesByConcept.get(id) ?? []) {
775
+ if (!declaringConcept.has(identity)) declaringConcept.set(identity, []);
776
+ declaringConcept.get(identity).push(id);
777
+ }
778
+ }
779
+
780
+ const scored = [];
781
+ for (const entry of model.leaves.values()) {
782
+ const { record: leaf } = entry;
783
+ // Signals are gathered in DESCENDING weight — operation, concept, term —
784
+ // so the strongest reason a leaf surfaced reads first in the output.
785
+ //
786
+ // WITHIN each weight class they are sorted by `via`, and that sort is a
787
+ // determinism requirement rather than tidiness. Every one of these three
788
+ // sources is an AUTHORED array (the query's matched operations, the leaf's
789
+ // declared concepts, the leaf's `terms`), so emitting them in encounter
790
+ // order would make the published `signals` depend on the order somebody
791
+ // happened to write a list in — two stores with identical content and
792
+ // different authoring order would produce different bytes, which is exactly
793
+ // what D-012 forbids. Sorting on the value makes the output a function of
794
+ // WHAT a leaf declares, never of the sequence it was typed in.
795
+ const declaredOps = leafOperations(leaf);
796
+ const operationSignals = operations
797
+ .filter(({ value }) => declaredOps.includes(value))
798
+ .map(({ value }) => ({ signal: 'operation', via: value }));
799
+ const conceptSignals = [...(declaringConcept.get(entry.identity) ?? [])]
800
+ .map((id) => ({ signal: 'concept', via: id }));
801
+ const termTokens = [];
802
+ const termSignals = [];
803
+ for (const term of strings(leaf.terms)) {
804
+ const hit = phraseHit(phraseWords(term), tokens);
805
+ if (!hit) continue;
806
+ termTokens.push(...hit);
807
+ termSignals.push({ signal: 'term', via: term });
808
+ }
809
+ const byVia = (a, b) => compare(a.via, b.via);
810
+ const signals = [
811
+ ...operationSignals.sort(byVia),
812
+ ...conceptSignals.sort(byVia),
813
+ ...termSignals.sort(byVia),
814
+ ];
815
+ if (!signals.length) continue;
816
+ // A leaf's own term text consumes tokens too — it is a join like any other,
817
+ // and a token it accounted for is not unresolved. Recorded on the shared
818
+ // consumed set so residue sees it.
819
+ for (const t of termTokens) decomposition.consumed.add(t);
820
+ const { score, signals: weighted } = leafScore(signals);
821
+ scored.push({ entry, score, signals: weighted });
822
+ }
823
+ // Both declared arrays are SORTED before publication, for the same reason the
824
+ // signals are: they are authored lists, and byte-stable output must be a
825
+ // function of what a leaf declares rather than the order its author typed it
826
+ // (D-012). Sorted copies, never in place — mutating the loaded record would
827
+ // reorder the model every other surface reads.
828
+ return scored.map(({ entry, score, signals }) => ({
829
+ score,
830
+ signals,
831
+ applies: [...leafJurisdictions(entry.record)].sort(compare),
832
+ [OPERATIONS_FIELD]: [...leafOperations(entry.record)].sort(compare),
833
+ ...publishLeaf(model, entry, today),
834
+ }));
835
+ }
836
+
837
+ /**
838
+ * Split scored leaves into those the query's scope keeps and those it excludes
839
+ * (UCS-1152).
840
+ *
841
+ * A leaf is EXCLUDED when it declares jurisdictions and the query named a
842
+ * jurisdiction that is not among them. It is excluded WITH ITS REASON and
843
+ * published in its own section — never silently absent, which is the acceptance
844
+ * criterion and the whole point. "No knowledge about theming tokens for us-ca"
845
+ * and "the knowledge about theming tokens is eu-eaa-only" demand opposite
846
+ * conduct from a reader, and a filtered-away leaf makes them indistinguishable.
847
+ *
848
+ * A leaf declaring NO jurisdictions is universal and never excluded — see
849
+ * `leafJurisdictions`. A query naming no jurisdiction excludes nothing: with no
850
+ * scope asserted there is nothing to be out of scope of.
851
+ */
852
+ function applyScope(scored, jurisdictions) {
853
+ if (!jurisdictions.length) return { kept: scored, excluded: [] };
854
+ const asked = jurisdictions.map((j) => j.value);
855
+ const kept = [];
856
+ const excluded = [];
857
+ for (const leaf of scored) {
858
+ if (!leaf.applies.length || leaf.applies.some((j) => asked.includes(j))) {
859
+ kept.push(leaf);
860
+ continue;
861
+ }
862
+ excluded.push({
863
+ id: leaf.id,
864
+ notation: leaf.notation,
865
+ heading: leaf.heading,
866
+ file: leaf.file,
867
+ applies: leaf.applies,
868
+ asked,
869
+ reason: `declares applies.jurisdictions [${leaf.applies.join(', ')}] — the query is scoped to [${asked.join(', ')}], which this leaf does not cover (UCS-1152)`,
870
+ });
871
+ }
872
+ return { kept, excluded };
873
+ }
874
+
875
+ /**
876
+ * Rank scored leaves: time-verdict and stage demotions first, then score
877
+ * (UCS-1152).
878
+ *
879
+ * `downranked` is already the UNION of the stage and time demotions
880
+ * (publishLeaf, UCS-1150), so sorting on it applies BOTH demotions through one
881
+ * comparator rather than two competing ones — a stale leaf and a draft leaf
882
+ * both sort below the promoted, fresh ones, and a leaf that is both reports
883
+ * both reasons without either absorbing the other.
884
+ *
885
+ * Demotion before score, deliberately: a high-scoring stale leaf is still one
886
+ * whose claims nobody has re-verified, and putting it above a fresh lower-scoring
887
+ * answer would rank confidence above currency. It is a demotion and never a
888
+ * filter — the leaf is still published, still scored, still explains itself.
889
+ */
890
+ const rankLeaves = (leaves) => [...leaves].sort((a, b) =>
891
+ Number(a.downranked) - Number(b.downranked)
892
+ || b.score - a.score
893
+ || compare(a.id ?? a.notation, b.id ?? b.notation));
894
+
895
+ /**
896
+ * THE JOIN CORE — one text in, the store's joins out (UCS-1156).
897
+ *
898
+ * Extracted from `resolveQuery` so that a query and a document section reach
899
+ * the store through the SAME function rather than through two implementations
900
+ * that are supposed to agree. This is what makes the ticket's size-invariance
901
+ * claim a property of the code instead of a promise: `resolveQuery` calls it
902
+ * with the whole query, and `--doc` calls it once per section, so a query and
903
+ * its equivalent one-block document cannot produce different joins.
904
+ *
905
+ * Everything here was already the query path's behavior — the decomposition,
906
+ * the additive leaf scoring, the scope exclusion, the ranking. Nothing about
907
+ * matching changed; only its call site moved so a second caller could exist.
908
+ *
909
+ * @param {object} model the loaded store model
910
+ * @param {string} raw the text to join — a query, or one section's prose
911
+ * @param {string|null} today the injected date verdicts are measured against
912
+ * @returns {{query, tokens, decomposition, leaves, exclusions, residue, ...}}
913
+ */
914
+ function joinText(model, raw, today) {
915
+ const query = norm(raw);
916
+ const queryWords = words(query);
917
+ const tokens = tokenize(raw);
918
+ const decomposition = decompose(model, query, queryWords, tokens);
919
+ const { kept, excluded } = applyScope(scoreLeaves(model, decomposition, today), decomposition.jurisdictions);
920
+ // Residue is computed LAST, after every join has had its chance to consume:
921
+ // it is defined as what nothing resolved, so anything computed earlier would
922
+ // be measuring a partially-run decomposition. De-duplicated, in query order —
923
+ // a token the user typed twice is one unresolved thing.
924
+ const residue = [...new Set(tokens.filter((t) => !decomposition.consumed.has(t) && !STOPWORDS.has(t)))];
925
+ return {
926
+ query,
927
+ queryWords,
928
+ tokens,
929
+ decomposition,
930
+ operations: decomposition.operations,
931
+ concepts: decomposition.concepts,
932
+ jurisdictions: decomposition.jurisdictions,
933
+ leaves: rankLeaves(kept),
934
+ exclusions: excluded.sort((a, b) => compare(a.id ?? a.notation, b.id ?? b.notation)),
935
+ residue,
936
+ };
937
+ }
938
+
939
+ function resolveQuery(model, terms, today) {
940
+ const raw = terms.join(' ');
941
+ if (!words(norm(raw)).length) throw new UsageError('query terms must contain a word');
942
+ const joined = joinText(model, raw, today);
943
+ const { query, decomposition, tokens, residue } = joined;
944
+
945
+ // Concept results keep their pre-1152 shape and their pre-1152 scores — the
946
+ // structured joins are ADDITIVE surface, never a renegotiation of a ranking
947
+ // consumers already read.
948
+ const results = [];
949
+ for (const { id, file, record, match } of decomposition.concepts) {
950
+ // Only LADDER matches become concept results. A concept the phrase test
951
+ // reached but the ladder did not has no rung and therefore no score, and
952
+ // inventing one would put concepts in this list that the pre-1152 engine
953
+ // never returned — breaking the ranking this ticket promised to leave
954
+ // alone. It is still published in `decomposition.concepts`, where it is
955
+ // what it actually is: a noun the ask named, joined structurally to leaves.
956
+ if (match === null) continue;
957
+ results.push({
958
+ id,
959
+ term: record.term ?? null,
960
+ summary: record.summary ?? null,
961
+ status: record.status ?? null,
962
+ score: conceptScore(match, record.status),
963
+ match,
964
+ file,
965
+ 'source-of-truth': strings(record['source-of-truth']),
966
+ 'confusable-with': confusables(model, record),
967
+ knowledge: knowledgeEntryPoints(model, record, id, today),
968
+ });
969
+ }
970
+ results.sort((a, b) => b.score - a.score || compare(a.id, b.id));
971
+
972
+ return {
973
+ query,
974
+ // The decomposition itself, published (UCS-1152) — which vocabulary each
975
+ // axis of the ask landed in, and what did not land anywhere. This is the
976
+ // section that makes the resolution auditable: a reader can see that "add a
977
+ // token" resolved a VERB through the operations registry rather than
978
+ // guessing at a noun.
979
+ decomposition: {
980
+ tokens,
981
+ operations: decomposition.operations,
982
+ concepts: decomposition.concepts.map((c) => ({ id: c.id, term: c.record.term ?? null, match: c.match, tokens: c.tokens })),
983
+ jurisdictions: decomposition.jurisdictions,
984
+ // Every one of these is a STABLE key that may be an empty array, never an
985
+ // omitted one: a consumer must not need a presence check to tell "nothing
986
+ // near-missed" from "this engine predates near-miss reporting".
987
+ 'near-miss': decomposition.nearMiss,
988
+ residue,
989
+ // The resolved context a residue finding is logged ALONGSIDE (UCS-1152).
990
+ // The acceptance criterion is that residue is emitted "with the resolved
991
+ // context attached" — a bare unresolved token is a finding nobody can act
992
+ // on, while "`stencil` was unresolved in an ask that DID resolve
993
+ // add-token and eu-eaa" localizes the gap precisely enough that the
994
+ // minting decision writes itself.
995
+ 'resolved-context': [
996
+ ...decomposition.operations.map((o) => o.value),
997
+ ...decomposition.concepts.map((c) => c.id),
998
+ ...decomposition.jurisdictions.map((j) => j.value),
999
+ ],
1000
+ },
1001
+ // The scoring table the ranking above was computed with, so a consumer
1002
+ // reproducing it never hard-codes weights or vendors a copy that goes stale.
1003
+ scoring: scoringTable(),
1004
+ results,
1005
+ // Leaves as FIRST-CLASS scored results, not attachments (UCS-1152). The
1006
+ // concept-attached `knowledge` lists above are untouched and still
1007
+ // published: this extends the payload rather than breaking it, so every
1008
+ // existing consumer keeps working while a new one can read leaves directly.
1009
+ leaves: joined.leaves,
1010
+ // Excluded, never silently absent.
1011
+ exclusions: joined.exclusions,
1012
+ // Zero resolution is a NORMAL outcome and must be machine-distinguishable
1013
+ // from a failure (PRD §7). The conduct text is IN THE PAYLOAD rather than
1014
+ // only on the human surface, so an agent reading JSON is told what to do
1015
+ // next instead of inferring it from an empty array.
1016
+ ...(results.length || joined.leaves.length ? {} : { conduct: ZERO_RESOLUTION_CONDUCT }),
1017
+ };
1018
+ }
1019
+
1020
+ /**
1021
+ * What to do when nothing resolved (PRD §7) — the fallback conduct, in the
1022
+ * payload.
1023
+ *
1024
+ * Zero resolution exits 0 and is a normal outcome, common in month one. What
1025
+ * makes it machine-distinguishable from a failure is not the exit code alone
1026
+ * but this: an explicit empty result WITH the conduct that follows from it. An
1027
+ * agent that receives empty arrays and no instruction has to guess whether the
1028
+ * lookup failed or the store is simply silent on the topic, and those demand
1029
+ * different next steps.
1030
+ */
1031
+ const ZERO_RESOLUTION_CONDUCT = 'zero resolution is a normal outcome (PRD §7): fall back to search within survey-scope.yaml; append a retrieval-miss finding only if this topic plausibly should be mapped (an unmapped area the scope excludes is expected, not a miss)';
1032
+
1033
+ // ---------------------------------------------------------------- paths mode
1034
+
1035
+ /**
1036
+ * Normalize a path to the repo-root-relative posix form pointers use (§9.1):
1037
+ * backslashes become '/', `..`/`.`/`//` resolve away (path.posix semantics),
1038
+ * trailing slashes drop, and absolute paths relativize against `root`.
1039
+ * Wrong normalization is wrong ATTRIBUTION — `a/b/../c.ts` must hit the file
1040
+ * pointer `a/c.ts`, not the folder pointer `a/b`.
1041
+ */
1042
+ function normPath(root, p) {
1043
+ let path = posix.normalize(p.trim().replace(/\\/g, '/'));
1044
+ if (posix.isAbsolute(path)) path = posix.relative(root.replace(/\\/g, '/'), path);
1045
+ path = path.replace(/\/+$/, '');
1046
+ return path === '.' ? '' : path;
1047
+ }
1048
+
1049
+ /**
1050
+ * §3.1: a folder pointer nests over its subtree. Ask the filesystem, never
1051
+ * the name — the map is never the fact. `src/api.v2` is a directory whose
1052
+ * `extname` is ".v2", so a name-based guess drops every path beneath it: a
1053
+ * silent missed attribution in exactly the ACT pre-commit check a developer
1054
+ * trusts to say which concepts their change touches.
1055
+ *
1056
+ * The filesystem decides whenever it can. It cannot when the pointer does not
1057
+ * exist — `--paths` is fed from a diff, and a diff names deleted paths — so a
1058
+ * missing pointer falls back to the name: no extension, treat as a folder.
1059
+ * That keeps deletion attribution working (deleting a source-of-truth
1060
+ * directory still flags its concept) while never nesting under something that
1061
+ * looks like a file. Erring this way keeps every residual gap on the
1062
+ * missed-attribution side: a deleted DOTTED directory stops nesting, which
1063
+ * under-reports. Over-reporting would put a concept the change never touched
1064
+ * in front of a human, and false attribution is the costlier error here.
1065
+ *
1066
+ * An unreadable pointer (EACCES on its parent) is epistemically the same as an
1067
+ * absent one — the filesystem declines to say — so it takes the same name
1068
+ * fallback rather than crashing a lookup that can still answer for every other
1069
+ * pointer. `throwIfNoEntry: false` silences ENOENT only.
1070
+ *
1071
+ * Stat once per pointer: `--paths` crosses every pointer with every path.
1072
+ */
1073
+ function folderPointerTest(repoRoot) {
1074
+ const cache = new Map();
1075
+ const statOrNull = (path) => {
1076
+ try {
1077
+ return statSync(path, { throwIfNoEntry: false }) ?? null;
1078
+ } catch {
1079
+ return null; // unreadable: the filesystem cannot decide, so the name does
1080
+ }
1081
+ };
1082
+ return (pointer) => {
1083
+ if (!cache.has(pointer)) {
1084
+ const stat = statOrNull(join(repoRoot, pointer));
1085
+ cache.set(pointer, stat ? stat.isDirectory() : posix.extname(pointer) === '');
1086
+ }
1087
+ return cache.get(pointer);
1088
+ };
1089
+ }
1090
+
1091
+ function resolvePaths(model, rawPaths, repoRoot, today) {
1092
+ // Pointers are repo-root-relative (§9.1), so both sides normalize against
1093
+ // the repo root — the KK-08 two-root convention (model.root may be the
1094
+ // nested unknown-knowledge/ store dir in a seeded repo).
1095
+ // An entry that normalizes away (empty, ".", "src/..") names the repo root,
1096
+ // not a path inside it. Dropping it silently would shrink the lookup the
1097
+ // caller asked for — a lookup that never ran, wearing a clean exit.
1098
+ const rootish = rawPaths.filter((p) => normPath(repoRoot, p) === '');
1099
+ if (rootish.length) {
1100
+ throw new UsageError(`--paths entries ${rootish.map((p) => JSON.stringify(p)).join(', ')} name the repo root, not a path inside it — name the files or directories the change touched`);
1101
+ }
1102
+ const paths = [...new Set(rawPaths.map((p) => normPath(repoRoot, p)))].sort(compare);
1103
+ if (!paths.length) {
1104
+ throw new UsageError('--paths must name at least one path — a lookup that never ran is a failure, never a silent empty result');
1105
+ }
1106
+ const isFolderPointer = folderPointerTest(repoRoot);
1107
+ // Leaf `paths` are the second pointer family (UCS-1151), indexed once for the
1108
+ // whole lookup rather than re-walked per path: --paths already crosses every
1109
+ // pointer with every path, and a diff is routinely hundreds of paths.
1110
+ const leafPointers = leafPathIndex(model);
1111
+ /**
1112
+ * Does this declared pointer govern this path — exactly, or by nesting?
1113
+ *
1114
+ * A pointer that normalizes to EMPTY names the repo root, and it is skipped
1115
+ * rather than treated as a folder that nests over everything. The structural
1116
+ * validator refuses such a pointer outright (`missing-path`: a pointer at
1117
+ * everything attributes nothing), so this branch only runs against a store
1118
+ * that has not been validated — and the resolver deliberately never gates on
1119
+ * store health (§4), so it is reachable. Matching every path here would put
1120
+ * one leaf in front of every developer regardless of what they touched,
1121
+ * which is the false-attribution direction §3.1 already calls the costlier
1122
+ * error. Skipping keeps the residual gap on the under-reporting side, and
1123
+ * the validator is where the author is told to fix it.
1124
+ */
1125
+ const governs = (pointer, path) => {
1126
+ const p = normPath(repoRoot, pointer);
1127
+ if (p === '') return false;
1128
+ return path === p || (isFolderPointer(p) && path.startsWith(`${p}/`));
1129
+ };
1130
+ return paths.map((path) => {
1131
+ const seen = new Set();
1132
+ const concepts = [];
1133
+ for (const [pointer, ids] of model.pointers) {
1134
+ if (!governs(pointer, path)) continue;
1135
+ for (const id of ids) {
1136
+ if (seen.has(id)) continue; // keep the lexicographically first pointer
1137
+ seen.add(id);
1138
+ const record = model.concepts.get(id)?.record;
1139
+ concepts.push({
1140
+ id,
1141
+ term: record?.term ?? null,
1142
+ status: record?.status ?? null,
1143
+ pointer,
1144
+ });
1145
+ }
1146
+ }
1147
+ concepts.sort((a, b) => compare(a.id, b.id));
1148
+ return { path, concepts, knowledge: governingLeaves(model, path, concepts, leafPointers, governs, today) };
1149
+ });
1150
+ }
1151
+
1152
+ /**
1153
+ * Leaf `paths` declarations, indexed pointer → leaf identities (UCS-1151).
1154
+ *
1155
+ * The leaf-side mirror of the loader's concept pointer index, built here rather
1156
+ * than in the loader because it is a resolver concern: nothing else joins over
1157
+ * it. Same shape and same guarantees — de-duplicated, sorted, so the reverse
1158
+ * lookup is stable regardless of authoring order.
1159
+ *
1160
+ * @param {object} model the loaded store model
1161
+ * @returns {Map<string, string[]>} declared path -> leaf identities
1162
+ */
1163
+ function leafPathIndex(model) {
1164
+ const index = new Map();
1165
+ for (const entry of model.leaves.values()) {
1166
+ for (const path of strings(entry.record?.[LEAF_PATHS_FIELD])) {
1167
+ if (!index.has(path)) index.set(path, []);
1168
+ const identities = index.get(path);
1169
+ if (!identities.includes(entry.identity)) identities.push(entry.identity);
1170
+ }
1171
+ }
1172
+ for (const identities of index.values()) identities.sort(compare);
1173
+ return index;
1174
+ }
1175
+
1176
+ /**
1177
+ * The leaves that GOVERN one path — the reverse lookup's whole point
1178
+ * (UCS-1151).
1179
+ *
1180
+ * Two joins, unioned, because a leaf can be attached to a file two different
1181
+ * ways and a developer about to edit that file needs both:
1182
+ *
1183
+ * direct the leaf declares the path in its own `paths` — it says outright
1184
+ * that it governs this part of the tree.
1185
+ * concept the path is under a CONCEPT's source-of-truth pointer, and the
1186
+ * leaf declared that concept. The knowledge is one hop away through
1187
+ * the ontology, which is exactly the join the concept pointers were
1188
+ * always for; before this ticket the reverse lookup stopped at the
1189
+ * concept and left the reader to make that hop by hand.
1190
+ *
1191
+ * `via` says which, and `direct` wins when both fire: it is the leaf's own
1192
+ * claim about this path rather than an inference through a third record.
1193
+ *
1194
+ * Concept attribution reuses the `concepts` already computed for this path, so
1195
+ * the two halves of one result can never disagree about which concepts the path
1196
+ * touched — a second pointer walk here would be a second answer to a question
1197
+ * already settled four lines up.
1198
+ *
1199
+ * Stable-sorted the same way knowledge entry points are: promoted before
1200
+ * downranked, then by identity. An agent reading top-down reaches certified
1201
+ * knowledge first, and byte-stability does not depend on store iteration order.
1202
+ */
1203
+ function governingLeaves(model, path, concepts, leafPointers, governs, today) {
1204
+ const via = new Map(); // leaf identity -> how it was reached
1205
+ for (const [pointer, identities] of leafPointers) {
1206
+ if (!governs(pointer, path)) continue;
1207
+ for (const identity of identities) via.set(identity, 'direct');
1208
+ }
1209
+ for (const { id } of concepts) {
1210
+ for (const identity of model.leavesByConcept.get(id) ?? []) {
1211
+ if (!via.has(identity)) via.set(identity, 'concept');
1212
+ }
1213
+ }
1214
+ const out = [];
1215
+ for (const [identity, how] of via) {
1216
+ const entry = model.leaves.get(identity);
1217
+ if (entry) out.push({ via: how, ...publishLeaf(model, entry, today) });
1218
+ }
1219
+ return out.sort((a, b) =>
1220
+ Number(a.downranked) - Number(b.downranked)
1221
+ || compare(a.id ?? a.notation, b.id ?? b.notation));
1222
+ }
1223
+
1224
+ // ---------------------------------------------------------------- doc mode
1225
+
1226
+ /**
1227
+ * Resolve a whole document into a COVERAGE MAP (UCS-1156).
1228
+ *
1229
+ * The document is adapted into the IR by the versioned adapter seam
1230
+ * (UCS-1153), then the coverage module sections it and streams the store's
1231
+ * vocabularies over each section — using THIS command's `joinText`, so a
1232
+ * section's join is the query pipeline run over that section's text.
1233
+ *
1234
+ * The suppression entries are loaded here, from the same client-zone
1235
+ * `suppressions.yaml` the reverse audit reads, and they FAIL OPEN exactly as
1236
+ * they do there: a malformed file suppresses nothing and its warnings travel
1237
+ * into the payload, because a suppression that could silence a candidate by
1238
+ * being broken is one nobody can trust.
1239
+ *
1240
+ * @param {object} model the loaded store model
1241
+ * @param {string} document the submitted document's path
1242
+ * @param {string} kitRoot where suppressions.yaml lives
1243
+ * @param {string|null} today the injected date
1244
+ */
1245
+ function resolveDoc(model, document, kitRoot, today) {
1246
+ // DISPATCH BEFORE READ, the same order ingest.js uses and for the same
1247
+ // reason: whether the bytes exist is irrelevant when no adapter claims the
1248
+ // format, and reading first would answer a `.docx` submission with "cannot
1249
+ // read", burying the conduct the submitter actually needs.
1250
+ adapterFor(document);
1251
+ let bytes;
1252
+ try {
1253
+ bytes = readFileSync(resolvePath(document));
1254
+ } catch (error) {
1255
+ rethrowIfBug(error);
1256
+ throw new UsageError(`cannot read ${document}: ${error.message}`);
1257
+ }
1258
+ const ir = adapt(document, bytes);
1259
+ const { entries, warnings } = loadSuppressions(kitRoot);
1260
+ const map = buildCoverageMap({
1261
+ document,
1262
+ ir,
1263
+ model,
1264
+ joinSection: (text) => joinText(model, text, today),
1265
+ suppressionEntries: entries,
1266
+ });
1267
+ // Warnings surface in the payload, never only on stderr: a malformed
1268
+ // suppressions file must not vanish silently from a machine-read output.
1269
+ // A STABLE KEY that may be an empty array, like every other field in this
1270
+ // payload — a consumer must never need a presence check to tell "the
1271
+ // suppressions file was clean" from "this engine predates the warning".
1272
+ return { ...map, 'suppression-warnings': warnings };
1273
+ }
1274
+
1275
+ /**
1276
+ * The coverage map, for a human.
1277
+ *
1278
+ * Deliberately COMPACT. The map's whole promise is that it is cheaper to read
1279
+ * than the document, and a human surface that reprinted every join per section
1280
+ * would cost as much as the document it summarizes. So: one line per section
1281
+ * with its locator, one line per gathered leaf with its verdict, and the ranked
1282
+ * candidates. The `--json` payload carries everything.
1283
+ */
1284
+ function renderDoc(payload) {
1285
+ const lines = [];
1286
+ const map = payload.map;
1287
+ lines.push(
1288
+ `resolve --doc ${map.document} -> ${map.sections.length} section(s) with signal, `
1289
+ + `${map.gather.length} governed leaf/leaves, ${map['candidates-ranked'].length} candidate(s)`,
1290
+ '',
1291
+ `adapter: ${map.adapter} hash: ${map.hash} (byte-identical resubmission dedupes on this hash)`,
1292
+ `ir: ${map.ir.blocks} block(s) -> ${map.ir.sections} section(s); `
1293
+ + `repetition threshold ${map.ir['repetition-threshold']} (pinned step function of document size)`,
1294
+ '',
1295
+ );
1296
+ renderTimeCheck(payload, lines);
1297
+ renderHealth(payload['store-health'], lines);
1298
+
1299
+ if (map.sections.length) {
1300
+ lines.push('coverage by section:');
1301
+ for (const s of map.sections) {
1302
+ const at = s.locator.line === undefined
1303
+ ? `p${s.locator.page}-${s.locator.endPage}`
1304
+ : `L${s.locator.line}-${s.locator.endLine}`;
1305
+ lines.push(` ${at} ${s.section}`);
1306
+ const joins = [
1307
+ s.joins.operations.length ? `operations: ${s.joins.operations.join(', ')}` : null,
1308
+ s.joins.concepts.length ? `concepts: ${s.joins.concepts.join(', ')}` : null,
1309
+ s.joins.jurisdictions.length ? `jurisdictions: ${s.joins.jurisdictions.join(', ')}` : null,
1310
+ s.joins.leaves.length ? `leaves: ${s.joins.leaves.join(', ')}` : null,
1311
+ ].filter(Boolean);
1312
+ for (const join of joins) lines.push(` ${join}`);
1313
+ if (s.candidates.length) lines.push(` candidates: ${s.candidates.join(', ')}`);
1314
+ // Folded sections are named, not merely counted away: an agent may need
1315
+ // to open any one of them, so each keeps its own address and locator.
1316
+ if (s['repeats-count']) {
1317
+ const shown = s.repeats.map((r) => r.section).join(', ');
1318
+ const more = s['repeats-count'] - s.repeats.length;
1319
+ lines.push(` same coverage in ${s['repeats-count']} other section(s): ${shown}${more ? ` (+${more} more)` : ''}`);
1320
+ }
1321
+ }
1322
+ lines.push('');
1323
+ }
1324
+
1325
+ if (map.gather.length) {
1326
+ lines.push('gather rollup:');
1327
+ for (const g of map.gather) {
1328
+ lines.push(` ${g.id ? `${g.id} ` : ''}${g.notation} ${g.heading} score ${g.score} [${g.verdict}] (${g.file})`);
1329
+ const reached = `${g.sections.join(', ')}${g['sections-more'] ? ` (+${g['sections-more']} more)` : ''}`;
1330
+ lines.push(` signals: ${g.signals.join(', ')} sections: ${reached}`);
1331
+ // The flag is printed on its own line because it is a claim about
1332
+ // applicability the reader has to act on, not a detail of the hit.
1333
+ if (g['scope-mismatch']) lines.push(` scope-mismatch: ${g['scope-mismatch']}`);
1334
+ for (const d of g.demotions) lines.push(` demoted (${d.reason}): ${d.detail}`);
1335
+ }
1336
+ lines.push('');
1337
+ }
1338
+
1339
+ if (map['candidates-ranked'].length) {
1340
+ lines.push('candidates (ranked — the document\'s residue, section-addressed):');
1341
+ for (const c of map['candidates-ranked']) {
1342
+ const where = `${c.sections.join(', ')}${c['sections-more'] ? ` (+${c['sections-more']} more)` : ''}`;
1343
+ lines.push(` ${c.term} x${c.count} [${c.signatures.join(', ')}] in ${where}`);
1344
+ }
1345
+ lines.push('');
1346
+ }
1347
+ // Suppressed candidates are NAMED, not counted away: "reported as suppressed
1348
+ // rather than silently absent" is the acceptance criterion, and a bare count
1349
+ // would leave a reader unable to tell which term a steward had settled.
1350
+ if (map.suppressed.length) {
1351
+ lines.push('suppressed candidates (a steward refused these; reported, never silently absent):');
1352
+ for (const c of map.suppressed) lines.push(` ${c.term} x${c.count} in ${c.sections.join(', ')}`);
1353
+ lines.push('');
1354
+ }
1355
+ for (const w of payload.map['suppression-warnings'] ?? []) lines.push(w);
1356
+ lines.push('open a section just-in-time with its locator — the context cost is this map plus what you open, never the document');
1357
+ return lines;
1358
+ }
1359
+
1360
+ // ------------------------------------------------------------- CLI plumbing
1361
+
1362
+ function parseArgs(argv) {
1363
+ const { options, positionals } = parseFlags(argv, {
1364
+ boolean: ['json'],
1365
+ value: ['root', 'today', 'doc'],
1366
+ repeatable: ['paths'],
1367
+ // Query terms arrive as bare arguments; --paths is the reverse lookup;
1368
+ // --doc is the third input shape (UCS-1156).
1369
+ positionals: true,
1370
+ });
1371
+ const opts = {
1372
+ json: !!options.json,
1373
+ root: options.root ?? process.cwd(),
1374
+ paths: options.paths ? options.paths.flatMap((v) => v.split(',')) : null,
1375
+ doc: options.doc ?? null,
1376
+ terms: positionals,
1377
+ // The injected date the time verdicts are measured against (UCS-1150).
1378
+ // Null is a legitimate answer, not a default to be filled in: without it
1379
+ // the verdicts report themselves skipped, and the output says so.
1380
+ today: options.today ?? null,
1381
+ };
1382
+ if (opts.today !== null && !isCalendarDate(opts.today)) {
1383
+ // Same strictness as audit and preflight (UCS-957): `Date.parse` rolls
1384
+ // 2026-02-30 forward to March 2nd, so a leaf's age would be measured from
1385
+ // a day the caller never named — and here that age decides a demotion.
1386
+ throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(opts.today)}`);
1387
+ }
1388
+ // The three input shapes are alternatives, not a combination. Two of them at
1389
+ // once has no honest answer — a coverage map of a document is not a lookup of
1390
+ // a query — so it is a usage error rather than a silently-preferred mode.
1391
+ const shapes = [
1392
+ opts.terms.length ? 'query terms' : null,
1393
+ opts.paths ? '--paths' : null,
1394
+ opts.doc ? '--doc' : null,
1395
+ ].filter(Boolean);
1396
+ if (shapes.length > 1) {
1397
+ throw new UsageError(`give exactly one input shape, got ${shapes.join(' and ')} — a query, --paths, or --doc`);
1398
+ }
1399
+ if (!shapes.length) {
1400
+ throw new UsageError('nothing to resolve — give query terms, --paths, or --doc');
1401
+ }
1402
+ return opts;
1403
+ }
1404
+
1405
+ function renderHealth(health, lines) {
1406
+ if (health.ok && !health.warnings) return; // warnings surface even when ok
1407
+ lines.push(
1408
+ `store health: ${health.errors} error(s), ${health.warnings} warning(s) — resolution ran on what loaded; run preflight for verdicts`,
1409
+ '',
1410
+ );
1411
+ }
1412
+
1413
+ /**
1414
+ * A leaf's one-hop neighborhood, for the human surface (UCS-1151).
1415
+ *
1416
+ * Only NON-EMPTY kinds are printed, which is the opposite of the JSON contract
1417
+ * on purpose: JSON keeps every kind as a stable key so a consumer never needs a
1418
+ * presence check, while a human reading four "(none)" lines per leaf learns
1419
+ * nothing and loses the hits in the noise. The kind is always named — the whole
1420
+ * value of a typed edge is that `contradicts` and `see-also` do not mean the
1421
+ * same thing to the agent deciding what to read next.
1422
+ *
1423
+ * @param {object} leaf a published leaf
1424
+ * @param {string} indent leading whitespace for the block
1425
+ */
1426
+ function renderRelates(leaf, indent = ' ') {
1427
+ const lines = [];
1428
+ for (const kind of RELATES_KINDS) {
1429
+ const neighbors = leaf[RELATES_FIELD]?.[kind] ?? [];
1430
+ if (!neighbors.length) continue;
1431
+ lines.push(`${indent}${kind}: ${neighbors.map((n) => `${n.id ?? n.notation} "${n.heading ?? '?'}"`).join(', ')}`);
1432
+ }
1433
+ return lines;
1434
+ }
1435
+
1436
+ /**
1437
+ * The demotion marker for one published leaf, for the human surface
1438
+ * (UCS-1150).
1439
+ *
1440
+ * Every demotion that fired is named, on the line the ordering already put the
1441
+ * leaf on — a reader skimming top-down sees WHY a leaf sits at the bottom
1442
+ * without a second lookup. A leaf that is both draft and stale shows both: the
1443
+ * two are independent reasons to distrust it, and printing only the first would
1444
+ * hide a stale verdict behind a draft one.
1445
+ */
1446
+ const renderDemotions = (leaf) => (leaf.demotions?.length
1447
+ ? ` [${leaf.demotions.map((d) => (d.reason === 'time'
1448
+ ? `stale — ${leaf.time.volatility} verified ${leaf.time.age}d ago > ${leaf.time.limit}d`
1449
+ : `${leaf.stage} — downranked`)).join('; ')}]`
1450
+ : '');
1451
+
1452
+ /**
1453
+ * Whether time verdicts ran, printed on every human run (UCS-1150).
1454
+ *
1455
+ * Unconditional, and that is the requirement rather than a stylistic choice: a
1456
+ * run without `--today` computed no freshness verdicts, and a surface that said
1457
+ * nothing would be indistinguishable from one that checked and found everything
1458
+ * fresh. A check that never ran is never a silent pass (PRD §5).
1459
+ */
1460
+ const renderTimeCheck = (payload, lines) => lines.push(`time check: ${payload['time-check']}`, '');
1461
+
1462
+ /**
1463
+ * The decomposition block, for the human surface (UCS-1152).
1464
+ *
1465
+ * Printed BEFORE the results, because it is what the results follow from: a
1466
+ * reader who sees "verb: add-token" first understands why leaves about token
1467
+ * registries came back for an ask that named no concept. Only non-empty axes
1468
+ * print — except residue and exclusions, which print their absence explicitly
1469
+ * elsewhere, since "nothing was unresolved" is a claim worth making out loud.
1470
+ */
1471
+ function renderDecomposition(payload, lines) {
1472
+ const d = payload.decomposition;
1473
+ if (!d) return;
1474
+ lines.push('decomposition:');
1475
+ if (d.operations.length) {
1476
+ lines.push(` verb -> operations: ${d.operations.map((o) => `${o.value} (matched "${o.matched}")`).join(', ')}`);
1477
+ }
1478
+ if (d.concepts.length) {
1479
+ // `match` is the ladder's rung, and it is null for a concept the phrase
1480
+ // test reached but the ladder did not. Printed as `named` rather than as
1481
+ // "null", because that is what it means: the ask named this concept without
1482
+ // being a query for it, which is the ordinary case for any ask that says
1483
+ // more than one thing.
1484
+ lines.push(` noun -> concepts: ${d.concepts.map((c) => `${c.id} "${c.term ?? '?'}" (${c.match ?? 'named'})`).join(', ')}`);
1485
+ }
1486
+ if (d.jurisdictions.length) {
1487
+ lines.push(` place -> jurisdictions: ${d.jurisdictions.map((j) => `${j.value} (matched "${j.matched}")`).join(', ')}`);
1488
+ }
1489
+ if (!d.operations.length && !d.concepts.length && !d.jurisdictions.length) {
1490
+ lines.push(' no axis of this ask joined a governed vocabulary');
1491
+ }
1492
+ // Residue is stated either way. "Residue: none" is the store saying it
1493
+ // understood the whole ask, which is a different and more useful message than
1494
+ // saying nothing at all.
1495
+ lines.push(d.residue.length
1496
+ ? ` residue (unresolved): ${d.residue.join(', ')} [resolved context: ${d['resolved-context'].join(', ') || 'none'}]`
1497
+ : ' residue: none — every non-stopword token resolved');
1498
+ for (const m of d['near-miss']) {
1499
+ lines.push(` near-miss: ${m.kind} ${m.id} — token overlap [${m.overlap.join(', ')}] below the match threshold`);
1500
+ }
1501
+ lines.push('');
1502
+ }
1503
+
1504
+ /** Scope exclusions, for the human surface — excluded, never silently absent. */
1505
+ function renderExclusions(payload, lines) {
1506
+ if (!payload.exclusions?.length) return;
1507
+ lines.push('excluded by scope:');
1508
+ for (const x of payload.exclusions) {
1509
+ lines.push(` ${x.id ? `${x.id} ` : ''}${x.notation} ${x.heading} (${x.file})`);
1510
+ lines.push(` ${x.reason}`);
1511
+ }
1512
+ lines.push('');
1513
+ }
1514
+
1515
+ /** Leaves as first-class results, for the human surface (UCS-1152). */
1516
+ function renderLeaves(payload, lines) {
1517
+ if (!payload.leaves?.length) return;
1518
+ lines.push(`knowledge leaves -> ${payload.leaves.length}`);
1519
+ for (const leaf of payload.leaves) {
1520
+ lines.push(` ${leaf.id ? `${leaf.id} ` : ''}${leaf.notation} ${leaf.heading} score ${leaf.score}${renderDemotions(leaf)} (${leaf.file})`);
1521
+ // The signals, so the score is reproducible on the human surface too — a
1522
+ // ranking a reader cannot decompose is one they cannot check.
1523
+ lines.push(` signals: ${leaf.signals.map((s) => `${s.signal}:${s.via} +${s.score}`).join(', ')}`);
1524
+ if (leaf.excerpt) lines.push(` ${leaf.excerpt}`);
1525
+ lines.push(...renderRelates(leaf, ' '));
1526
+ }
1527
+ lines.push('');
1528
+ }
1529
+
1530
+ function renderQuery(payload) {
1531
+ const lines = [];
1532
+ const n = payload.results.length;
1533
+ lines.push(`resolve "${payload.query}" -> ${n} concept${n === 1 ? '' : 's'}`, '');
1534
+ renderTimeCheck(payload, lines);
1535
+ renderHealth(payload['store-health'], lines);
1536
+ renderDecomposition(payload, lines);
1537
+ renderLeaves(payload, lines);
1538
+ renderExclusions(payload, lines);
1539
+ if (!n) {
1540
+ // The same conduct the payload carries, so the two surfaces cannot drift
1541
+ // into telling a reader different things about the same empty result.
1542
+ if (payload.conduct) lines.push(payload.conduct);
1543
+ else lines.push('no concepts matched, but knowledge leaves resolved directly — see above');
1544
+ return lines;
1545
+ }
1546
+ for (const r of payload.results) {
1547
+ lines.push(`${r.id} ${r.term} [${r.status}] score ${r.score} (${r.match})`);
1548
+ for (const c of r['confusable-with']) {
1549
+ lines.push(` confusable-with: ${c.id} "${c.term ?? '?'}" — confirm this is the concept you mean`);
1550
+ }
1551
+ if (r.summary) lines.push(` summary: ${r.summary}`);
1552
+ if (r['source-of-truth'].length) {
1553
+ lines.push(' source-of-truth:');
1554
+ for (const p of r['source-of-truth']) lines.push(` ${p}`);
1555
+ }
1556
+ if (r.knowledge.length) {
1557
+ lines.push(' knowledge entry points:');
1558
+ // The accession leads — it is the leaf's identity and what a citation
1559
+ // must spell (UCS-1147) — with the legacy notation still shown after it,
1560
+ // since a reader navigating an older tree still recognizes it.
1561
+ // The draft marker rides the same line as the heading, so the demotion is
1562
+ // visible where the ordering already put the leaf last — an agent
1563
+ // skimming the list sees WHY a leaf sits at the bottom without a second
1564
+ // lookup. The derived excerpt follows indented beneath, which is the
1565
+ // display prose the retired `description` field used to be for; a leaf
1566
+ // whose body opens with no prose simply shows no excerpt line.
1567
+ for (const k of r.knowledge) {
1568
+ lines.push(` ${k.id ? `${k.id} ` : ''}${k.notation} ${k.heading}${renderDemotions(k)} (${k.file})`);
1569
+ if (k.excerpt) lines.push(` ${k.excerpt}`);
1570
+ lines.push(...renderRelates(k));
1571
+ }
1572
+ }
1573
+ lines.push('');
1574
+ }
1575
+ return lines;
1576
+ }
1577
+
1578
+ function renderPaths(payload) {
1579
+ const lines = [];
1580
+ const n = payload.paths.length;
1581
+ lines.push(`resolve --paths -> ${n} path${n === 1 ? '' : 's'}`, '');
1582
+ renderTimeCheck(payload, lines);
1583
+ renderHealth(payload['store-health'], lines);
1584
+ for (const { path, concepts, knowledge } of payload.paths) {
1585
+ lines.push(path);
1586
+ if (!concepts.length) {
1587
+ lines.push(' no concepts point at this path');
1588
+ }
1589
+ for (const c of concepts) {
1590
+ lines.push(` ${c.id} ${c.term ?? '?'} [${c.status}] (pointer: ${c.pointer})`);
1591
+ }
1592
+ // The leaves that govern this path (UCS-1151) — the knowledge to read
1593
+ // BEFORE editing the file, which is the whole reason a diff is fed in here.
1594
+ // `via` is shown because "this leaf names your file" and "this leaf covers a
1595
+ // concept your file is under" are different strengths of claim.
1596
+ if (knowledge.length) {
1597
+ lines.push(' governing knowledge:');
1598
+ for (const k of knowledge) {
1599
+ lines.push(` ${k.id ? `${k.id} ` : ''}${k.notation} ${k.heading} [via ${k.via}]${renderDemotions(k)} (${k.file})`);
1600
+ if (k.excerpt) lines.push(` ${k.excerpt}`);
1601
+ lines.push(...renderRelates(k));
1602
+ }
1603
+ }
1604
+ lines.push('');
1605
+ }
1606
+ lines.push('update every concept listed above in the same commit as the change (PRD §7 ACT)');
1607
+ return lines;
1608
+ }
1609
+
1610
+ export function main(argv) {
1611
+ {
1612
+ const opts = parseArgs(argv);
1613
+
1614
+ let model;
1615
+ let kitRoot;
1616
+ try {
1617
+ // KK-08 two-root convention: --root is the REPO root; the stores live
1618
+ // at <root>/unknown-knowledge/ when seeded (§9.1) or at the root itself
1619
+ // (dogfood layout).
1620
+ kitRoot = locateKitRoot(opts.root);
1621
+ model = loadStores(kitRoot);
1622
+ } catch (error) {
1623
+ // An EXPECTED refusal from the loader — an unreadable root, an ambiguous
1624
+ // kit layout, a Store that will not load. The stores this command would
1625
+ // check never loaded, so its checks never ran: exit 2, never 1.
1626
+ process.stderr.write(`resolve: ${error.message}\n`);
1627
+ rethrowIfBug(error); // a bug, or a UsageError raised deep in the loader, is not ours to speak for
1628
+ return EXIT_CODES.FAILURE;
1629
+ }
1630
+
1631
+ const health = healthSummary(storeHealth(model));
1632
+ // `time-check` is on EVERY payload, in both modes, whether or not --today
1633
+ // was passed (UCS-1150). A run that computed no verdicts must say so in
1634
+ // its output — otherwise it is indistinguishable from one that checked and
1635
+ // found everything fresh, which is a check that never ran wearing a clean
1636
+ // result (PRD §5).
1637
+ const timeCheck = timeCheckStatus(opts.today);
1638
+ let payload;
1639
+ if (opts.doc) {
1640
+ // An out-of-envelope submission is an ANTICIPATED REFUSAL, not a bug: it
1641
+ // exits 2 with the adapter's conduct, because a parse that never ran is a
1642
+ // failure and never a silent partial (PRD §5.1). The exit contract is
1643
+ // unchanged — 0 ran, 2 never ran, never 1.
1644
+ try {
1645
+ payload = {
1646
+ mode: 'doc',
1647
+ 'time-check': timeCheck,
1648
+ 'store-health': health,
1649
+ map: resolveDoc(model, opts.doc, kitRoot, opts.today),
1650
+ };
1651
+ } catch (error) {
1652
+ rethrowIfBug(error);
1653
+ if (!(error instanceof UnsupportedFormatError || error instanceof AdaptError)) throw error;
1654
+ // Nothing goes to stdout: a caller piping it must receive no map at
1655
+ // all, not a truncated one.
1656
+ process.stderr.write(`error: ${error.message}\n`);
1657
+ return EXIT_CODES.FAILURE;
1658
+ }
1659
+ } else if (opts.paths) {
1660
+ payload = {
1661
+ mode: 'paths', 'time-check': timeCheck, 'store-health': health,
1662
+ paths: resolvePaths(model, opts.paths, opts.root, opts.today),
1663
+ };
1664
+ } else {
1665
+ payload = {
1666
+ mode: 'query', 'time-check': timeCheck, 'store-health': health,
1667
+ ...resolveQuery(model, opts.terms, opts.today),
1668
+ };
1669
+ }
1670
+
1671
+ const RENDER = { query: renderQuery, paths: renderPaths, doc: renderDoc };
1672
+ const lines = opts.json
1673
+ ? [JSON.stringify(payload, null, 2)]
1674
+ : RENDER[payload.mode](payload);
1675
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
1676
+ return EXIT_CODES.CLEAN;
1677
+ }
1678
+ }