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,124 @@
1
+ /**
2
+ * Ingest (UCS-1153) — the format-adapter seam at the command line.
3
+ *
4
+ * One document in, one intermediate representation out: ordered blocks with
5
+ * kinds and source locators, normalized from md / txt / html / pdf by the
6
+ * versioned adapters in lib/format-adapters.js. Everything downstream reads
7
+ * the IR; nothing downstream re-reads the original bytes.
8
+ *
9
+ * The seam exists as its own surface because the whole point is that it is
10
+ * TESTABLE AND COMPOSABLE: the document coverage map (UCS-1156) is built on
11
+ * this output, and an agent can run it directly to see exactly what the
12
+ * engine believes a document contains — the same way `survey-map` shows what
13
+ * it believes a repo contains.
14
+ *
15
+ * EXIT CODES (PRD §5, D-011). This surface has no findings to report — it
16
+ * either produces an IR or it does not:
17
+ * 0 — the document was adapted; the IR is on stdout.
18
+ * 2 — the format has no adapter, the content is outside the adapter's
19
+ * envelope, or the file could not be read.
20
+ *
21
+ * There is no exit 1 and no partial IR. An unsupported format is a HARD ERROR
22
+ * WITH CONDUCT, never a best-effort parse: a silent partial poisons what the
23
+ * team believes was reviewed, which is worse than no review at all (D-005 /
24
+ * D-012, the false-all-clear class). The refusal names the format and states
25
+ * every way forward — convert and resubmit, author an adapter, or convert
26
+ * scanned content upstream and resubmit the text.
27
+ */
28
+ import { readFileSync } from 'node:fs';
29
+ import { resolve } from 'node:path';
30
+ import { EXIT_CODES } from '../lib/exit-codes.js';
31
+ import { parseArgs as parseFlags, rethrowIfBug, UsageError } from '../lib/cli.js';
32
+ import { AdaptError, UnsupportedFormatError, adapt, adapterFor } from '../lib/format-adapters.js';
33
+
34
+ export const USAGE = 'usage: node payload/engine/ingest.js <document> [--json]';
35
+
36
+ function parseArgs(argv) {
37
+ const { options, positionals } = parseFlags(argv, {
38
+ boolean: ['json'],
39
+ positionals: true,
40
+ });
41
+ if (positionals.length === 0) {
42
+ throw new UsageError('nothing to ingest — name one document to adapt');
43
+ }
44
+ if (positionals.length > 1) {
45
+ // Two documents would produce two IRs with no honest way to say which
46
+ // blocks came from which; the caller runs the command twice.
47
+ throw new UsageError(`unexpected argument ${JSON.stringify(positionals[1])} — ingest adapts one document at a time`);
48
+ }
49
+ return { document: positionals[0], json: !!options.json };
50
+ }
51
+
52
+ /**
53
+ * Render the IR for a human: the provenance line, then one line per block.
54
+ * The locator is printed in the adapter's own scheme (line-based for text
55
+ * formats, page/object for pdf) — the reader needs to find the block in the
56
+ * source, and those are the coordinates the source actually has.
57
+ */
58
+ function printHuman(ir, document) {
59
+ const locate = ({ line, endLine, page, object }) =>
60
+ page === undefined
61
+ ? `L${line}${endLine !== line ? `-${endLine}` : ''}`
62
+ : `p${page}#${object}`;
63
+ // Folded, not spread: `Math.max(...blocks.map(…))` passes one argument per
64
+ // block, and a long document would exceed the engine's argument limit and
65
+ // throw — a crash on the happy path, from formatting alone.
66
+ let width = 0;
67
+ for (const b of ir.blocks) width = Math.max(width, locate(b.locator).length);
68
+ const lines = [
69
+ `${document}: ${ir.blocks.length} block(s) via ${ir.adapter} (${ir.hash})`,
70
+ ...ir.blocks.map((b) => {
71
+ const text = b.text.replace(/\n/g, ' ');
72
+ const excerpt = text.length > 72 ? `${text.slice(0, 71)}…` : text;
73
+ return ` ${locate(b.locator).padEnd(width)} ${b.kind.padEnd(10)} ${excerpt}`;
74
+ }),
75
+ ];
76
+ process.stdout.write(`${lines.join('\n')}\n`);
77
+ }
78
+
79
+ /**
80
+ * CLI entry. Deterministic by construction: the IR is a pure function of the
81
+ * document's bytes, so two runs over the same file are byte-identical.
82
+ */
83
+ export function main(argv) {
84
+ const opts = parseArgs(argv); // a UsageError reaches the harness
85
+
86
+ // DISPATCH BEFORE READ, deliberately. Whether the bytes exist is irrelevant
87
+ // when no adapter claims the format: reading first would answer a `.docx`
88
+ // submission with "cannot read", burying the conduct the submitter needs
89
+ // behind an incidental filesystem detail.
90
+ try {
91
+ adapterFor(opts.document);
92
+ } catch (error) {
93
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
94
+ if (!(error instanceof UnsupportedFormatError)) throw error;
95
+ process.stderr.write(`error: ${error.message}\n`);
96
+ return EXIT_CODES.FAILURE;
97
+ }
98
+
99
+ const path = resolve(opts.document);
100
+ let bytes;
101
+ try {
102
+ bytes = readFileSync(path);
103
+ } catch (error) {
104
+ rethrowIfBug(error);
105
+ process.stderr.write(`ingest: cannot read ${opts.document}: ${error.message}\n`);
106
+ return EXIT_CODES.FAILURE;
107
+ }
108
+
109
+ let ir;
110
+ try {
111
+ ir = adapt(opts.document, bytes);
112
+ } catch (error) {
113
+ rethrowIfBug(error);
114
+ if (!(error instanceof UnsupportedFormatError || error instanceof AdaptError)) throw error;
115
+ // The refusal and its conduct go to stderr, and NOTHING goes to stdout:
116
+ // a caller piping stdout must receive no IR at all, not a truncated one.
117
+ process.stderr.write(`error: ${error.message}\n`);
118
+ return EXIT_CODES.FAILURE;
119
+ }
120
+
121
+ if (opts.json) process.stdout.write(`${JSON.stringify({ document: opts.document, ...ir }, null, 2)}\n`);
122
+ else printHuman(ir, opts.document);
123
+ return EXIT_CODES.CLEAN;
124
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * engine/log-entry.js (KK-13) — CLI over lib/log-entry.js so agents append
3
+ * and transition fragment-based log entries (PRD §3.4, D-010) without
4
+ * hand-editing YAML.
5
+ *
6
+ * node engine/log-entry.js create --log findings --date 2026-07-08 \
7
+ * --entry '{"trigger":"correction","summary":"K-210 stale per src/tokens.ts"}'
8
+ *
9
+ * node engine/log-entry.js transition --file logs/findings/2026-07-08-a3f2b9c4.yaml \
10
+ * --to proposed --date 2026-07-09 [--reason "..."]
11
+ *
12
+ * --date is mandatory (injectable dates — the helper never reads the wall
13
+ * clock, PRD §5); --root defaults to the cwd. Prints the fragment path and
14
+ * entry as JSON on stdout. Exit codes per PRD §5: 0 clean, 2 failure (an
15
+ * illegal transition or invalid entry is a hard error, never a silent pass).
16
+ *
17
+ * Capture content policy (§3.4): entries carry concept IDs and file paths
18
+ * only — never verbatim user text or secrets.
19
+ */
20
+ import process from 'node:process';
21
+ import { createEntry, transitionStatus, LOGS } from '../lib/log-entry.js';
22
+ import { EXIT_CODES } from '../lib/exit-codes.js';
23
+ import { parseArgs as parseFlags, rethrowIfBug, UsageError } from '../lib/cli.js';
24
+
25
+ export const USAGE = `usage:
26
+ log-entry.js create --log <${Object.keys(LOGS).join('|')}> --date YYYY-MM-DD --entry '<json fields>' [--suffix hhhhhhhh] [--root dir]
27
+ log-entry.js transition --file logs/<log>/<entry>.yaml --to <status> --date YYYY-MM-DD [--reason "..."] [--root dir]`;
28
+
29
+ /** Flags each command understands — a typo (--sufix) is an error, never silence. */
30
+ const KNOWN_FLAGS = Object.freeze({
31
+ create: Object.freeze(['log', 'date', 'entry', 'suffix', 'root']),
32
+ transition: Object.freeze(['file', 'to', 'date', 'reason', 'root']),
33
+ });
34
+
35
+ function parseArgs(argv) {
36
+ const [command, ...rest] = argv;
37
+ if (!Object.hasOwn(KNOWN_FLAGS, command ?? '')) {
38
+ throw new UsageError(`unknown command ${JSON.stringify(command)}`);
39
+ }
40
+ // The grammar is per-subcommand: `--file` is meaningful to transition and a
41
+ // typo to create. A flag the subcommand does not know is an error, never
42
+ // silence.
43
+ const { options } = parseFlags(rest, { value: KNOWN_FLAGS[command] });
44
+ return { command, options };
45
+ }
46
+
47
+ /** @returns {number} an exit code */
48
+ export function main(argv) {
49
+ const { command, options } = parseArgs(argv); // a UsageError reaches the harness
50
+ const root = options.root ?? process.cwd();
51
+
52
+ let fields = null;
53
+ if (command === 'create') {
54
+ try {
55
+ fields = JSON.parse(options.entry ?? '');
56
+ } catch {
57
+ // fall through to the shape check below
58
+ }
59
+ // JSON.parse also accepts null/scalars/arrays — only a plain object is
60
+ // a set of kind-specific fields.
61
+ if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
62
+ throw new UsageError('--entry must be a JSON object of kind-specific fields');
63
+ }
64
+ }
65
+
66
+ let result;
67
+ try {
68
+ result = command === 'create'
69
+ ? createEntry({ root, log: options.log, date: options.date, fields, suffix: options.suffix })
70
+ : transitionStatus({ root, file: options.file, to: options.to, date: options.date, reason: options.reason });
71
+ } catch (error) {
72
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
73
+ // An EXPECTED refusal (an illegal transition, an invalid entry, an unknown
74
+ // log): a hard error, never a silent pass (PRD §5). Exit 2 — the entry was
75
+ // not written, so nothing was found and nothing was logged.
76
+ process.stderr.write(`log-entry: ${error.message}\n`);
77
+ return EXIT_CODES.FAILURE;
78
+ }
79
+
80
+ const { file, entry } = result;
81
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
82
+ // piped JSON output could truncate (corrupt output with exit 0).
83
+ console.log(JSON.stringify({ file, status: entry.status, entry }, null, 2));
84
+ return EXIT_CODES.CLEAN;
85
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Phoenix events (KK-34) — apply a governed bulk re-taxonomy (UCS-1154).
3
+ *
4
+ * The first engine command that MUTATES a store, which is why almost all of it
5
+ * is about refusing to.
6
+ *
7
+ * A phoenix event rewrites a drifted subtree's facets in bulk from a
8
+ * leaf-granular mapping, bumps the `edition` of every leaf it moves — the only
9
+ * thing that ever bumps it in v2 — and lands as an ordinary PR. Citations are
10
+ * untouched by construction: identity is the accession id, and no event ever
11
+ * changes one, so nothing that cites a leaf notices it was reclassified.
12
+ *
13
+ * Two verbs, and the read-only one is the default:
14
+ *
15
+ * --check (default) plan the event and report. Writes nothing, ever.
16
+ * --apply plan the event, and write ONLY if the plan is completely clean.
17
+ *
18
+ * `--check` is the default because the safe reading of a bare `phoenix` is the
19
+ * one that cannot damage a store. An agent that runs the command without
20
+ * reading this file gets a dry run; mutating takes a word typed on purpose.
21
+ *
22
+ * Exit codes follow the engine contract (PRD §5, D-011), and the classification
23
+ * of a rejected mapping is the load-bearing choice here. A mapping that misses
24
+ * a leaf in its declared scope, or names an accession the store does not carry,
25
+ * is FINDINGS — exit 1. The precedent is the one every other surface follows:
26
+ * exit 1 means the run happened and the input has defects its author fixes,
27
+ * which is exactly what a bad mapping is. Exit 2 stays reserved for a run that
28
+ * never happened (unreadable store, unparseable mapping, no such event), where
29
+ * an agent must not conclude anything about the store at all.
30
+ *
31
+ * A rejected event NEVER writes. The gate is total and it runs first: the whole
32
+ * mapping is planned against the whole store before a single byte is written,
33
+ * and any finding refuses the entire event. There is no partial apply, because
34
+ * a half-re-taxonomized store is worse than an untouched one — the next run
35
+ * cannot tell which leaves already moved.
36
+ */
37
+ import { resolve } from 'node:path';
38
+ import { locateKitRoot } from '../lib/kit-root.js';
39
+ import {
40
+ loadStores, storeHealth, healthSummary, PHOENIX_DIR, PHOENIX_STORES,
41
+ } from '../lib/load-stores.js';
42
+ import { CHECKS, applyRewrites, planEvent, phoenixPath } from '../lib/phoenix.js';
43
+ import { compare } from '../lib/validate-record.js';
44
+ import { EXIT_CODES } from '../lib/exit-codes.js';
45
+ import { parseArgs as parseFlags, rethrowIfBug, UsageError } from '../lib/cli.js';
46
+
47
+ export const USAGE = 'usage: node payload/engine/phoenix.js <event> [--apply] [--check] [--root <dir>] [--json]';
48
+
49
+ function parseArgs(argv) {
50
+ const { options, positionals } = parseFlags(argv, {
51
+ boolean: ['json', 'apply', 'check'],
52
+ value: ['root'],
53
+ positionals: true,
54
+ });
55
+ if (positionals.length > 1) {
56
+ throw new UsageError(`unexpected argument ${JSON.stringify(positionals[1])} — one event at a time`);
57
+ }
58
+ if (!positionals.length) {
59
+ throw new UsageError(
60
+ `name the phoenix event to apply, e.g. P-001 (its mapping is ${phoenixPath(PHOENIX_STORES[0], '<event>')})`,
61
+ );
62
+ }
63
+ // Naming both verbs states two intentions and the difference between them is
64
+ // whether the store gets written to. Refusing is the only reading that cannot
65
+ // silently pick the destructive one.
66
+ if (options.apply && options.check) {
67
+ throw new UsageError('--apply and --check are the two verbs; name one');
68
+ }
69
+ return {
70
+ event: positionals[0],
71
+ root: resolve(options.root ?? '.'),
72
+ json: !!options.json,
73
+ apply: !!options.apply,
74
+ };
75
+ }
76
+
77
+ function render(payload) {
78
+ const { event, verb, findings, rewrites, carried } = payload;
79
+ const lines = [];
80
+ if (findings.length) {
81
+ lines.push(
82
+ `phoenix ${event} -> REFUSED — ${findings.length} finding(s); nothing was written`,
83
+ `checks run: ${CHECKS.join(', ')}`,
84
+ );
85
+ for (const f of findings) {
86
+ lines.push(`${f.severity} ${f.code} ${f.id} ${f.file} ${f.path}`, ` ${f.message}`);
87
+ }
88
+ lines.push('a phoenix event applies in full or not at all — fix every finding and re-run');
89
+ return lines;
90
+ }
91
+ const moved = `${rewrites.length} leaf/leaves rewritten, ${carried.length} carried forward`;
92
+ lines.push(
93
+ verb === 'apply'
94
+ ? `phoenix ${event} -> APPLIED — ${moved}`
95
+ : `phoenix ${event} -> would apply cleanly — ${moved} (nothing written; --apply to write)`,
96
+ `checks run: ${CHECKS.join(', ')}`,
97
+ );
98
+ for (const r of rewrites) {
99
+ lines.push(` ${r.id} ${r.from} -> ${r.to} edition ${r.edition - 1} -> ${r.edition} ${r.file}`);
100
+ }
101
+ for (const id of carried) lines.push(` ${id} carried forward — considered, unmoved, edition unchanged`);
102
+ lines.push('accession ids and citations are untouched by construction — nothing that cites these leaves moved');
103
+ return lines;
104
+ }
105
+
106
+ /**
107
+ * CLI entry. Exit codes per the engine contract (PRD §5): 0 the event is clean
108
+ * (and applied, under --apply), 1 the mapping was REFUSED with findings, 2 the
109
+ * run never happened.
110
+ *
111
+ * @param {string[]} argv
112
+ * @returns {number} an exit code
113
+ */
114
+ export function main(argv) {
115
+ const opts = parseArgs(argv); // a UsageError reaches the harness
116
+
117
+ let model;
118
+ try {
119
+ model = loadStores(locateKitRoot(opts.root));
120
+ } catch (error) {
121
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
122
+ process.stderr.write(`phoenix: ${error.message}\n`);
123
+ return EXIT_CODES.FAILURE;
124
+ }
125
+
126
+ // A store the loader rejects cannot be re-taxonomized: the model the plan
127
+ // would be built from is already known to be wrong, so every verdict about
128
+ // which leaves are in scope would be untrustworthy — and under --apply we
129
+ // would write from it. Exit 2: the event never ran.
130
+ if (!model.ok) {
131
+ const health = storeHealth(model);
132
+ process.stderr.write(
133
+ `phoenix: the store has ${health.errors.length} loader error(s); no event runs against a store that does not load\n`,
134
+ );
135
+ for (const d of health.errors) {
136
+ process.stderr.write(` ${d.code} ${d.file}${d.path ? ` ${d.path}` : ''} ${d.message}\n`);
137
+ }
138
+ return EXIT_CODES.FAILURE;
139
+ }
140
+
141
+ // Which stores may carry events is the descriptor table's answer, not this
142
+ // command's: naming "knowledge" here would leave a second phoenix-capable
143
+ // store's events loaded into the model and unreachable from the CLI.
144
+ const event = PHOENIX_STORES.map((store) => model.phoenix.get(`${store}/${opts.event}`))
145
+ .find((found) => found !== undefined);
146
+ if (!event) {
147
+ const known = [...model.phoenix.keys()].map((k) => k.slice(k.indexOf('/') + 1)).sort(compare);
148
+ const looked = PHOENIX_STORES.map((store) => phoenixPath(store, opts.event)).join(' or ');
149
+ process.stderr.write(
150
+ `phoenix: no event "${opts.event}" — expected its mapping at ${looked}`
151
+ + `${known.length ? `; this store carries ${known.join(', ')}` : `; this store carries no ${PHOENIX_DIR}/ mappings`}\n`,
152
+ );
153
+ return EXIT_CODES.FAILURE;
154
+ }
155
+
156
+ const plan = planEvent(model, event);
157
+ // THE GATE. Everything above decided; nothing above wrote. A single finding
158
+ // refuses the whole event, and the write below is unreachable.
159
+ const refused = plan.findings.length > 0;
160
+ if (opts.apply && !refused) {
161
+ const touched = [];
162
+ try {
163
+ applyRewrites(model.root, plan.rewrites, touched);
164
+ } catch (error) {
165
+ rethrowIfBug(error); // a bug is not a refusal — the harness prints its stack
166
+ // The gate passed and the write began, so this is a filesystem failure
167
+ // between the probe and the write — rare, and the one path that can leave
168
+ // a store partially rewritten. Exit 2, never 1: the event did not finish,
169
+ // and an agent reading 1 would conclude it was cleanly refused and that
170
+ // nothing was written, which is exactly false here.
171
+ //
172
+ // The last entry is the file the write threw on. It is listed with the
173
+ // rest rather than excused: writeFileSync can truncate and then fail, so
174
+ // that file may be damaged even though its write did not complete — and
175
+ // it is the likeliest of all of them to need reverting.
176
+ const failed = touched[touched.length - 1];
177
+ process.stderr.write(
178
+ `phoenix: ${event.event} FAILED PART-WAY THROUGH — the store is partially rewritten\n`
179
+ + ` ${error.message}\n`
180
+ + ` revert these ${touched.length} file(s) and re-run:\n`
181
+ + touched.map((f) => ` ${f}${f === failed ? ' <- the write failed here; this file may be truncated' : ''}\n`).join(''),
182
+ );
183
+ return EXIT_CODES.FAILURE;
184
+ }
185
+ }
186
+
187
+ const payload = {
188
+ event: event.event,
189
+ verb: opts.apply && !refused ? 'apply' : 'check',
190
+ mapping: event.file,
191
+ decision: event.decision,
192
+ scope: event.scope,
193
+ checks: CHECKS,
194
+ 'store-health': healthSummary(storeHealth(model)),
195
+ counts: { findings: plan.findings.length, rewritten: plan.rewrites.length, carried: plan.carried.length },
196
+ findings: plan.findings,
197
+ // The before/after text is deliberately not published: it would make the
198
+ // JSON enormous and the diff is what `git diff` is for. What ships is the
199
+ // claim a reviewer checks — which leaf moved where, and to which edition.
200
+ rewrites: plan.rewrites.map(({ id, file, from, to, edition }) => ({ id, file, from, to, edition })),
201
+ carried: plan.carried,
202
+ };
203
+ const lines = opts.json ? [JSON.stringify(payload, null, 2)] : render(payload);
204
+ process.stdout.write(`${lines.join('\n').replace(/\n+$/, '')}\n`);
205
+ return refused ? EXIT_CODES.FINDINGS : EXIT_CODES.CLEAN;
206
+ }