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.
- package/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- 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
|
+
}
|