@sabaiway/agent-workflow-kit 5.6.0 → 5.7.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 (42) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +7 -1
  7. package/references/modes/doc-parity.md +1 -1
  8. package/references/modes/gates.md +16 -3
  9. package/references/modes/recommendations.md +3 -0
  10. package/references/modes/review-state.md +1 -1
  11. package/references/modes/setup.md +18 -2
  12. package/references/modes/upgrade.md +38 -18
  13. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  14. package/references/scripts/migrate-gates.mjs +295 -60
  15. package/references/scripts/migrate-gates.test.mjs +206 -14
  16. package/references/shared/deploy-tail.md +1 -1
  17. package/references/templates/gates.json +1 -1
  18. package/tools/ack-write.mjs +20 -11
  19. package/tools/atomic-write.mjs +71 -18
  20. package/tools/checker-claim.mjs +100 -0
  21. package/tools/coverage-producer.mjs +43 -6
  22. package/tools/direct-run.mjs +76 -0
  23. package/tools/doc-parity.mjs +34 -3
  24. package/tools/engine-source.mjs +12 -8
  25. package/tools/ensure-configs.mjs +141 -0
  26. package/tools/ensure-ops.mjs +284 -0
  27. package/tools/ensure-vocabulary.mjs +71 -0
  28. package/tools/gates-declaration.mjs +23 -10
  29. package/tools/gates-init.mjs +6 -3
  30. package/tools/hide-footprint.mjs +21 -3
  31. package/tools/lens-region.mjs +74 -23
  32. package/tools/orchestration-config.mjs +5 -3
  33. package/tools/orchestration-write.mjs +7 -0
  34. package/tools/recommendations.mjs +315 -66
  35. package/tools/refresh-parity.mjs +263 -0
  36. package/tools/run-gates.mjs +8 -5
  37. package/tools/setup-backends.mjs +88 -77
  38. package/tools/source-size-check.mjs +6 -16
  39. package/tools/source-size-core.mjs +7 -1
  40. package/tools/source-size-gate-cmd.mjs +18 -46
  41. package/tools/tracked-tree-census.mjs +102 -0
  42. package/tools/upgrade-runlist.mjs +92 -0
