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,289 @@
1
+ /**
2
+ * Reverse audit (KK-12) — the code→store scan (PRD §4). ADVISORY by design:
3
+ * it grows the map proposal-first — every unmatched anchor arrives as a
4
+ * drafted concept YAML ready for human review — and is never a CI gate.
5
+ *
6
+ * node payload/engine/audit.js [--root <dir>] [--json] [--fail-on-findings]
7
+ * [--today <YYYY-MM-DD>] [--stale-days <n>]
8
+ *
9
+ * The scan surface IS the survey map (KK-25): git-tracked files, built-in
10
+ * denylist, anchor candidates from the shared signature table — and the
11
+ * survey-scope.yaml honor-it contract, so excluded areas are never rescanned
12
+ * (widening happens via retrieval-miss findings, never re-litigation). A
13
+ * malformed scope file is an engine failure (exit 2), never a silently
14
+ * ignored boundary.
15
+ *
16
+ * Findings (all advisory):
17
+ * unmatched-anchor an anchor candidate no concept points at — exports,
18
+ * registries, config keys, module directories. Carries
19
+ * `draft`: a proposed §3.1 concept record. The draft id
20
+ * is a deliberate NON-minted placeholder (K-XXX):
21
+ * pasting a draft unedited must fail validation, never
22
+ * silently mint an id outside the owning class's range
23
+ * (§3.5). Matching is pointer-prefix: a concept whose
24
+ * source-of-truth names a folder covers its subtree
25
+ * ("point at a folder for identity, at a file for
26
+ * facts").
27
+ * stale-last-verified an active concept whose last-verified predates
28
+ * --today by more than --stale-days (default 90).
29
+ * Checked ONLY when --today is given: diffable output
30
+ * never reads the wall clock (D-012); without --today
31
+ * the check reports itself skipped, never silently.
32
+ *
33
+ * The kit's own zone is never audited — the map must not be told to map
34
+ * itself. The store dir is discovered: `<root>/ontology` (stores at the scan
35
+ * root, the engine-fixture layout) or `<root>/unknown-knowledge/` (the D-016
36
+ * post-init default). A repo with no stores at all still audits — every
37
+ * anchor is a proposal.
38
+ *
39
+ * Suppressions (KK-27, D-013 §11.1 minimal core): a client-zone
40
+ * `suppressions.yaml` in the kit zone — `<kitRoot>/suppressions.yaml`, i.e.
41
+ * next to the stores (unknown-knowledge/suppressions.yaml in the D-016
42
+ * layout; at the scan root, beside survey-scope.yaml, when the stores live
43
+ * there). A YAML list of entries, each STRICTLY
44
+ * { term, sourcePath, reason, date } — exact match only, no patterns, no
45
+ * expiry (deferred §11.1). What the fields match, per finding code:
46
+ * unmatched-anchor the anchor path is the identity: `sourcePath` must
47
+ * equal the finding's path exactly, and `term` must
48
+ * equal the term the draft would carry (the path's
49
+ * basename without extension) — so a suppression stops
50
+ * matching the moment the file moves.
51
+ * stale-last-verified the concept is the identity and has no file path:
52
+ * BOTH `term` and `sourcePath` must equal the concept
53
+ * id (e.g. K-101) — one strict shape, no second
54
+ * entry grammar.
55
+ * Suppressed findings leave the finding list (and never trip
56
+ * --fail-on-findings); the human report shows the suppressed count, the JSON
57
+ * report the full stable-sorted suppressed list. Suppression is
58
+ * advisory-side and FAILS OPEN — the opposite of survey-scope.yaml: a
59
+ * malformed entry (or an unreadable/unparseable file) produces a warning and
60
+ * suppresses nothing, so the findings it would have hidden resurface; it is
61
+ * never an engine failure. A missing file is a plain no-op. Engine-internal
62
+ * noise heuristics (denylist, the "." root filter) stay engine-side — the
63
+ * client file is for client judgments only.
64
+ *
65
+ * Single health model (KK-04): a store with error-severity diagnostics is an
66
+ * engine failure (exit 2) — matching candidates against broken stores would
67
+ * misreport, and a check that never ran is a blocking defect, never a silent
68
+ * pass (PRD §5).
69
+ *
70
+ * Exit codes (PRD §5): 0 ran (findings are advisory), 2 engine failure.
71
+ * `--fail-on-findings` opts a HUMAN run into exit 1 on findings — never a
72
+ * shipped CI default (governance test lands with KK-27). Output is
73
+ * deterministic and stable-sorted; no wall-clock timestamps (drafted dates
74
+ * come from --today or are omitted).
75
+ */
76
+ import process from 'node:process';
77
+ import { readFileSync } from 'node:fs';
78
+ import { basename, extname, join, resolve } from 'node:path';
79
+ import { dump, load } from 'js-yaml';
80
+ import { buildSurveyMap } from './survey-map.js';
81
+ import { locateKit, SCOPE_FILE } from '../lib/kit-root.js';
82
+ import { loadSuppressions, partitionBySuppression, suppressibleBy, SUPPRESSIONS_FILE } from '../lib/suppressions.js';
83
+ import { daysBetween, isCalendarDate, ISO_DATE } from '../lib/iso-date.js';
84
+ import { loadStores, storeHealth } from '../lib/load-stores.js';
85
+ import { compare } from '../lib/validate-record.js';
86
+ import { EXIT_CODES } from '../lib/exit-codes.js';
87
+ import { parseArgs as parseFlags, rethrowIfBug, UsageError } from '../lib/cli.js';
88
+
89
+ export const USAGE = 'usage: node payload/engine/audit.js [--root <dir>] [--json] [--fail-on-findings] [--today <YYYY-MM-DD>] [--stale-days <n>]';
90
+ const STALE_DAYS_DEFAULT = 90;
91
+ // Kit layout — where the stores live and what counts as kit zone — is
92
+ // lib/kit-root.js's one job (UCS-934). The audit used to answer that itself,
93
+ // with the opposite tie-break, and so read a different Store than every other
94
+ // surface whenever a repo carried both layouts.
95
+ export { SUPPRESSIONS_FILE };
96
+
97
+
98
+ const underPrefix = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
99
+
100
+ /** Pointer-prefix matching: a folder pointer covers its subtree (§3.1). */
101
+ function matchesPointer(path, pointerPaths) {
102
+ return pointerPaths.some((p) => underPrefix(path, p));
103
+ }
104
+
105
+ /** Drafted §3.1 concept record for one unmatched anchor (proposal-first). */
106
+ function draftConcept(path, kinds, today) {
107
+ const record = {
108
+ // NOT a mintable K-NNN: the human mints the id inside the owning class's
109
+ // range at PR time (§3.5) — a paste-unedited draft must fail validation.
110
+ id: 'K-XXX',
111
+ term: basename(path, extname(path)),
112
+ class: 'TODO — owning class file; mint the id in its declared range (§3.5)',
113
+ summary: `TODO — drafted by reverse audit: ${kinds.join(', ')} anchor with no concept pointing at it`,
114
+ 'source-of-truth': [path],
115
+ status: 'draft',
116
+ ...(today ? { 'last-verified': today } : {}),
117
+ };
118
+ return dump(record, { lineWidth: -1 });
119
+ }
120
+
121
+
122
+ /**
123
+ * Run the reverse audit: survey the repo at `root` (scope-honoring), load the
124
+ * stores, and report unmatched anchors + stale verifications. Throws on
125
+ * engine failure (unhealthy store, malformed scope, no git).
126
+ */
127
+ export function runAudit(root, { today = null, staleDays = STALE_DAYS_DEFAULT } = {}) {
128
+ const map = buildSurveyMap(root);
129
+ const { kitRoot, kitPrefixes } = locateKit(root);
130
+ const model = loadStores(kitRoot);
131
+
132
+ const { errors } = storeHealth(model);
133
+ if (errors.length > 0) {
134
+ const detail = errors.map((d) => ` ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''} ${d.message}`);
135
+ throw new Error(`the store loader reported ${errors.length} error(s) — auditing against broken stores would misreport (single health model, PRD §4):\n${detail.join('\n')}`);
136
+ }
137
+
138
+ const pointerPaths = [...model.pointers.keys()];
139
+ // One finding per path: a file matching several signatures is one anchor
140
+ // to propose, never a cascade of near-identical drafts.
141
+ const byPath = new Map();
142
+ let matched = 0;
143
+ for (const { kind, path } of map.candidates) {
144
+ if (path === '.') continue; // engine-side noise heuristic (D-013): a draft proposing "the repo root is a concept" is never actionable
145
+ if (kitPrefixes.some((p) => underPrefix(path, p))) continue; // the map never maps itself
146
+ if (matchesPointer(path, pointerPaths)) {
147
+ matched += 1;
148
+ continue;
149
+ }
150
+ if (!byPath.has(path)) byPath.set(path, []);
151
+ if (!byPath.get(path).includes(kind)) byPath.get(path).push(kind);
152
+ }
153
+
154
+ const findings = [];
155
+ for (const [path, kinds] of byPath) {
156
+ kinds.sort(compare);
157
+ findings.push(suppressibleBy({
158
+ code: 'unmatched-anchor', severity: 'advisory', path, kinds,
159
+ message: `no concept points at this ${kinds.join('/')} anchor — draft below is ready for human review`,
160
+ draft: draftConcept(path, kinds, today),
161
+ // A steward suppresses an anchor by the term the draft would have used
162
+ // and the path it sits at — the two things they actually read.
163
+ }, { term: basename(path, extname(path)), sourcePath: path }));
164
+ }
165
+
166
+ if (today) {
167
+ for (const { id, record } of model.concepts.values()) {
168
+ if (record.status !== 'active') continue; // draft/proposed are unverified by definition; deprecated needs no re-verification
169
+ const verified = record['last-verified'];
170
+ if (typeof verified !== 'string' || !ISO_DATE.test(verified)) continue; // presence/shape is KK-05's check
171
+ const days = daysBetween(verified, today);
172
+ if (days > staleDays) {
173
+ findings.push(suppressibleBy({
174
+ code: 'stale-last-verified', severity: 'advisory', concept: id,
175
+ 'last-verified': verified, days, 'stale-days': staleDays,
176
+ message: `last verified ${days} day(s) ago — re-verify or deprecate (§3.1 rung 4)`,
177
+ // There is no anchor path here; the concept id names the finding in
178
+ // both fields, so one entry suppresses one concept's staleness.
179
+ }, { term: id, sourcePath: id }));
180
+ }
181
+ }
182
+ }
183
+
184
+ findings.sort((a, b) =>
185
+ compare(a.code, b.code) || compare(a.path ?? '', b.path ?? '') || compare(a.concept ?? '', b.concept ?? ''));
186
+
187
+ // Client-zone suppressions (KK-27): exact-match filter, fails open. Only
188
+ // well-formed entries suppress; the sort above keeps both lists stable. Each
189
+ // finding carries the identity it would be suppressed by, so nothing here
190
+ // has to know what codes exist.
191
+ const { entries: suppressionEntries, warnings } = loadSuppressions(kitRoot);
192
+ const { kept, suppressed } = partitionBySuppression(findings, suppressionEntries);
193
+
194
+ return {
195
+ checks: {
196
+ 'unmatched-anchor': 'checked',
197
+ 'stale-last-verified': today
198
+ ? `checked against --today ${today} (stale after ${staleDays} day(s))`
199
+ : 'skipped — pass --today YYYY-MM-DD to enable; diffable output never reads the wall clock (D-012)',
200
+ },
201
+ scope: map.scope.source === SCOPE_FILE ? SCOPE_FILE : 'unscoped (no confirmed survey-scope.yaml)',
202
+ counts: {
203
+ candidates: map.candidates.length,
204
+ matched,
205
+ findings: kept.length,
206
+ suppressed: suppressed.length,
207
+ },
208
+ findings: kept,
209
+ // Full suppressed list in JSON output (stable-sorted, same order as
210
+ // findings); the human renderer shows only the count. Warnings surface
211
+ // in both — a malformed entry must never vanish silently.
212
+ suppressions: { warnings, suppressed },
213
+ };
214
+ }
215
+
216
+ // ------------------------------------------------------------- CLI plumbing
217
+
218
+ function parseArgs(argv) {
219
+ const { options } = parseFlags(argv, {
220
+ boolean: ['json', 'fail-on-findings'],
221
+ value: ['root', 'today', 'stale-days'],
222
+ });
223
+ const opts = {
224
+ root: resolve(options.root ?? process.cwd()),
225
+ json: !!options.json,
226
+ // Advisory by design (D-013): findings never gate. Only a HUMAN opting in
227
+ // with --fail-on-findings turns them into a non-zero exit, and it is never
228
+ // a shipped CI default.
229
+ failOnFindings: !!options['fail-on-findings'],
230
+ today: options.today ?? null,
231
+ staleDays: STALE_DAYS_DEFAULT,
232
+ };
233
+ if (opts.today !== null && !isCalendarDate(opts.today)) {
234
+ // Not just the shape: Date.parse rolls 2026-02-30 forward to March 2nd, and
235
+ // staleness would then be measured from a day the caller never named.
236
+ throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(opts.today)}`);
237
+ }
238
+ if (options['stale-days'] !== undefined) {
239
+ opts.staleDays = Number(options['stale-days']);
240
+ if (!Number.isInteger(opts.staleDays) || opts.staleDays < 0) {
241
+ throw new UsageError(`--stale-days must be a non-negative integer, got ${JSON.stringify(options['stale-days'])}`);
242
+ }
243
+ }
244
+ return opts;
245
+ }
246
+
247
+ function renderHuman(payload) {
248
+ const lines = [];
249
+ const n = payload.counts.findings;
250
+ const s = payload.counts.suppressed;
251
+ lines.push(
252
+ `audit (advisory — proposals for human review, never a gate): `
253
+ + `${payload.counts.candidates} candidate(s), ${payload.counts.matched} matched, `
254
+ + `${n} finding${n === 1 ? '' : 's'}`
255
+ + (s > 0 ? `, ${s} suppressed (${SUPPRESSIONS_FILE})` : ''),
256
+ `scope: ${payload.scope}`,
257
+ `stale check: ${payload.checks['stale-last-verified']}`,
258
+ );
259
+ for (const w of payload.suppressions.warnings) lines.push(`warning: ${w}`);
260
+ for (const f of payload.findings) {
261
+ lines.push('', `${f.code} ${f.path ?? f.concept}`, ` ${f.message}`);
262
+ if (f.draft) lines.push(...f.draft.trimEnd().split('\n').map((l) => ` | ${l}`));
263
+ }
264
+ if (n === 0) lines.push('', 'nothing to propose — every anchor is mapped');
265
+ return lines;
266
+ }
267
+
268
+ export function main(argv) {
269
+ const opts = parseArgs(argv); // a UsageError reaches the harness
270
+
271
+ let payload;
272
+ try {
273
+ payload = runAudit(opts.root, { today: opts.today, staleDays: opts.staleDays });
274
+ } catch (error) {
275
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
276
+ // An EXPECTED engine failure (malformed scope, unhealthy store, no git):
277
+ // the audit never ran to completion — exit 2, never a plausible report.
278
+ // These are anticipated conditions with actionable messages, so they say
279
+ // what happened rather than dumping a stack; an UNEXPECTED throw anywhere
280
+ // else reaches the harness, which does dump one.
281
+ process.stderr.write(`audit: ${error.message}\n`);
282
+ return EXIT_CODES.FAILURE;
283
+ }
284
+
285
+ const lines = opts.json ? [JSON.stringify(payload, null, 2)] : renderHuman(payload);
286
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
287
+ // Advisory (D-013): findings alone never gate. Only the human opt-in does.
288
+ return opts.failOnFindings && payload.counts.findings > 0 ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
289
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The derived layer (UCS-1158) — generate the browse trees and resolution index.
3
+ *
4
+ * The second engine command that writes, and it follows the first one's shape
5
+ * (phoenix, UCS-1154): the read-only verb is the DEFAULT, and writing takes a
6
+ * word typed on purpose.
7
+ *
8
+ * --check (default) compute the layer and compare it to what is on disk.
9
+ * --write compute the layer and write it, replacing the directory whole.
10
+ *
11
+ * What makes this command different from phoenix is what it is allowed to
12
+ * destroy. Phoenix rewrites authored leaves, so its gate is total and a single
13
+ * finding refuses the event. This command writes only into `knowledge/derived/`,
14
+ * a directory whose entire contract is that deleting it loses nothing — so the
15
+ * dangerous direction is reversed. The risk is not that a write destroys
16
+ * something; it is that something starts DEPENDING on the output, at which
17
+ * point the layer is no longer disposable and the store has quietly acquired a
18
+ * second source of truth. Three things hold that line:
19
+ *
20
+ * - the loader skips `derived/` by name, so no generated artifact can enter
21
+ * the model or affect store health (lib/load-stores.js, DERIVED_DIR);
22
+ * - `--check` exits FINDINGS when the on-disk layer differs from the computed
23
+ * one, so a stale or hand-edited artifact is reported rather than believed;
24
+ * - the round-trip test deletes the directory, regenerates, and asserts the
25
+ * bytes are identical — which fails the moment anything unreproducible
26
+ * starts living here.
27
+ *
28
+ * `--check` returning FINDINGS is the right code rather than FAILURE: the check
29
+ * RAN, and what it found is a real defect its author fixes by re-running with
30
+ * --write. Exit 2 stays reserved for a run that never happened.
31
+ *
32
+ * TIME VERDICTS NEED --today, ALWAYS INJECTED. The trees annotate stale leaves,
33
+ * and staleness is measured from an injected date — never the wall clock (D-012,
34
+ * PRD §5). Without `--today` the time verdicts are `skipped` and every artifact
35
+ * SAYS so in its header; it is never a silent pass, and the artifacts stay
36
+ * byte-stable across runs, which is the property baseline diffing rests on.
37
+ * That also means `--today` changes the output: two runs on different injected
38
+ * dates legitimately differ, and the check compares against whatever date this
39
+ * invocation named.
40
+ */
41
+ import {
42
+ mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync,
43
+ } from 'node:fs';
44
+ import { dirname, join, resolve } from 'node:path';
45
+ import { locateKitRoot } from '../lib/kit-root.js';
46
+ import { loadStores, storeHealth, healthSummary, DERIVED_DIR } from '../lib/load-stores.js';
47
+ import {
48
+ AXES, DERIVED_BANNER, RECALL_SLOT, demotionsFor, deriveArtifacts,
49
+ } from '../lib/derived.js';
50
+ import { compare } from '../lib/validate-record.js';
51
+ import { timeCheckStatus } from '../lib/time-verdicts.js';
52
+ import { isCalendarDate } from '../lib/iso-date.js';
53
+ import { EXIT_CODES } from '../lib/exit-codes.js';
54
+ import { parseArgs as parseFlags, rethrowIfBug, UsageError } from '../lib/cli.js';
55
+
56
+ export const USAGE = 'usage: node payload/engine/derive.js [--write] [--check] [--root <dir>] [--today <YYYY-MM-DD>] [--json]';
57
+
58
+ function parseArgs(argv) {
59
+ const { options } = parseFlags(argv, {
60
+ boolean: ['json', 'write', 'check'],
61
+ value: ['root', 'today'],
62
+ });
63
+ // Naming both verbs states two intentions whose difference is whether the
64
+ // directory gets rewritten. Refusing is the only reading that cannot silently
65
+ // pick the writing one.
66
+ if (options.write && options.check) {
67
+ throw new UsageError('--write and --check are the two verbs; name one');
68
+ }
69
+ const today = options.today ?? null;
70
+ if (today !== null && !isCalendarDate(today)) {
71
+ // The shape AND the calendar: 2026-02-30 matches the pattern and names no
72
+ // day, and an age measured from it is a number nobody's calendar agrees
73
+ // with (UCS-957). Same refusal the resolver makes.
74
+ throw new UsageError(`--today must be a real calendar date (YYYY-MM-DD), got ${JSON.stringify(today)}`);
75
+ }
76
+ return {
77
+ root: resolve(options.root ?? '.'),
78
+ json: !!options.json,
79
+ write: !!options.write,
80
+ today,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * ENOENT is the ONLY read failure that means "this is not there".
86
+ *
87
+ * Every other errno means the engine could not LOOK: EACCES (no permission),
88
+ * EIO (the disk failed), EMFILE (out of descriptors), ENOTDIR (something on the
89
+ * path is a file). Reading any of those as an empty layer would let `--check`
90
+ * report the layer cleanly regenerable when nothing was actually examined —
91
+ * the silent pass the exit-code contract exists to prevent (PRD §5). A check
92
+ * that never ran is a blocking defect, so those rethrow and surface as exit 2.
93
+ *
94
+ * ENOTDIR is deliberately on the ERROR side of that line. If `knowledge/derived`
95
+ * exists as a FILE, the layer is not absent — it is corrupted, and `--write`
96
+ * would have to delete a file the engine never created. Reporting that as
97
+ * `derived-missing` would tell an author to regenerate, when what they need to
98
+ * know is that something is squatting on the directory's name.
99
+ *
100
+ * @param {unknown} error a caught filesystem error
101
+ * @returns {boolean} whether it means the path simply does not exist
102
+ */
103
+ const isAbsent = (error) => error?.code === 'ENOENT';
104
+
105
+ /**
106
+ * Every file currently under the derived directory, relative to the store root.
107
+ *
108
+ * A missing directory reads as no files, not as an error: the layer being absent
109
+ * is the ordinary state of a fresh clone (it is engine output), and it is the
110
+ * exact state the round-trip test creates on purpose. Every OTHER failure
111
+ * rethrows — see `isAbsent`.
112
+ *
113
+ * @param {string} root the store root
114
+ * @returns {string[]} sorted relative paths
115
+ * @throws when the directory exists but could not be read
116
+ */
117
+ function existingArtifacts(root) {
118
+ const dir = join(root, 'knowledge', DERIVED_DIR);
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(dir, { withFileTypes: true, recursive: true });
122
+ } catch (error) {
123
+ if (isAbsent(error)) return [];
124
+ throw error;
125
+ }
126
+ return entries
127
+ .filter((e) => e.isFile())
128
+ .map((e) => {
129
+ // `parentPath` is absolute; make it relative to the store root and
130
+ // forward-slashed, so the comparison is platform-identical.
131
+ const rel = join(e.parentPath ?? dir, e.name).slice(root.length + 1);
132
+ return rel.split(/[\\/]/).join('/');
133
+ })
134
+ .sort(compare);
135
+ }
136
+
137
+ /**
138
+ * Compare the computed layer against what is on disk.
139
+ *
140
+ * Three defect classes, each its own finding code, because they call for
141
+ * different edits: a MISSING artifact means the layer was never generated (or
142
+ * was partly deleted), a STALE one means the store changed since it was, and an
143
+ * UNEXPECTED one means a file is living in the derived directory that no axis
144
+ * generates — which is the drift that would make the layer load-bearing.
145
+ *
146
+ * @param {string} root the store root
147
+ * @param {Array<{path: string, text: string}>} artifacts the computed layer
148
+ * @returns {Array<object>} findings, sorted
149
+ */
150
+ function checkArtifacts(root, artifacts) {
151
+ const findings = [];
152
+ const expected = new Set(artifacts.map((a) => a.path));
153
+ for (const artifact of artifacts) {
154
+ let actual;
155
+ try {
156
+ actual = readFileSync(join(root, artifact.path), 'utf8');
157
+ } catch (error) {
158
+ // Same rule as `existingArtifacts`: only ENOENT is a missing artifact.
159
+ // An unreadable file is not an absent one, and calling it `derived-missing`
160
+ // would send an author to regenerate a layer whose real problem is that
161
+ // the engine could not read it.
162
+ if (!isAbsent(error)) throw error;
163
+ findings.push({
164
+ severity: 'error',
165
+ code: 'derived-missing',
166
+ file: artifact.path,
167
+ message: 'this derived artifact is absent — regenerate the layer with --write (nothing is lost by doing so)',
168
+ });
169
+ continue;
170
+ }
171
+ if (actual !== artifact.text) {
172
+ findings.push({
173
+ severity: 'error',
174
+ code: 'derived-stale',
175
+ file: artifact.path,
176
+ message: 'this derived artifact differs from what the store projects — the leaves changed, '
177
+ + 'or the file was hand-edited; regenerate with --write, and never edit a derived file',
178
+ });
179
+ }
180
+ }
181
+ for (const found of existingArtifacts(root)) {
182
+ if (expected.has(found)) continue;
183
+ findings.push({
184
+ severity: 'error',
185
+ code: 'derived-unexpected',
186
+ file: found,
187
+ message: 'no axis generates this file, so regenerating the layer would delete it — '
188
+ + 'nothing may live in the derived directory that the engine does not produce',
189
+ });
190
+ }
191
+ return findings.sort((a, b) => compare(a.file, b.file) || compare(a.code, b.code));
192
+ }
193
+
194
+ /**
195
+ * Replace the derived directory with the computed artifacts.
196
+ *
197
+ * Removes the whole directory first, deliberately. A merge-write would leave
198
+ * behind artifacts from a previous axis table — a tree for an axis that no
199
+ * longer exists, still looking authoritative — and the layer's promise is that
200
+ * what is there is what the store currently projects. Removing first is safe
201
+ * precisely because this directory holds nothing that is not regenerable, which
202
+ * is the same property the round-trip test asserts.
203
+ *
204
+ * @param {string} root the store root
205
+ * @param {Array<{path: string, text: string}>} artifacts
206
+ */
207
+ function writeArtifacts(root, artifacts) {
208
+ rmSync(join(root, 'knowledge', DERIVED_DIR), { recursive: true, force: true });
209
+ for (const artifact of artifacts) {
210
+ const file = join(root, artifact.path);
211
+ mkdirSync(dirname(file), { recursive: true });
212
+ writeFileSync(file, artifact.text);
213
+ }
214
+ }
215
+
216
+ function render(payload) {
217
+ const { verb, findings, artifacts, counts } = payload;
218
+ const lines = [];
219
+ if (findings.length) {
220
+ lines.push(
221
+ `derive -> ${findings.length} finding(s) — the derived layer is not what the store projects`,
222
+ );
223
+ for (const f of findings) lines.push(`${f.severity} ${f.code} ${f.file}`, ` ${f.message}`);
224
+ lines.push('regenerate with --write; a derived artifact is never hand-edited');
225
+ } else {
226
+ lines.push(verb === 'write'
227
+ ? `derive -> WROTE ${artifacts.length} artifact(s) — ${counts.leaves} leaf/leaves across ${payload.axes.length} axes`
228
+ : `derive -> up to date — ${artifacts.length} artifact(s) match what the store projects`);
229
+ }
230
+ for (const a of artifacts) lines.push(` ${a}`);
231
+ lines.push(`time verdicts: ${payload['time-check']}`);
232
+ lines.push(`demoted leaves: ${counts.demoted} (annotated in every tree, never hidden)`);
233
+ lines.push(`embedding recall: ${RECALL_SLOT.status} — ${RECALL_SLOT['out-of-scope-here']}`);
234
+ lines.push(DERIVED_BANNER);
235
+ return lines;
236
+ }
237
+
238
+ /**
239
+ * CLI entry. Exit codes per the engine contract (PRD §5): 0 the layer is
240
+ * up to date (or was written), 1 the on-disk layer differs from what the store
241
+ * projects, 2 the run never happened.
242
+ *
243
+ * @param {string[]} argv
244
+ * @returns {number} an exit code
245
+ */
246
+ export function main(argv) {
247
+ const opts = parseArgs(argv); // a UsageError reaches the harness
248
+
249
+ let model;
250
+ try {
251
+ model = loadStores(locateKitRoot(opts.root));
252
+ } catch (error) {
253
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
254
+ process.stderr.write(`derive: ${error.message}\n`);
255
+ return EXIT_CODES.FAILURE;
256
+ }
257
+
258
+ // A store the loader rejects cannot be projected: the trees would file leaves
259
+ // by facets no check approved, and under --write we would publish them. The
260
+ // derived layer's claim is that it says what the store says, and a store that
261
+ // does not load has not said anything yet. Exit 2 — the generation never ran.
262
+ if (!model.ok) {
263
+ const health = storeHealth(model);
264
+ process.stderr.write(
265
+ `derive: the store has ${health.errors.length} loader error(s); nothing is projected from a store that does not load\n`,
266
+ );
267
+ for (const d of health.errors) {
268
+ process.stderr.write(` ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''} ${d.message}\n`);
269
+ }
270
+ return EXIT_CODES.FAILURE;
271
+ }
272
+
273
+ const artifacts = deriveArtifacts(model.leaves.values(), opts.today);
274
+
275
+ let findings = [];
276
+ if (opts.write) {
277
+ try {
278
+ writeArtifacts(model.root, artifacts);
279
+ } catch (error) {
280
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
281
+ // A filesystem failure part-way through leaves the directory incomplete.
282
+ // Exit 2, never 1: the generation did not finish, and an agent reading 1
283
+ // would conclude the layer was cleanly checked and found wanting. The
284
+ // remedy is the same either way and costs nothing — re-run with --write,
285
+ // because nothing here is authored.
286
+ process.stderr.write(
287
+ `derive: writing the derived layer FAILED part-way through — the directory is incomplete\n`
288
+ + ` ${error.message}\n`
289
+ + ` re-run with --write; the derived layer is regenerable, so nothing is lost\n`,
290
+ );
291
+ return EXIT_CODES.FAILURE;
292
+ }
293
+ } else {
294
+ try {
295
+ findings = checkArtifacts(model.root, artifacts);
296
+ } catch (error) {
297
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
298
+ // The engine could not READ the layer — a permission, I/O, or ENOTDIR
299
+ // failure, never a merely absent directory (that returns cleanly, and is
300
+ // the ordinary state of a fresh clone). Exit 2, never 1: nothing was
301
+ // compared, so there are no findings to report, and an agent reading 1
302
+ // would conclude the layer had been checked and found wanting.
303
+ process.stderr.write(
304
+ `derive: the derived layer could not be READ, so nothing was checked\n`
305
+ + ` ${error.message}\n`
306
+ + ` this is not a stale layer — the engine never got to look; fix the path and re-run\n`,
307
+ );
308
+ return EXIT_CODES.FAILURE;
309
+ }
310
+ }
311
+
312
+ // Counted off the same predicate the trees annotate with, so the summary
313
+ // cannot report a different number of demoted leaves than the artifacts show.
314
+ let demoted = 0;
315
+ for (const entry of model.leaves.values()) {
316
+ if (demotionsFor(entry.record, opts.today).length) demoted += 1;
317
+ }
318
+
319
+ const payload = {
320
+ verb: opts.write ? 'write' : 'check',
321
+ directory: `knowledge/${DERIVED_DIR}`,
322
+ note: DERIVED_BANNER,
323
+ 'time-check': timeCheckStatus(opts.today),
324
+ axes: AXES.map((a) => ({ key: a.key, label: a.label, audience: a.audience })),
325
+ 'recall-slot': RECALL_SLOT,
326
+ 'store-health': healthSummary(storeHealth(model)),
327
+ counts: { leaves: model.leaves.size, demoted, findings: findings.length },
328
+ artifacts: artifacts.map((a) => a.path),
329
+ findings,
330
+ };
331
+ const lines = opts.json ? [JSON.stringify(payload, null, 2)] : render(payload);
332
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
333
+ return findings.length ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
334
+ }