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,494 @@
1
+ /**
2
+ * The derived layer (UCS-1158) — browse trees, call numbers, resolution index.
3
+ *
4
+ * Everything discovery-shaped is a PROJECTION of the flat store, regenerated on
5
+ * demand and thrown away without loss. That sentence is the whole design, and
6
+ * every decision below follows from taking it literally.
7
+ *
8
+ * WHY PLURAL TREES. The store's first life filed each leaf at one position in
9
+ * one tree, and that position was also its identity (§3.2). One tree means one
10
+ * privileged reading of the material, chosen at bootstrap by whoever wrote the
11
+ * spine, and permanent because moving a leaf broke every citation to it. The
12
+ * accession inversion (UCS-1147) removed the cost of being wrong, and this
13
+ * module spends the refund: the same flat store projects into as many trees as
14
+ * there are useful orderings, because a projection commits to nothing.
15
+ *
16
+ * The two shipped axes are not arbitrary. A steward asks "what do we know about
17
+ * design tokens?" and wants DOMAIN first, form second — subject matter is how a
18
+ * person holds a library in their head. An agent asks "what are the constraints
19
+ * here?" and wants FORM first: it is looking for a kind of thing to act on, and
20
+ * the domain narrows it afterwards. Those are two genuinely different reading
21
+ * orders over one set of facts, and before this ticket the store could serve
22
+ * only whichever one its spine happened to encode.
23
+ *
24
+ * WHY DELETING IT LOSES NOTHING. No derived artifact carries a fact that is not
25
+ * already in a leaf. There is no field an author edits here, no id minted here,
26
+ * nothing here that another surface reads back. `derived/` is a build output in
27
+ * the ordinary sense, and `regenerate` proves it: delete the directory, run the
28
+ * command, and the bytes are identical (tests/derived-round-trip). If that test
29
+ * ever fails, something started living in the derived layer that had no other
30
+ * home — which is the exact drift this design exists to prevent.
31
+ *
32
+ * WHY DEMOTION IS ANNOTATION, NEVER OMISSION. A stale or draft leaf appears in
33
+ * every tree it belongs in, wearing a visible marker. Hiding it would make the
34
+ * tree lie by omission — a browsing steward would conclude the store knows
35
+ * nothing about a subject when what is true is that what it knows has rotted,
36
+ * and those two are opposite calls to action. The demotion vocabulary is
37
+ * imported from the surfaces that already own it (`timeVerdict`,
38
+ * `isPrePromotionStatus`) rather than re-derived, so a leaf cannot read stale in
39
+ * a tree and trusted in the resolver.
40
+ *
41
+ * WHERE THE PROBABILISTIC LAYER LIVES, AND WHY IT IS HERE AND NOWHERE ELSE.
42
+ * The resolution index carries a declared SLOT for embedding recall
43
+ * (`RECALL_SLOT`) and a gate that refuses to let anything through it. The slot
44
+ * is empty by design and this ticket implements no recall — model selection and
45
+ * semantic search are out of scope per the spec. What the slot does is fix the
46
+ * architectural position so it cannot be occupied by accident somewhere worse:
47
+ * embedding recall belongs in the DERIVED index, is consulted only after the
48
+ * deterministic layer is exhausted, and produces PROPOSALS that cannot be cited
49
+ * or persisted without passing a human gate. A proposal that reached frontmatter
50
+ * would be a probabilistic guess wearing the same clothes as a cited fact, and
51
+ * no downstream reader could tell them apart. Keeping the slot in the disposable
52
+ * layer means the strongest possible guarantee: whatever the recall layer ever
53
+ * computes, deleting `derived/` deletes all of it, and the store is unchanged.
54
+ */
55
+ import { compare } from './validate-record.js';
56
+ import {
57
+ DERIVED_DIR, isPrePromotionStatus, leafStage, recordId,
58
+ } from './load-stores.js';
59
+ import { timeVerdict, timeCheckStatus } from './time-verdicts.js';
60
+ import { synthesizeCallNumber } from './call-numbers.js';
61
+
62
+ /**
63
+ * The derived layer's home — one directory, clearly disposable.
64
+ *
65
+ * Sits INSIDE the knowledge store rather than at the repo root, because the
66
+ * projection is of this store's leaves and a sibling directory would imply it
67
+ * spans all three. `derived` rather than `generated` or `build`: the word names
68
+ * the relationship to the source (it is derived FROM the leaves) rather than
69
+ * the process that made it, which is what a reader deciding whether it is safe
70
+ * to delete needs to know.
71
+ *
72
+ * Declared in lib/load-stores.js and re-exported here, because the LOADER is
73
+ * the surface that must ignore it: browse trees are markdown under `knowledge/`
74
+ * and so are leaves, so nothing but the directory name distinguishes engine
75
+ * output from a record. One string, named on both sides.
76
+ */
77
+ export { DERIVED_DIR };
78
+
79
+ /** The banner every generated artifact opens with — the disposability notice. */
80
+ export const DERIVED_BANNER = 'DERIVED — regenerated by `node payload/engine/derive.js --write`. Do not edit; do not cite. Deleting this directory loses nothing.';
81
+
82
+ /**
83
+ * The browse axes, in the order they generate.
84
+ *
85
+ * Each is a pure function from a leaf record to a facet path — the same shape
86
+ * the prototype's pivots use, because the prototype is the executable intent
87
+ * for this ticket. A missing facet contributes an `unclassified` segment rather
88
+ * than being skipped, so a leaf with no `form` files under `.../unclassified/`
89
+ * where a steward will see it, instead of silently flattening up a level into
90
+ * the domain node and looking correctly classified.
91
+ *
92
+ * Adding an axis is one row here. Nothing else in this module names an axis,
93
+ * and the command enumerates this table — so a third ordering is a data change,
94
+ * not a code change, which is the property that makes "plural" true rather than
95
+ * "two".
96
+ *
97
+ * @type {ReadonlyArray<{ key: string, label: string, path: (record: object) => string[] }>}
98
+ */
99
+ export const AXES = Object.freeze([
100
+ Object.freeze({
101
+ key: 'domain-form',
102
+ label: 'domain → form',
103
+ audience: 'stewards — subject matter first, the way a person holds a library in their head',
104
+ path: (record) => [...domainSegments(record), formOf(record)],
105
+ }),
106
+ Object.freeze({
107
+ key: 'form-domain',
108
+ label: 'form → domain',
109
+ audience: 'agents — kind of knowledge first, narrowed by subject afterwards',
110
+ path: (record) => [formOf(record), ...domainSegments(record)],
111
+ }),
112
+ ]);
113
+
114
+ /** The marker a facet path segment carries when the leaf declares no value. */
115
+ export const UNCLASSIFIED = 'unclassified';
116
+
117
+ /**
118
+ * A leaf's governed domain path, split into segments.
119
+ *
120
+ * Reads `facets.domain` — the REGISTRY-GOVERNED subject path (UCS-1148) — and
121
+ * never the legacy top-level `domain` free string, which is ungoverned and on
122
+ * its way out. Reading the legacy field would make the trees project a
123
+ * vocabulary nothing checks, so a typo would mint a shelf.
124
+ *
125
+ * @param {object} record a leaf's front-matter record
126
+ * @returns {string[]} the domain segments, or one `unclassified` segment
127
+ */
128
+ export function domainSegments(record) {
129
+ const domain = record?.facets?.domain;
130
+ if (typeof domain !== 'string' || domain === '') return [UNCLASSIFIED];
131
+ const segments = domain.split('/').filter((s) => s !== '');
132
+ return segments.length ? segments : [UNCLASSIFIED];
133
+ }
134
+
135
+ /**
136
+ * A leaf's governed form, or the unclassified marker.
137
+ *
138
+ * @param {object} record a leaf's front-matter record
139
+ * @returns {string}
140
+ */
141
+ export function formOf(record) {
142
+ const form = record?.facets?.form;
143
+ return typeof form === 'string' && form !== '' ? form : UNCLASSIFIED;
144
+ }
145
+
146
+ /**
147
+ * Every demotion that applies to one leaf, with its reason.
148
+ *
149
+ * The SAME two predicates the resolver demotes on (`isPrePromotionStatus` for
150
+ * stage, `timeVerdict().stale` for time), read through the same accessors, and
151
+ * that sharing is the point rather than a convenience: a leaf marked stale in a
152
+ * browse tree and trusted in resolver output would be one record wearing two
153
+ * answers, and a reader would have no way to know which surface to believe.
154
+ * UCS-1150's module header commits to exactly one implementation and names this
155
+ * ticket as an inheritor; this is that inheritance.
156
+ *
157
+ * Returns the same `{reason, detail}` shape the resolver publishes, so a
158
+ * consumer reading both surfaces sees one vocabulary.
159
+ *
160
+ * @param {object} record a leaf's front-matter record
161
+ * @param {string|null} today the injected date, or null
162
+ * @returns {Array<{reason: string, detail: string}>}
163
+ */
164
+ export function demotionsFor(record, today) {
165
+ const stage = leafStage(record);
166
+ const time = timeVerdict(record, today);
167
+ return [
168
+ ...(isPrePromotionStatus(stage)
169
+ ? [{ reason: 'stage', detail: `stage "${stage}" is pre-promotion — no moderator has certified this leaf's citations (UCS-1149)` }]
170
+ : []),
171
+ ...(time.stale ? [{ reason: 'time', detail: time.reason }] : []),
172
+ ];
173
+ }
174
+
175
+ /**
176
+ * Project one leaf into one axis — the row a tree prints.
177
+ *
178
+ * The call number is synthesized from the SAME path array the tree nests the
179
+ * leaf under, so the printed shelf label and the actual position cannot
180
+ * disagree. See lib/call-numbers.js for why that string is not an identity.
181
+ *
182
+ * @param {object} entry a loader-indexed leaf entry
183
+ * @param {{ path: (record: object) => string[] }} axis
184
+ * @param {string|null} today the injected date, or null
185
+ * @returns {{ id: string, path: string[], callNumber: string, heading: string|null, file: string, stage: string|null, time: object, demoted: boolean, demotions: Array<object> }}
186
+ */
187
+ export function projectLeaf(entry, axis, today) {
188
+ const record = entry.record;
189
+ const path = axis.path(record);
190
+ const id = recordId(entry);
191
+ const demotions = demotionsFor(record, today);
192
+ return {
193
+ id: typeof id === 'string' ? id : null,
194
+ path,
195
+ 'call-number': synthesizeCallNumber(path, typeof id === 'string' ? id : null),
196
+ heading: typeof record?.heading === 'string' ? record.heading : null,
197
+ file: entry.file,
198
+ stage: leafStage(record),
199
+ time: timeVerdict(record, today),
200
+ // A single boolean answering a single question — does this leaf sort below
201
+ // the promoted ones — with WHICH demotions fired kept separately, so a leaf
202
+ // that is both draft and stale reports both rather than having one absorb
203
+ // the other. Identical to the resolver's contract (UCS-1150).
204
+ demoted: demotions.length > 0,
205
+ demotions,
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Build one browse tree from the flat leaf index.
211
+ *
212
+ * Nodes are sorted by name and leaves by accession, both through `compare` —
213
+ * the same collator every other surface stable-sorts with, so a tree's byte
214
+ * order does not depend on the order the filesystem happened to hand the loader
215
+ * its leaves. That is what makes the golden files diffable at all, and the
216
+ * reordered-twin fixture pins it.
217
+ *
218
+ * DEMOTED LEAVES SORT LAST WITHIN THEIR NODE, and that is the only way demotion
219
+ * changes the tree's shape. They are not moved to another node, not collapsed,
220
+ * and not omitted — a steward browsing `design-system/components` sees every leaf
221
+ * filed there, with the rotted ones at the bottom wearing their reason. Sorting
222
+ * them out of the way is a reading aid; hiding them would be a lie about what
223
+ * the store contains.
224
+ *
225
+ * @param {Iterable<object>} entries loader-indexed leaf entries
226
+ * @param {{ path: (record: object) => string[] }} axis
227
+ * @param {string|null} today the injected date, or null
228
+ * @returns {object} the tree root
229
+ */
230
+ export function buildTree(entries, axis, today) {
231
+ const root = { name: null, children: new Map(), leaves: [] };
232
+ for (const entry of entries) {
233
+ const projected = projectLeaf(entry, axis, today);
234
+ let node = root;
235
+ for (const segment of projected.path) {
236
+ if (!node.children.has(segment)) {
237
+ node.children.set(segment, { name: segment, children: new Map(), leaves: [] });
238
+ }
239
+ node = node.children.get(segment);
240
+ }
241
+ node.leaves.push(projected);
242
+ }
243
+ return sortNode(root);
244
+ }
245
+
246
+ /**
247
+ * Sort one node's children and leaves, recursively, into their published order.
248
+ *
249
+ * @param {object} node
250
+ * @returns {object} the same node, sorted
251
+ */
252
+ function sortNode(node) {
253
+ node.leaves.sort((a, b) => {
254
+ // Demoted last, then by accession. Two demoted leaves keep accession order
255
+ // between them rather than sorting by WHICH demotion fired: the demotion is
256
+ // a fact about the leaf, not a rank, and ordering by it would invent a
257
+ // precedence between "draft" and "stale" that nothing in the store declares.
258
+ if (a.demoted !== b.demoted) return a.demoted ? 1 : -1;
259
+ return compare(a.id, b.id);
260
+ });
261
+ const sorted = new Map([...node.children.entries()].sort((a, b) => compare(a[0], b[0])));
262
+ for (const child of sorted.values()) sortNode(child);
263
+ node.children = sorted;
264
+ return node;
265
+ }
266
+
267
+ /**
268
+ * Count the leaves at or under one node.
269
+ *
270
+ * @param {object} node
271
+ * @returns {number}
272
+ */
273
+ export function countLeaves(node) {
274
+ let n = node.leaves.length;
275
+ for (const child of node.children.values()) n += countLeaves(child);
276
+ return n;
277
+ }
278
+
279
+ /**
280
+ * Render one tree as the markdown artifact written to disk.
281
+ *
282
+ * Markdown rather than JSON because the audience for a browse tree is a person
283
+ * or an agent READING it — the same reason a catalog is browsable. The
284
+ * machine-shaped projection of the same facts is the resolution index, which is
285
+ * JSON; the two are generated from one model so they cannot disagree.
286
+ *
287
+ * No timestamps, anywhere. The banner and the axis are the only preamble, and
288
+ * every line below is a function of the store's content — which is what lets
289
+ * D-012 baseline diffing attribute a changed tree to a changed leaf rather than
290
+ * to the clock.
291
+ *
292
+ * @param {object} tree the sorted tree root
293
+ * @param {{ key: string, label: string, audience: string }} axis
294
+ * @param {string|null} today the injected date, or null
295
+ * @returns {string} the artifact's full text
296
+ */
297
+ export function renderTree(tree, axis, today) {
298
+ const lines = [
299
+ `# Browse tree — ${axis.label}`,
300
+ '',
301
+ `> ${DERIVED_BANNER}`,
302
+ '',
303
+ `- axis: \`${axis.key}\` (${axis.label})`,
304
+ `- for: ${axis.audience}`,
305
+ `- leaves: ${countLeaves(tree)}`,
306
+ `- time verdicts: ${timeCheckStatus(today)}`,
307
+ '',
308
+ 'Call numbers are synthesized display strings for this projection only. They',
309
+ 'are NOT identities: cite the accession id (L-NNNNNN), never a call number.',
310
+ '',
311
+ ];
312
+ renderNode(tree, 0, lines);
313
+ return `${lines.join('\n').replace(/\n+$/, '')}\n`;
314
+ }
315
+
316
+ /**
317
+ * Render one node and its subtree into `lines`.
318
+ *
319
+ * @param {object} node
320
+ * @param {number} depth
321
+ * @param {string[]} lines
322
+ */
323
+ function renderNode(node, depth, lines) {
324
+ const indent = ' '.repeat(depth);
325
+ for (const [name, child] of node.children) {
326
+ lines.push(`${indent}- **${name}/** (${countLeaves(child)})`);
327
+ renderNode(child, depth + 1, lines);
328
+ }
329
+ for (const leaf of node.leaves) {
330
+ // The demotion annotation is part of the leaf's LINE, not a separate
331
+ // section: a reader scanning the shelf must not have to cross-reference a
332
+ // footnote to learn that what they are looking at has rotted.
333
+ const marks = leaf.demotions.map((d) => `${d.reason}`).join(', ');
334
+ const suffix = leaf.demoted ? ` — **demoted** (${marks})` : '';
335
+ lines.push(`${indent}- \`${leaf['call-number']}\` ${leaf.heading ?? '(no heading)'}${suffix}`);
336
+ for (const demotion of leaf.demotions) {
337
+ lines.push(`${indent} - ${demotion.reason}: ${demotion.detail}`);
338
+ }
339
+ }
340
+ }
341
+
342
+ /**
343
+ * The declared position of embedding recall — a SLOT, deliberately empty.
344
+ *
345
+ * This ticket implements no recall and selects no model: that is out of scope by
346
+ * spec, and writing similarity code here would be the scope error the spec warns
347
+ * about. What ships is the ARCHITECTURE — where recall may live, when it may be
348
+ * consulted, and what it is forbidden to do — so that an implementation arriving
349
+ * later has exactly one legal shape and cannot quietly acquire a worse one.
350
+ *
351
+ * The three constraints are each load-bearing:
352
+ *
353
+ * `location: derived` — recall lives in the disposable layer and nowhere else.
354
+ * Deleting `derived/` must delete every probabilistic artifact the system has
355
+ * ever computed. An embedding cached in frontmatter would be a guess sitting
356
+ * in the same file as cited facts, indistinguishable to every later reader.
357
+ *
358
+ * `consulted: after-deterministic-exhausted` — recall runs only when the
359
+ * deterministic layer returns nothing. Consulting it earlier would let a
360
+ * similarity score outrank a declared edge, and the store's whole claim is
361
+ * that what resolves, resolves for a reason someone can point at.
362
+ *
363
+ * `output: proposals` with `citable: false` and `persistable: false` — recall
364
+ * may PROPOSE and may never persist or be cited. A proposal becomes knowledge
365
+ * only by passing a human gate, at which point it is an ordinary authored leaf
366
+ * with citations, and the probabilistic step is history rather than provenance.
367
+ *
368
+ * @type {Readonly<object>}
369
+ */
370
+ export const RECALL_SLOT = Object.freeze({
371
+ status: 'declared-unimplemented',
372
+ location: DERIVED_DIR,
373
+ consulted: 'after-deterministic-exhausted',
374
+ output: 'proposals',
375
+ citable: false,
376
+ persistable: false,
377
+ gate: 'human-review — a proposal enters the store only as an authored leaf with citations (UCS-1158)',
378
+ 'in-scope-here': 'the slot and the gate',
379
+ 'out-of-scope-here': 'model selection and semantic-recall implementation (issue #49)',
380
+ });
381
+
382
+ /**
383
+ * The gate every probabilistic proposal must pass, and never does automatically.
384
+ *
385
+ * Total by construction: it returns a refusal for EVERY proposal, because no
386
+ * proposal can be admitted without a human, and there is no argument this
387
+ * function could take that would represent one. An implementation of recall
388
+ * calls this and gets a refusal; the only way into the store is the ordinary
389
+ * authoring path, where a human writes a leaf with citations and a moderator
390
+ * promotes it.
391
+ *
392
+ * A function that always refuses may read as a placeholder. It is not: it is the
393
+ * seam that makes "nothing probabilistic persists without a gate" checkable by a
394
+ * test rather than promised by a comment, and it is what a future recall
395
+ * implementation must route through to be reviewable at all.
396
+ *
397
+ * @param {object} proposal a candidate produced by probabilistic recall
398
+ * @returns {{ admitted: false, reason: string, proposal: object }}
399
+ */
400
+ export function gateProposal(proposal) {
401
+ return {
402
+ admitted: false,
403
+ reason: 'probabilistic proposals are never admitted automatically — they may be read by a human, '
404
+ + 'who may author an ordinary cited leaf; nothing reaches the store without that gate (UCS-1158)',
405
+ proposal,
406
+ };
407
+ }
408
+
409
+ /**
410
+ * Build the resolution index — the machine-shaped projection of the same facts.
411
+ *
412
+ * One entry per leaf, carrying its identity, its position on every axis, the
413
+ * call number each axis synthesizes, and its demotion state. The trees are for
414
+ * reading; this is for joining, and both are generated from one pass over one
415
+ * model so a leaf cannot be filed one way in a tree and another way here.
416
+ *
417
+ * The index is keyed by ACCESSION and by nothing else. Keying it by call number
418
+ * would create a second resolvable name for a leaf — exactly the fusion the
419
+ * accession inversion removed — and a lookup table is the most tempting place
420
+ * in the system to reintroduce it.
421
+ *
422
+ * @param {Iterable<object>} entries loader-indexed leaf entries
423
+ * @param {string|null} today the injected date, or null
424
+ * @returns {object} the index payload
425
+ */
426
+ export function buildIndex(entries, today) {
427
+ const leaves = [];
428
+ for (const entry of entries) {
429
+ const record = entry.record;
430
+ const id = recordId(entry);
431
+ const demotions = demotionsFor(record, today);
432
+ const positions = {};
433
+ for (const axis of AXES) {
434
+ const path = axis.path(record);
435
+ positions[axis.key] = {
436
+ path,
437
+ 'call-number': synthesizeCallNumber(path, typeof id === 'string' ? id : null),
438
+ };
439
+ }
440
+ leaves.push({
441
+ id: typeof id === 'string' ? id : null,
442
+ heading: typeof record?.heading === 'string' ? record.heading : null,
443
+ file: entry.file,
444
+ stage: leafStage(record),
445
+ time: timeVerdict(record, today),
446
+ demoted: demotions.length > 0,
447
+ demotions,
448
+ positions,
449
+ });
450
+ }
451
+ leaves.sort((a, b) => compare(a.id, b.id));
452
+ return {
453
+ note: DERIVED_BANNER,
454
+ // Spelled out rather than left implicit, because the index is the artifact
455
+ // most likely to be read by something that wants to resolve through it.
456
+ 'call-numbers': 'synthesized display strings, per axis — NOT identities and never citable; cite the accession id',
457
+ 'time-check': timeCheckStatus(today),
458
+ axes: AXES.map((a) => ({ key: a.key, label: a.label, audience: a.audience })),
459
+ 'recall-slot': RECALL_SLOT,
460
+ counts: {
461
+ leaves: leaves.length,
462
+ demoted: leaves.filter((l) => l.demoted).length,
463
+ },
464
+ leaves,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Every artifact the derived layer consists of, as `{path, text}` pairs.
470
+ *
471
+ * ONE function computes the whole layer, and both verbs run it: `--check`
472
+ * compares these bytes against what is on disk, `--write` writes them. That is
473
+ * what makes the check meaningful — a check that recomputed the artifacts a
474
+ * second way could pass while the writer produced something else.
475
+ *
476
+ * Paths are relative to the store root and always forward-slashed, so the
477
+ * manifest is identical on every platform.
478
+ *
479
+ * @param {Iterable<object>} entries loader-indexed leaf entries
480
+ * @param {string|null} today the injected date, or null
481
+ * @returns {Array<{path: string, text: string}>} sorted by path
482
+ */
483
+ export function deriveArtifacts(entries, today) {
484
+ const materialized = [...entries];
485
+ const artifacts = AXES.map((axis) => ({
486
+ path: `knowledge/${DERIVED_DIR}/tree.${axis.key}.md`,
487
+ text: renderTree(buildTree(materialized, axis, today), axis, today),
488
+ }));
489
+ artifacts.push({
490
+ path: `knowledge/${DERIVED_DIR}/index.json`,
491
+ text: `${JSON.stringify(buildIndex(materialized, today), null, 2)}\n`,
492
+ });
493
+ return artifacts.sort((a, b) => compare(a.path, b.path));
494
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The difference between a refusal and a bug (UCS-949).
3
+ *
4
+ * A surface may catch an engine throw and print a clean, actionable message:
5
+ * "no git", "the store loader reported 3 error(s)", "illegal transition". These
6
+ * are ANTICIPATED conditions. The engine reached them on purpose, so a stack
7
+ * trace would be noise.
8
+ *
9
+ * A `TypeError` from a genuine bug is not that. It must reach `runCli`, which
10
+ * prints the stack — otherwise a crash hides behind a one-line message that
11
+ * reads exactly like a considered refusal, and nobody can debug it.
12
+ *
13
+ * Both still exit 2. This is about diagnosability, not the exit-code contract.
14
+ *
15
+ * The rule: an anticipated refusal is a plain `new Error(msg)` — which is what
16
+ * every refusal in the engine already throws — or an `EngineRefusal` subclass
17
+ * for the ones that carry more than a message. Everything else is a bug, or a
18
+ * `UsageError` that the harness alone gets to interpret.
19
+ */
20
+
21
+ /** An anticipated, actionable engine refusal. Reported without a stack trace. */
22
+ export class EngineRefusal extends Error {
23
+ name = 'EngineRefusal';
24
+ }
25
+
26
+ /**
27
+ * Rethrow `error` unless it is an anticipated engine refusal.
28
+ *
29
+ * Call this first inside a surface's engine-failure catch, so the catch speaks
30
+ * only for the failures it can actually explain and everything else — a bug, a
31
+ * UsageError raised deep in the loader — travels on to the harness.
32
+ *
33
+ * @param {unknown} error
34
+ * @returns {void} when the error is a refusal the caller may report itself
35
+ * @throws {unknown} the original error, when it is not
36
+ */
37
+ export function rethrowIfBug(error) {
38
+ const expected = error instanceof EngineRefusal || error?.constructor === Error;
39
+ if (!expected) throw error;
40
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Engine exit-code contract (PRD §5, D-011) — uniform across every engine
3
+ * module: validate, validate-values, audit, resolve, survey-map, preflight.
4
+ *
5
+ * 0 — clean
6
+ * 1 — findings / quarantines
7
+ * 2 — engine or environment failure. A check that never ran is a
8
+ * blocking defect, never a silent pass.
9
+ */
10
+ export const EXIT_CODES = Object.freeze({
11
+ CLEAN: 0,
12
+ FINDINGS: 1,
13
+ FAILURE: 2,
14
+ });