@@ -0,0 +1,100 @@
1
+ // checker-claim.mjs — what a declared gate cmd CLAIMS about one of the kit's own `--check` tools,
2
+ // as three named outcomes instead of a boolean. A LEAF: node built-ins only, so every surface that
3
+ // must recognize a tool invocation (the source-size matcher, the advisor's probes, the standalone
4
+ // migration's twin) decides through ONE screen.
5
+ // Dependency-free, Node >= 22. No side effects on import.
6
+
7
+ import { lstatSync, realpathSync } from 'node:fs';
8
+ import { isAbsolute, join } from 'node:path';
9
+
10
+ // checker-claim canon >>> BEGIN drift-guarded region
11
+ // Authored TWICE, byte-identically: in the composition root's tools/checker-claim.mjs and in the
12
+ // memory substrate's references/scripts/migrate-gates.mjs. Neither side imports the other — the
13
+ // substrate is standalone and must not depend on the root, and the root must not import mirrored
14
+ // bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
15
+ // mirror sync.
16
+ //
17
+ // A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
18
+ // what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
19
+ // absence, with a remedy (adopt it) that then collides with the entry already there:
20
+ // • canonical — this tool's `--check` invocation, resolving to THIS copy of it
21
+ // • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
22
+ // • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
23
+ // path nothing can resolve
24
+ // The realpath anchor never widens: a lookalike file that merely carries the basename is not this
25
+ // tool, whatever it prints. What widens is the VOCABULARY. Stated residual, unchanged by the split:
26
+ // nothing here reads the file's CONTENT, so a byte-swapped file at the canonical path is invisible.
27
+ export const CHECKER_CLAIM = Object.freeze({
28
+ CANONICAL: 'canonical',
29
+ ELSEWHERE: 'tool-elsewhere',
30
+ NOT_THE_TOOL: 'not-the-tool',
31
+ });
32
+
33
+ // The token is screened by the rules of the quoting it actually carries, because the two halves are
34
+ // interpreted differently and a single screen would be wrong for one of them:
35
+ // • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
36
+ // • BARE — anything the shell may split, expand or glob makes the executed command different
37
+ // from the string, so a bare token is admitted only from a known-safe alphabet.
38
+ // Either way the point is the same: a path that resolves literally here while the shell would read
39
+ // it differently must never be called a claim about this tool, or the screen certifies a command
40
+ // that never runs.
41
+ export const dqUnsafePath = (text) => [...text].some((ch) => {
42
+ const code = ch.codePointAt(0);
43
+ return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
44
+ });
45
+
46
+ // Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
47
+ // perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
48
+ // through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
49
+ // and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
50
+ const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
51
+ const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
52
+ const code = ch.codePointAt(0);
53
+ return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
54
+ });
55
+
56
+ const RE_META = /[.*+?^${}()|[\]\\]/g;
57
+
58
+ // checkerClaimTool(basename, canonicalPath) → the screen for ONE tool. The shape is the STRICT full
59
+ // command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` + END — so a
60
+ // masked form (`--check --help`, `--check || true`, a prefix command) is never any claim at all.
61
+ // Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
62
+ // execute as written. The basename is regex-escaped here, never by the caller — a caller-escaped
63
+ // literal is one forgotten backslash away from a dot matching any byte.
64
+ export const checkerClaimTool = (basename, canonicalPath) => {
65
+ const safe = basename.replace(RE_META, '\\$&');
66
+ return Object.freeze({
67
+ re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
68
+ canonical: canonicalPath,
69
+ });
70
+ };
71
+
72
+ // classifyCheckerClaim(tool, cmd, projectDir) → one CHECKER_CLAIM value. Every unresolvable side
73
+ // fails CLOSED to `not-the-tool`: an unresolvable path is not evidence the tool lives elsewhere, it
74
+ // is evidence nothing can be told about it — and `tool-elsewhere` is a claim a consumer ACTS on.
75
+ //
76
+ // Two screens beyond the shape, for the same reason the quoting screens exist — a claim must never
77
+ // be minted for a command that cannot run the tool as written:
78
+ // • a token starting with `-` is an OPTION to node, whatever it resolves to on disk. (First-order,
79
+ // like the producer canon's own leading-`-` rule: `{x,-y}` still defeats it, and the cost of a
80
+ // miss is only a withheld claim.)
81
+ // • the RESOLVED target must be a REGULAR FILE. A directory or a FIFO carrying the basename
82
+ // resolves perfectly well and is not a copy of anything; `realpathSync` succeeding proves a path
83
+ // exists, never that it is a tool. lstat runs AFTER realpath, so there is no link left to follow.
84
+ export const classifyCheckerClaim = (tool, cmd, projectDir) => {
85
+ if (typeof cmd !== 'string' || typeof projectDir !== 'string') return CHECKER_CLAIM.NOT_THE_TOOL;
86
+ const match = tool.re.exec(cmd.trim());
87
+ if (!match) return CHECKER_CLAIM.NOT_THE_TOOL;
88
+ const token = match[1] ?? match[2];
89
+ const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
90
+ if (!admissible || token.startsWith('-')) return CHECKER_CLAIM.NOT_THE_TOOL;
91
+ const declared = isAbsolute(token) ? token : join(projectDir, token);
92
+ try {
93
+ const resolved = realpathSync(declared);
94
+ if (!lstatSync(resolved).isFile()) return CHECKER_CLAIM.NOT_THE_TOOL;
95
+ return resolved === realpathSync(tool.canonical) ? CHECKER_CLAIM.CANONICAL : CHECKER_CLAIM.ELSEWHERE;
96
+ } catch {
97
+ return CHECKER_CLAIM.NOT_THE_TOOL;
98
+ }
99
+ };
100
+ // checker-claim canon <<< END drift-guarded region
@@ -13,13 +13,29 @@
13
13
  //
14
14
  // The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
15
15
  // plain run AND on --final (AW_LCOV_FILE is --final only), so one cmd survives the unmet
16
- // producer-variable preflight in both modes. The explicit stdout reporter is not decoration:
17
- // without it the lcov reporter swallows the human TAP/spec stream.
16
+ // producer-variable preflight in both modes. The `:?` is not decoration either: this cmd is also
17
+ // PASTE-READY, and the required-parameter form makes bash refuse BY NAME when AW_GIT_DIR is unset
18
+ // or EMPTY, where a bare `$AW_GIT_DIR` expanded to empty and wrote the lcov to the filesystem ROOT.
19
+ // Residual, stated: `:?` says nothing about the value's ORIGIN — a STALE exported AW_GIT_DIR
20
+ // expands fine and the lcov lands under it; only the runner's own injection makes it the right dir.
21
+ // The explicit stdout reporter keeps the human stream: without it the lcov reporter swallows the
22
+ // TAP/spec output.
18
23
  export const UNIT_TESTS_COVERAGE_FLAGS =
19
- '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
24
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="${AW_GIT_DIR:?exported by run-gates}/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
20
25
 
21
- // The ONE suite body that produces that lcov with no extra dependency.
26
+ // Every flag set the kit has EVER emitted APPEND-ONLY, newest first. Emission uses the head; the
27
+ // tail exists so a declaration written by an EARLIER kit and living on disk in a deployed project
28
+ // keeps reading as the producer it is. De-recognizing a prior form would silently reclassify a
29
+ // working suite gate as customized and withhold the checker over it.
30
+ export const KNOWN_COVERAGE_FLAG_SETS = Object.freeze([
31
+ UNIT_TESTS_COVERAGE_FLAGS,
32
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout',
33
+ ]);
34
+
35
+ // The ONE suite body that produces that lcov with no extra dependency (the EMITTED form), beside
36
+ // the closed set of bodies recognition accepts.
22
37
  export const COVERAGE_PRODUCER_BODY = `node --test ${UNIT_TESTS_COVERAGE_FLAGS}`;
38
+ const KNOWN_PRODUCER_BODIES = Object.freeze(KNOWN_COVERAGE_FLAG_SETS.map((flags) => `node --test ${flags}`));
23
39
 
24
40
  // The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
25
41
  // the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
@@ -52,8 +68,9 @@ const PRODUCER_EXEC_PREFIXES = Object.freeze([
52
68
  const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
53
69
  const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
54
70
  const carriesProducerBody = (text) =>
55
- text === COVERAGE_PRODUCER_BODY ||
56
- (text.startsWith(`${COVERAGE_PRODUCER_BODY} `) && pathShapedTail(text.slice(COVERAGE_PRODUCER_BODY.length).trim()));
71
+ KNOWN_PRODUCER_BODIES.some(
72
+ (body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
73
+ );
57
74
 
58
75
  // matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
59
76
  // substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
@@ -65,4 +82,24 @@ export const matchesCoverageProducer = (cmd) => {
65
82
  if (carriesProducerBody(trimmed)) return true;
66
83
  return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
67
84
  };
85
+
86
+ // isCoverageProducerGate(gate) → the GATE-level producer question, and the ONE predicate every
87
+ // consumer asks it through: does THIS declared entry write the lcov the canonical checker reads?
88
+ // Exactly two ways to be one — the cmd passes the closed world above, or the declaration CLAIMS
89
+ // production through the optional `lcovProducer` marker. The marker exists because the closed world
90
+ // is a `node --test` world: a project whose primary suite is another runner has NO cmd form
91
+ // recognition can accept, so without it the checker over such a suite reads as a dead pair forever.
92
+ // Recognition itself never widens (anti-squatter) — the marker is a declared claim, not a new
93
+ // grammar. Only the literal `true` claims: any truthy value would let the string "false" certify.
94
+ // And the claim is about the DECLARATION, never the run — a marked gate that produces no lcov still
95
+ // ends `skipped-no-lcov` / `attested=no` at run time.
96
+ // An entry with no RUNNABLE cmd claims nothing (fail closed): no string cmd, an empty or
97
+ // whitespace-only one, or one carrying an embedded newline. The strict validator already refuses all
98
+ // three, but this predicate has a SECOND host — the standalone migration's loader is deliberately
99
+ // lenient — and a marker must never make a checker pair with an entry that runs nothing there.
100
+ export const isCoverageProducerGate = (gate) => {
101
+ if (gate === null || typeof gate !== 'object' || Array.isArray(gate) || typeof gate.cmd !== 'string') return false;
102
+ if (gate.cmd.trim() === '' || /[\r\n]/.test(gate.cmd)) return false;
103
+ return matchesCoverageProducer(gate.cmd) || gate.lcovProducer === true;
104
+ };
68
105
  // coverage-producer canon <<< END drift-guarded region
@@ -0,0 +1,76 @@
1
+ // direct-run.mjs — the ONE direct-invocation predicate for kit modules, plus the EXPLICIT registry of
2
+ // library-only modules and the refusal they print when someone runs one as a command.
3
+ //
4
+ // Two facts this leaf owns:
5
+ //
6
+ // 1. WAS this module the process entry point? Compared by REAL path, not lexically. ESM resolves a
7
+ // symlinked entry point to its target, so `import.meta.url === pathToFileURL(process.argv[1]).href`
8
+ // is FALSE whenever a tool is invoked through a link — a CLI whose gate reads that way exits 0
9
+ // having run nothing, which reads as PASS. The realpath compare was fixed once inside the
10
+ // source-size writer; this module is that fix extracted, so every consumer shares it (the
11
+ // THE-LEXICAL-DIRECT-RUN-GUARD class stays closed in one place instead of per file).
12
+ //
13
+ // 2. WHICH modules have no CLI at all. A library-only module invoked directly today runs its
14
+ // top-level code, prints nothing, and exits 0 — indistinguishable from a tool that worked. The
15
+ // registry below turns that silence into a one-line pointer at the command that DOES the thing,
16
+ // and a non-zero exit.
17
+ //
18
+ // The registry is EXPLICIT, never a repo-wide heuristic (D6). Its membership rule — the one the
19
+ // completeness test enforces — is REACHABILITY BY NAME: a module a mode doc names is a module an
20
+ // agent or a user can try to run, and it is exactly those that need a pointer. A library module no
21
+ // document names is unreachable by name and stays out.
22
+ //
23
+ // A pure leaf: it imports NOTHING from tools/ (the read-graph purity walk depends on that) and has no
24
+ // side effect on import.
25
+
26
+ import { realpathSync } from 'node:fs';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { basename } from 'node:path';
29
+
30
+ // The usage exit code of the family's CLIs — invoking a library module is a usage error, not a
31
+ // precondition failure.
32
+ export const DIRECT_RUN_USAGE_EXIT = 2;
33
+
34
+ // Compared by REAL path (see (1) above). Exported as a test seam: the unresolvable arm cannot be
35
+ // reached through a real invocation, where an existing entry point is a precondition.
36
+ export const sameFile = (a, b) => {
37
+ try {
38
+ return realpathSync(a) === realpathSync(b);
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
44
+ // isDirectRun(import.meta.url) → was THIS module the entry point of the current process?
45
+ export const isDirectRun = (moduleUrl, argv1 = process.argv[1]) =>
46
+ Boolean(argv1) && sameFile(fileURLToPath(moduleUrl), argv1);
47
+
48
+ // The registry: library-only module → the command that does what someone reaching for it wants. The
49
+ // value is a user-facing command token of the kit's own catalog (commands.mjs), pinned by the test —
50
+ // never a raw node invocation, which would just move the confusion one file over.
51
+ export const LIBRARY_ONLY_MODULES = Object.freeze({
52
+ // Named by references/modes/upgrade.md as the source of truth for the _README refresh decision; the
53
+ // command that APPLIES that decision is the ensure CLI, and the one that edits the config by intent
54
+ // is set-recipe.
55
+ 'orchestration-config.mjs': '/agent-workflow-kit set-recipe',
56
+ });
57
+
58
+ // The frozen refusal line. One line, names the module, names the command.
59
+ export const libraryOnlyLine = (name) => {
60
+ const command = LIBRARY_ONLY_MODULES[name];
61
+ if (command === undefined) {
62
+ throw new Error(`[agent-workflow-kit] ${name} is not in LIBRARY_ONLY_MODULES — register it before guarding it`);
63
+ }
64
+ return `${name}: library module — no CLI; use ${command}`;
65
+ };
66
+
67
+ // refuseDirectRun(import.meta.url) — the guard a library-only module ends with. A no-op when imported
68
+ // (the overwhelmingly common case); on a DIRECT run it prints the pointer and sets a non-zero exit
69
+ // code. `process.exitCode` rather than `process.exit`: nothing else is pending on a direct run, and a
70
+ // hard exit inside module evaluation would be a side effect of import if the predicate ever misfired.
71
+ export const refuseDirectRun = (moduleUrl, deps = {}) => {
72
+ if (!isDirectRun(moduleUrl, deps.argv1 ?? process.argv[1])) return 0;
73
+ (deps.errlog ?? console.error)(libraryOnlyLine(basename(fileURLToPath(moduleUrl))));
74
+ (deps.setExitCode ?? ((code) => { process.exitCode = code; }))(DIRECT_RUN_USAGE_EXIT);
75
+ return DIRECT_RUN_USAGE_EXIT;
76
+ };
@@ -33,6 +33,8 @@ import {
33
33
  ACKS_FILE,
34
34
  } from './recommendations.mjs';
35
35
  import { SKIPPED_READONLY } from './setup-backends.mjs';
36
+ // The parity verdicts that read-only skip may report — a CLOSED set the same two mode docs enumerate.
37
+ import { PARITY } from './refresh-parity.mjs';
36
38
  // The host-conditional qualifier every settings-derived runtime claim carries (Decision 11).
37
39
  import { HOST_HONORS_QUALIFIER } from './velocity-profile.mjs';
38
40
  import { LATENT_ARM_NOTICE } from './review-state.mjs';
@@ -47,6 +49,14 @@ import { RECEIPT_DEADLINE_CONTRACT } from './receipt-deadline.mjs';
47
49
  import { DISPATCH_CONTRACT } from './dispatch.mjs';
48
50
  // The coverage vocabulary leaf: a CLOSED value set the gates contract doc must enumerate.
49
51
  import { COVERAGE } from './coverage-state.mjs';
52
+ // The canonical producer body: gates.md prints the whole command byte for byte, so the doc is a
53
+ // hand copy of a moving constant unless it is bound here.
54
+ import { COVERAGE_PRODUCER_BODY } from './coverage-producer.mjs';
55
+ // The ensure outcomes upgrade.md relays: the doc enumerates them for the agent, so an outcome the
56
+ // tool renames or drops must fail here rather than leave the doc teaching a vocabulary nobody emits.
57
+ // Imported from the VOCABULARY leaf, never from the ops: a read-only lint must not pull the ensure
58
+ // implementation — and through it the orchestration writer — into its import graph.
59
+ import { RELAYED_ENSURE_TOKENS } from './ensure-vocabulary.mjs';
50
60
 
51
61
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
52
62
 
@@ -116,6 +126,18 @@ export const BINDINGS = Object.freeze([
116
126
  // (setup.md owns --refresh-placed; upgrade.md pastes its lines) — a reworded doc dropping the
117
127
  // outcome fails this pin plus the gate. The token tracks the exported SETUP constant.
118
128
  valueBinding('refresh-skipped-readonly', SKIPPED_READONLY, SKIPPED_READONLY, [SETUP_DOC, UPGRADE_DOC]),
129
+ // And the parity verdict that outcome now carries (feedback-hardening Plan 1 F3 / D3): the skip
130
+ // line used to assert "PARTIALLY updated" and "drift persists" unconditionally, so the docs
131
+ // described a post-state nothing had checked. The verdict set is CLOSED — one binding per value,
132
+ // backticked so a bare word in prose cannot pass for the pinned token — and both docs that
133
+ // enumerate the refresh outcomes must carry all three, or a reworded doc goes back to promising a
134
+ // claim the tool no longer makes.
135
+ ...Object.values(PARITY).map((token) => valueBinding(`refresh-parity:${token}`, token, `\`${token}\``, [SETUP_DOC, UPGRADE_DOC])),
136
+ // The project-configuration ensures (feedback-hardening Plan 1 F4): upgrade.md used to PRESCRIBE
137
+ // each of the four as prose, so the doc was the only place the outcome set existed and drifted for
138
+ // free. Now one command performs them and the doc enumerates its tokens — backticked, so a bare
139
+ // word in a sentence cannot pass for the pinned outcome.
140
+ ...RELAYED_ENSURE_TOKENS.map((token) => valueBinding(`ensure-outcome:${token}`, token, `\`${token}\``, [UPGRADE_DOC])),
119
141
  // The "the tool knows and does not say" contract: a clean-tree PASS must still name a latent arm.
120
142
  // It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
121
143
  // actually emits — a reworded doc dropping the notice fails this pin plus the gate.
@@ -163,6 +185,13 @@ export const BINDINGS = Object.freeze([
163
185
  // CLOSED value set, so a renamed or added value fails here instead of leaving the doc describing
164
186
  // a vocabulary the runner no longer speaks. One binding per value — the set is small and closed.
165
187
  ...Object.values(COVERAGE).map((value) => valueBinding(`coverage-state:${value}`, value, `\`coverage=${value}\``, [GATES_DOC])),
188
+ // The canonical producer body: gates.md prints the whole command byte for byte, so the doc is a
189
+ // HAND COPY of a constant that moves (it moved once already, when the destination became a
190
+ // required-parameter expansion). Nothing else pinned it — the drift guard holds the two authored
191
+ // copies of the code region equal, and the producer test pins the shipped template, but neither
192
+ // sees the doc. Bound here, a future move fails a declared gate instead of leaving the contract
193
+ // doc quietly describing a command the kit no longer emits.
194
+ valueBinding('coverage-producer-body', COVERAGE_PRODUCER_BODY, COVERAGE_PRODUCER_BODY, [GATES_DOC]),
166
195
  ].map((b) => Object.freeze(b)));
167
196
 
168
197
  // ── the pure checker (readText is injectable for hermetic tests) ────────────────────────
@@ -209,13 +238,15 @@ Usage:
209
238
  A CLOSED, exported registry binds each live code constant — the autonomy-doctor contract (the EXIT
210
239
  table, the status tokens, the trusted-dir allowlist), the recommendations/upgrade presentation
211
240
  contract (section header, empty line, verdict templates), the acks-store path, the host-conditional
212
- qualifier every settings-derived runtime claim carries, the setup refresh degrade token, the review-state clean-tree latent-arm notice, the worktrees provision-record
241
+ qualifier every settings-derived runtime claim carries, the setup refresh degrade token and the three
242
+ parity verdicts it reports, the review-state clean-tree latent-arm notice, the worktrees provision-record
213
243
  orientation contract (shared-queue rule, landing-from-main, no-dependencies install posture), the
214
244
  worktrees cleanup-ownership rule, the worktrees include-identity rule, the worktrees
215
245
  resume-verify rule, the flow tolerate contract (the accepted flow schema version + the
216
246
  lagging-kit sentence, procedures.md), the receipt-deadline arrival contract, the dispatch engine's
217
- FORM-only + aggregate-refusal contract (dispatch.md), and the runner's closed coverage= summary
218
- vocabulary (gates.md) to
247
+ FORM-only + aggregate-refusal contract (dispatch.md), the runner's closed coverage= summary
248
+ vocabulary (gates.md), and the canonical coverage-producer-body command the same doc prints in full
249
+ (gates.md) — to
219
250
  the exact token its references/modes/*.md contract must carry, and
220
251
  asserts the CURRENT value renders into every bound file. A drifted doc, an unreadable bound file,
221
252
  or an absent token FAILS CLOSED.
@@ -111,7 +111,7 @@ export const detectEngine = (engineDir, { source, rel } = {}, deps = {}) => {
111
111
  ? `engine manifest name "${report.name}" is not "${EXPECTED_ENGINE_NAME}"`
112
112
  : report.available === false
113
113
  ? 'engine manifest is a declared stub (available:false)'
114
- : `engine fragment missing (${fragmentRel})`;
114
+ : `a required engine file is missing (${fragmentRel})`;
115
115
  return { ok, reason, dir: engineDir };
116
116
  };
117
117
 
@@ -124,15 +124,19 @@ export const detectEngine = (engineDir, { source, rel } = {}, deps = {}) => {
124
124
  export const readEngineFragment = (engineDir, deps = {}) => {
125
125
  const detection = detectEngine(engineDir, { source: deps.source, rel: deps.rel }, deps);
126
126
  const installHint = `npx @sabaiway/agent-workflow-engine@latest init (or set ${ENGINE_ENV})`;
127
- if (!detection.ok) {
128
- throw new Error(`methodology engine not found/invalid at ${engineDir} (${detection.reason}) install it: ${installHint}`);
129
- }
127
+ // The typed {stable, reason} pair lets a consumer split the classified human line from the raw
128
+ // diagnostic (its machine-line channel) without parsing the message; .message stays whole for
129
+ // consumers that print one line.
130
+ const installMe = (reason) => Object.assign(
131
+ new Error(`methodology engine not found/invalid at ${engineDir} (${reason}) — install it: ${installHint}`),
132
+ { stable: `methodology engine not found/invalid at ${engineDir} — install it: ${installHint}`, reason },
133
+ );
134
+ if (!detection.ok) throw installMe(detection.reason);
130
135
  const read = deps.readFileSync ?? readFileSync;
136
+ const fragmentRel = deps.rel ?? ENGINE_FRAGMENT_REL;
131
137
  try {
132
- return read(join(engineDir, deps.rel ?? ENGINE_FRAGMENT_REL), 'utf8');
138
+ return read(join(engineDir, fragmentRel), 'utf8');
133
139
  } catch (err) {
134
- throw new Error(
135
- `methodology engine not found/invalid at ${engineDir} (fragment unreadable: ${err.message}) — install it: ${installHint}`,
136
- );
140
+ throw installMe(`a required engine file is unreadable (${fragmentRel}): ${err.message}`);
137
141
  }
138
142
  };
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // ensure-configs.mjs — ONE runnable command for the four stamp-independent upgrade ensures:
3
+ //
4
+ // orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
5
+ // gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
6
+ // autonomy docs/ai/autonomy.json seed-if-missing (same)
7
+ // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
8
+ //
9
+ // Each was prose in references/modes/upgrade.md that an agent performed by hand. One command instead
10
+ // of four is deliberate: four independent runs would be four chances to skip one, and the mode doc now
11
+ // has a single invocation point whose four outcome lines it relays.
12
+ //
13
+ // The contract (pinned by this module's tests):
14
+ // • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
15
+ // • --dry-run reports `would-*` tokens and writes nothing — never a write token.
16
+ // • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
17
+ // token, and the exit is non-zero when any of them failed.
18
+ // • The deployment gate runs ONCE, before any op: an absent/symlinked docs/ai stops the whole run
19
+ // with the gate's own message rather than four copies of it.
20
+ //
21
+ // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
22
+ // Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
23
+ // main(argv, ctx) → { code, stdout, stderr }; cwd + fs are injectable for host-independent tests.
24
+ //
25
+ // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
26
+
27
+ import { dirname, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { assertDocsAiDeployment } from './atomic-write.mjs';
30
+ import { isDirectRun } from './direct-run.mjs';
31
+ import { ENSURE_IMPLEMENTATIONS, ENSURE_OPS, failedOutcome } from './ensure-ops.mjs';
32
+
33
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
34
+
35
+ const EXIT_OK = 0;
36
+ const EXIT_FAILED = 1;
37
+ const EXIT_USAGE = 2;
38
+
39
+ const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
40
+
41
+ const EMPTY_CWD = '--cwd needs a path argument (an empty value would silently mean the current directory)';
42
+
43
+ const HELP = `ensure-configs — the four stamp-independent upgrade ensures, as ONE command.
44
+
45
+ Usage:
46
+ node ensure-configs.mjs --reconcile [--dry-run] [--cwd <project>]
47
+
48
+ --reconcile required — run the four ensures (orchestration, gates, autonomy, scripts)
49
+ --dry-run report what each ensure WOULD do; write nothing
50
+ --cwd <dir> the target project (default: the current directory)
51
+ --help, -h this help
52
+
53
+ Every seed is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
54
+ refreshed in place. The one refresh is the orchestration onboarding note, and only while it still
55
+ matches a canonical the kit shipped — your own wording is preserved verbatim. The enforcement-script
56
+ ensure detects an older ADR-store layout FIRST and instructs the opt-in migration instead of seeding.
57
+
58
+ Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
59
+
60
+ // argv → { reconcile, dryRun, cwd, help }. Order-independent; an unknown flag or a missing --cwd
61
+ // value is a usage error, never a silently-ignored argument.
62
+ export const parseArgs = (argv) => {
63
+ const out = { reconcile: false, dryRun: false, cwd: undefined, help: false };
64
+ for (let i = 0; i < argv.length; i += 1) {
65
+ const a = argv[i];
66
+ if (a === '--help' || a === '-h') out.help = true;
67
+ else if (a === '--reconcile') out.reconcile = true;
68
+ else if (a === '--dry-run') out.dryRun = true;
69
+ else if (a === '--cwd') {
70
+ // An EMPTY value resolves to the ambient cwd — a writing CLI would then act on a different
71
+ // project than the caller named, silently. Both spellings refuse it.
72
+ const next = argv[i + 1];
73
+ if (next === undefined || next === '' || next.startsWith('-')) throw fail(EXIT_USAGE, EMPTY_CWD);
74
+ out.cwd = next;
75
+ i += 1;
76
+ } else if (a.startsWith('--cwd=')) {
77
+ const value = a.slice('--cwd='.length);
78
+ if (value === '') throw fail(EXIT_USAGE, EMPTY_CWD);
79
+ out.cwd = value;
80
+ } else throw fail(EXIT_USAGE, `unknown argument: ${a}`);
81
+ }
82
+ if (!out.help && !out.reconcile) {
83
+ throw fail(EXIT_USAGE, 'nothing to do — pass --reconcile (see --help). This tool never writes without it.');
84
+ }
85
+ return out;
86
+ };
87
+
88
+ // Run every op in ENSURE_OPS order. A throw from one op becomes THAT op's failed outcome — the
89
+ // remaining ops still run, because a project missing its gate declaration should not also be left
90
+ // without its autonomy seed just because the first ensure hit an unreadable file.
91
+ export const runEnsures = ({ cwd, kitRoot, dryRun, deps }) =>
92
+ ENSURE_OPS.map((op) => {
93
+ try {
94
+ return ENSURE_IMPLEMENTATIONS[op]({ cwd, kitRoot, dryRun, deps });
95
+ } catch (err) {
96
+ return failedOutcome(op, err);
97
+ }
98
+ });
99
+
100
+ const render = (outcomes, dryRun) => {
101
+ // The banner names the tool + the flag it ran under (both machine tokens the L2 rule exempts);
102
+ // the failure footer is a user-grade sentence — the composed-lines guard scans both.
103
+ const lines = [dryRun ? 'ensure-configs (--reconcile, dry run — nothing written)' : 'ensure-configs (--reconcile)'];
104
+ for (const o of outcomes) {
105
+ lines.push(` ${o.op}: ${o.token}`);
106
+ for (const detail of o.lines) lines.push(` ${detail}`);
107
+ }
108
+ if (outcomes.some((o) => o.failed)) {
109
+ // No blanket claim about what was written: an op that copies file by file can stop PARTWAY, and
110
+ // its own lines are the only accurate account of what landed.
111
+ lines.push('', ' part of this configuration run did NOT complete — the lines above name the cause, and what was and was not written.');
112
+ }
113
+ return lines.join('\n');
114
+ };
115
+
116
+ export const main = (argv = [], ctx = {}) => {
117
+ try {
118
+ const args = parseArgs(argv);
119
+ if (args.help) return { code: EXIT_OK, stdout: HELP, stderr: '' };
120
+ const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
121
+ const deps = ctx.deps ?? {};
122
+ // ONE deployment gate for the whole run (see the header): with no docs/ai there is nothing to
123
+ // reconcile, and four identical STOPs would read as four separate problems.
124
+ assertDocsAiDeployment(cwd, deps, { noun: 'the project configuration', rel: 'under docs/ai' });
125
+ const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps });
126
+ return {
127
+ code: outcomes.some((o) => o.failed) ? EXIT_FAILED : EXIT_OK,
128
+ stdout: render(outcomes, args.dryRun),
129
+ stderr: '',
130
+ };
131
+ } catch (err) {
132
+ return { code: err.exitCode ?? EXIT_FAILED, stdout: '', stderr: `ensure-configs: ${err.message}` };
133
+ }
134
+ };
135
+
136
+ if (isDirectRun(import.meta.url)) {
137
+ const r = main(process.argv.slice(2));
138
+ if (r.stdout) console.log(r.stdout);
139
+ if (r.stderr) console.error(r.stderr);
140
+ process.exitCode = r.code;
141
+ }