@sabaiway/agent-workflow-kit 5.6.0 → 5.8.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 (53) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +26 -17
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -5
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +13 -4
  7. package/bridges/antigravity-cli-bridge/bin/agy.sh +7 -4
  8. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +24 -0
  9. package/bridges/antigravity-cli-bridge/capability.json +3 -3
  10. package/bridges/antigravity-cli-bridge/references/driving-agy.md +9 -8
  11. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +31 -14
  12. package/bridges/antigravity-cli-bridge/setup/README.md +4 -3
  13. package/capability.json +1 -1
  14. package/package.json +1 -1
  15. package/references/hooks/gate-approve.mjs +7 -1
  16. package/references/modes/doc-parity.md +1 -1
  17. package/references/modes/gates.md +16 -3
  18. package/references/modes/grounding.md +4 -3
  19. package/references/modes/recommendations.md +3 -0
  20. package/references/modes/review-state.md +1 -1
  21. package/references/modes/setup.md +18 -2
  22. package/references/modes/upgrade.md +38 -18
  23. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  24. package/references/scripts/migrate-gates.mjs +295 -60
  25. package/references/scripts/migrate-gates.test.mjs +206 -14
  26. package/references/shared/deploy-tail.md +1 -1
  27. package/references/templates/gates.json +1 -1
  28. package/tools/ack-write.mjs +20 -11
  29. package/tools/atomic-write.mjs +71 -18
  30. package/tools/checker-claim.mjs +100 -0
  31. package/tools/coverage-producer.mjs +43 -6
  32. package/tools/direct-run.mjs +76 -0
  33. package/tools/doc-parity.mjs +34 -3
  34. package/tools/engine-source.mjs +12 -8
  35. package/tools/ensure-configs.mjs +141 -0
  36. package/tools/ensure-ops.mjs +284 -0
  37. package/tools/ensure-vocabulary.mjs +71 -0
  38. package/tools/gates-declaration.mjs +23 -10
  39. package/tools/gates-init.mjs +6 -3
  40. package/tools/grounding.mjs +105 -16
  41. package/tools/hide-footprint.mjs +21 -3
  42. package/tools/lens-region.mjs +74 -23
  43. package/tools/orchestration-config.mjs +5 -3
  44. package/tools/orchestration-write.mjs +7 -0
  45. package/tools/recommendations.mjs +315 -66
  46. package/tools/refresh-parity.mjs +263 -0
  47. package/tools/run-gates.mjs +8 -5
  48. package/tools/setup-backends.mjs +88 -77
  49. package/tools/source-size-check.mjs +6 -16
  50. package/tools/source-size-core.mjs +7 -1
  51. package/tools/source-size-gate-cmd.mjs +18 -46
  52. package/tools/tracked-tree-census.mjs +102 -0
  53. package/tools/upgrade-runlist.mjs +92 -0
@@ -0,0 +1,284 @@
1
+ // ensure-ops.mjs — the FOUR upgrade ensure operations, one function each, behind one shared outcome
2
+ // shape. The CLI that orders and runs them is ensure-configs.mjs; this module owns what each ensure
3
+ // DOES and, more importantly, what it is allowed to CLAIM.
4
+ //
5
+ // Why they became code at all: `references/modes/upgrade.md` prescribed each of them as prose an agent
6
+ // was expected to carry out by hand ("create it from the template if missing", "copy the pair from
7
+ // references/scripts/ if missing"). A prescribed state-changing operation with no runnable command is
8
+ // a step that silently varies per session — the feedback item this phase answers.
9
+ //
10
+ // The invariants every op holds (they are what the tests pin):
11
+ // • CREATE-ONLY seeds. A seed never clobbers: the write is the link-based create-only arm of
12
+ // atomic-write.mjs, so a file that appears between the probe and the write survives byte-for-byte
13
+ // and the op says `already-present` rather than reporting a write it did not do.
14
+ // • The DECISION lives where it already lived. The orchestration `_README` refresh asks
15
+ // orchestration-config.mjs (refreshReadme / the known-prior canonical set) and writes through
16
+ // orchestration-write.mjs — the file's one writer. Nothing here re-derives either.
17
+ // • Every token names a state this run PROVED. `already-present` follows a probe; `skipped-no-node`
18
+ // names the missing package.json; an ADR-layout read that fails is `adr-layout-unverifiable` and
19
+ // writes NOTHING (the STRICT survey, fail-closed — the lenient status wrapper reads an unreadable
20
+ // tree as `none`, which here would mean seeding a rotator beside a store nobody could inspect).
21
+ // • A failed op is a non-zero signal, never a line that reads like success.
22
+ //
23
+ // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
24
+
25
+ import { readFileSync, lstatSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
28
+ import { seedConfig, writeConfig } from './orchestration-write.mjs';
29
+ import { lstatNoFollow, writeDocsAiFileAtomic, writeProjectFileCreateOnly } from './atomic-write.mjs';
30
+ import { GATES_REL } from './gates-declaration.mjs';
31
+ import { AUTONOMY_REL } from './autonomy-config.mjs';
32
+ import { surveyAdrLayoutStrict } from './family-registry.mjs';
33
+ import { ENSURE_TOKENS, FAILURE_CAUSES, SEED_SCRIPTS } from './ensure-vocabulary.mjs';
34
+
35
+ // The closed vocabulary lives in its own PURE leaf so the read-only doc-parity lint can bind the
36
+ // relayed token set without importing this module's writer graph. Re-exported here because every
37
+ // consumer of the ops also speaks the vocabulary.
38
+ export {
39
+ ENSURE_OPS,
40
+ ENSURE_TOKENS,
41
+ DRY_RUN_TOKENS,
42
+ FAILURE_CAUSES,
43
+ RELAYED_ENSURE_TOKENS,
44
+ SEED_SCRIPTS,
45
+ WRITE_TOKENS,
46
+ } from './ensure-vocabulary.mjs';
47
+
48
+ const PACKAGE_JSON = 'package.json';
49
+ const SCRIPTS_DIR = 'scripts';
50
+
51
+ const outcome = (op, token, lines, failed = false) => {
52
+ if (!ENSURE_TOKENS.includes(token)) {
53
+ throw new Error(`[agent-workflow-kit] unknown ensure outcome "${token}" — the token vocabulary is closed`);
54
+ }
55
+ return { op, token, failed, lines };
56
+ };
57
+ const ok = (op, token, ...lines) => outcome(op, token, lines, false);
58
+ // A LOUD failure the doc gives its OWN token (today: malformed-preserved — the file is preserved, and
59
+ // that is exactly what the reader must be told).
60
+ const loudToken = (op, token, ...lines) => outcome(op, token, lines, true);
61
+ // Every other LOUD failure: the token is always `failed`, the closed cause word opens the first line.
62
+ const loud = (op, cause, ...lines) => {
63
+ if (!FAILURE_CAUSES.includes(cause)) {
64
+ throw new Error(`[agent-workflow-kit] unknown ensure failure cause "${cause}" — the cause vocabulary is closed`);
65
+ }
66
+ return outcome(op, 'failed', [`${cause} — ${lines[0]}`, ...lines.slice(1)], true);
67
+ };
68
+
69
+ // The closed-vocabulary DOOR, exported as a seam so the refusal itself is testable: EVERY outcome in
70
+ // this module is composed through one of these two, and a word outside the closed sets throws here
71
+ // instead of reaching a caller that has no idea how to relay it.
72
+ export const composeOutcome = outcome;
73
+ export const composeFailure = loud;
74
+
75
+ const causeOf = (err) => String((err && err.message) || err);
76
+
77
+ // The CLI's catch-all, here rather than there so EVERY outcome in the system — including the one
78
+ // nobody planned for — is composed through the closed vocabulary. A throw that reached here is
79
+ // `unexpected-error`: the cause word is still one of the closed set, and the thrown message follows
80
+ // it (a bare `${op}: …` line would be the one failure in the system that names no cause).
81
+ export const failedOutcome = (op, err) => loud(op, 'unexpected-error', `${op}: ${causeOf(err)}`);
82
+
83
+ // `already-present` must mean a FILE is there. An lstat that merely finds SOMETHING would let a
84
+ // directory or a symlink named gates.json report a green ensure while the declaration the project
85
+ // needs does not exist — an exit 0 proving nothing (both review backends found this).
86
+ const NODE_KIND = (st) => (st.isSymbolicLink() ? 'a symlink' : st.isDirectory() ? 'a directory' : 'not a regular file');
87
+ const probeSeedTarget = (abs, lstat) => {
88
+ const st = lstatNoFollow(abs, lstat);
89
+ if (st === null) return { present: false };
90
+ return st.isFile() ? { present: true } : { present: true, wrongKind: NODE_KIND(st) };
91
+ };
92
+
93
+ // A leftover temp file never fails a completed write, and is never silent either.
94
+ const tmpNote = (rel, tmpLeftBehind) =>
95
+ (tmpLeftBehind ? [`${rel}: the write stands, but its temp file could not be removed — delete it by hand: ${tmpLeftBehind}`] : []);
96
+
97
+ // ── 1. orchestration.json — seed, or refresh ONLY a still-canonical onboarding note ────────────────
98
+
99
+ // Which no-change outcome is it? refreshReadme returns `changed: false` for two very different trees:
100
+ // a note that already IS the current canonical, and a note the user rewrote. Reporting both as
101
+ // "already current" would claim the second is something it is not.
102
+ const unchangedNoteToken = (config) =>
103
+ normalizeCanonical(config?._README ?? '') === normalizeCanonical(CANON_README) ? 'already-current' : 'customized-preserved';
104
+
105
+ const applyNoteRefresh = (cwd, config, dryRun, deps) => {
106
+ const { config: next, changed } = refreshReadme(config);
107
+ if (!changed) {
108
+ const token = unchangedNoteToken(config);
109
+ return ok(
110
+ 'orchestration',
111
+ token,
112
+ token === 'already-current'
113
+ ? `${CONFIG_REL}: the onboarding note is the current canonical — nothing written`
114
+ : `${CONFIG_REL}: the onboarding note carries your own wording — preserved verbatim, nothing written`,
115
+ );
116
+ }
117
+ if (dryRun) return ok('orchestration', 'would-refresh-note', `${CONFIG_REL}: the onboarding note matches a previous canonical and would be refreshed (every recipe you set is kept)`);
118
+ writeConfig(cwd, next, deps);
119
+ return ok('orchestration', 'note-refreshed', `${CONFIG_REL}: the onboarding note was refreshed to the current canonical (every recipe you set is kept)`);
120
+ };
121
+
122
+ // A load failure is not automatically "malformed": a file that VANISHED between the reader's own
123
+ // lstat and its read surfaces as unreadable, and calling that preserved-and-malformed would state
124
+ // two things this run did not observe. Probe once more and classify by what is there NOW.
125
+ const loadFailureOutcome = (cwd, err, lstat, whenSeeding) => {
126
+ if (lstatNoFollow(join(cwd, CONFIG_REL), lstat) === null) {
127
+ return loud('orchestration', 'race-unresolved', `${CONFIG_REL}: could not be read and is not there now — something is creating and removing it underneath this run; nothing written, re-run when the tree is settled`);
128
+ }
129
+ const where = whenSeeding ? 'appeared while this run was seeding it, and ' : '';
130
+ return loudToken('orchestration', 'malformed-preserved', `${CONFIG_REL}: ${where}could not be read as the config it must be — preserved untouched, nothing written. ${causeOf(err)}`);
131
+ };
132
+
133
+ export const ensureOrchestration = ({ cwd, dryRun = false, deps = {} }) => {
134
+ const read = deps.readFile ?? readFileSync;
135
+ const lstat = deps.lstat ?? lstatSync;
136
+ // The KIND comes before the content: a symlink pointing at valid JSON parses fine and would report
137
+ // `already-current` over a file this ensure would refuse to write through — an exit 0 that proves
138
+ // nothing about the config the project actually has.
139
+ const kind = probeSeedTarget(join(cwd, CONFIG_REL), lstat);
140
+ if (kind.wrongKind) {
141
+ return loud('orchestration', 'wrong-node-kind', `${CONFIG_REL}: exists but is ${kind.wrongKind} — nothing was read or written; resolve it by hand, then re-run`);
142
+ }
143
+ let loaded;
144
+ try {
145
+ loaded = loadConfig(cwd, read, lstat);
146
+ } catch (err) {
147
+ // Malformed / unreadable: preserved untouched, and LOUD — clobbering a file we cannot parse would
148
+ // destroy hand-authored configuration to fix a note.
149
+ return loadFailureOutcome(cwd, err, lstat, false);
150
+ }
151
+ if (loaded.config !== null) return applyNoteRefresh(cwd, loaded.config, dryRun, deps);
152
+ if (dryRun) return ok('orchestration', 'would-seed', `${CONFIG_REL}: absent — would be created from the canonical seed`);
153
+
154
+ const { created, tmpLeftBehind } = seedConfig(cwd, SEED_CONFIG, deps);
155
+ const seedNote = tmpNote(CONFIG_REL, tmpLeftBehind);
156
+ if (created) return ok('orchestration', 'seeded', `${CONFIG_REL}: created from the canonical seed`, ...seedNote);
157
+ // It appeared between the probe and the write. Nothing was overwritten; read it once more and
158
+ // report what it now IS, rather than a claim about the file we did not write.
159
+ let second;
160
+ try {
161
+ second = loadConfig(cwd, read, lstat);
162
+ } catch (err) {
163
+ return loadFailureOutcome(cwd, err, lstat, true);
164
+ }
165
+ if (second.config === null) {
166
+ return loud('orchestration', 'race-unresolved', `${CONFIG_REL}: something is creating and removing this file underneath this run — nothing written; re-run when the tree is settled`, ...seedNote);
167
+ }
168
+ const refreshed = applyNoteRefresh(cwd, second.config, dryRun, deps);
169
+ return { ...refreshed, lines: [...refreshed.lines, ...seedNote] };
170
+ };
171
+
172
+ // ── 2/3. gates.json + autonomy.json — seed-if-missing, existing file preserved byte-for-byte ───────
173
+
174
+ const seedFromTemplate = ({ op, rel, template, noun, cwd, kitRoot, dryRun, deps }) => {
175
+ const lstat = deps.lstat ?? lstatSync;
176
+ const read = deps.readFile ?? readFileSync;
177
+ const probe = probeSeedTarget(join(cwd, rel), lstat);
178
+ if (probe.wrongKind) {
179
+ return loud(op, 'wrong-node-kind', `${rel}: exists but is ${probe.wrongKind} — nothing was written, and this is NOT a usable declaration; resolve it by hand, then re-run`);
180
+ }
181
+ if (probe.present) return ok(op, 'already-present', `${rel}: already present — preserved byte-for-byte, nothing written`);
182
+ if (dryRun) return ok(op, 'would-seed', `${rel}: absent — would be created from the bundled template`);
183
+ let body;
184
+ try {
185
+ body = String(read(join(kitRoot, 'references', 'templates', template), 'utf8'));
186
+ } catch (err) {
187
+ return loud(op, 'template-unreadable', `${rel}: the bundled template could not be read, so nothing was written — reinstall the kit. ${causeOf(err)}`);
188
+ }
189
+ const { created, tmpLeftBehind } = writeDocsAiFileAtomic(cwd, rel, body, deps, { noun, createOnly: true });
190
+ return created
191
+ ? ok(op, 'seeded', `${rel}: created from the bundled template`, ...tmpNote(rel, tmpLeftBehind))
192
+ : ok(op, 'already-present', `${rel}: appeared while this run was seeding it — the existing file stands, byte-for-byte`, ...tmpNote(rel, tmpLeftBehind));
193
+ };
194
+
195
+ export const ensureGates = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
196
+ seedFromTemplate({ op: 'gates', rel: GATES_REL, template: 'gates.json', noun: 'a gate declaration', cwd, kitRoot, dryRun, deps });
197
+
198
+ export const ensureAutonomy = ({ cwd, kitRoot, dryRun = false, deps = {} }) =>
199
+ seedFromTemplate({ op: 'autonomy', rel: AUTONOMY_REL, template: 'autonomy.json', noun: 'an autonomy policy', cwd, kitRoot, dryRun, deps });
200
+
201
+ // ── 4. scripts/ — the ADR-cascade enforcement pairs, detect-first ──────────────────────────────────
202
+
203
+ // A project with no package.json at its root is not where Node enforcement scripts belong. Stated,
204
+ // never silent: the token names the evidence, and the three config ensures still run.
205
+ const isNodeProject = (cwd, lstat) => lstatNoFollow(join(cwd, PACKAGE_JSON), lstat) !== null;
206
+
207
+ const OLD_ADR_LAYOUTS = new Set(['old', 'old-unrotated']);
208
+
209
+ // The scripts ensure copies file by file, so a failure PARTWAY leaves earlier copies in place. Saying
210
+ // so is the difference between a failure the reader can act on and one they read as "nothing happened"
211
+ // — the CLI's summary therefore claims nothing about writes, and this line states the truth per op.
212
+ const partialNote = (lines) => {
213
+ const copied = lines.filter((line) => line.includes(': copied from the bundled scripts')).length;
214
+ return copied > 0 ? [`the copying stopped PARTWAY — the ${copied} file(s) named above were already copied and are NOT rolled back`] : [];
215
+ };
216
+
217
+ export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
218
+ const lstat = deps.lstat ?? lstatSync;
219
+ const read = deps.readFile ?? readFileSync;
220
+ if (!isNodeProject(cwd, lstat)) {
221
+ return ok('scripts', 'skipped-no-node', `${SCRIPTS_DIR}/: no ${PACKAGE_JSON} at the project root — the seeded pairs are Node enforcement; nothing written`);
222
+ }
223
+ let layout;
224
+ try {
225
+ layout = surveyAdrLayoutStrict(cwd, deps);
226
+ } catch (err) {
227
+ return loud('scripts', 'adr-layout-unverifiable', `${SCRIPTS_DIR}/: the ADR-store layout could not be read, so nothing was written — a rotator seeded beside an un-migrated store reds the ADR gate. ${causeOf(err)}`);
228
+ }
229
+ if (OLD_ADR_LAYOUTS.has(layout)) {
230
+ return ok(
231
+ 'scripts',
232
+ 'old-adr-layout-migration-instructed',
233
+ `${SCRIPTS_DIR}/: this project is still on the older ADR layout (${layout}) — nothing written. Run the opt-in /agent-workflow-kit migrate-adr-store (it previews, and never commits); the seed lands on the next upgrade.`,
234
+ );
235
+ }
236
+ const lines = [];
237
+ let anyCreated = false;
238
+ for (const name of SEED_SCRIPTS) {
239
+ const rel = `${SCRIPTS_DIR}/${name}`;
240
+ const probe = probeSeedTarget(join(cwd, SCRIPTS_DIR, name), lstat);
241
+ if (probe.wrongKind) {
242
+ return loud('scripts', 'wrong-node-kind', `${rel}: exists but is ${probe.wrongKind} — that is not the enforcement script this run places`, ...lines, ...partialNote(lines));
243
+ }
244
+ if (probe.present) {
245
+ lines.push(`${rel}: already present — preserved, never overwritten`);
246
+ continue;
247
+ }
248
+ if (dryRun) {
249
+ lines.push(`${rel}: absent — would be copied from the bundled scripts`);
250
+ continue;
251
+ }
252
+ let body;
253
+ try {
254
+ body = String(read(join(kitRoot, 'references', 'scripts', name), 'utf8'));
255
+ } catch (err) {
256
+ return loud('scripts', 'bundle-unreadable', `${rel}: the bundled script could not be read — reinstall the kit. ${causeOf(err)}`, ...lines, ...partialNote(lines));
257
+ }
258
+ // The WRITE is caught here, not by the CLI's catch-all: a throw that escapes this loop would take
259
+ // the accumulated lines with it, and the run would report a failure without saying which files it
260
+ // had already copied.
261
+ let result;
262
+ try {
263
+ result = writeProjectFileCreateOnly(cwd, rel, body, deps, { noun: 'a seeded enforcement script' });
264
+ } catch (err) {
265
+ return loud('scripts', 'write-refused', `${rel}: ${causeOf(err)}`, ...lines, ...partialNote(lines));
266
+ }
267
+ anyCreated = anyCreated || result.created;
268
+ lines.push(result.created ? `${rel}: copied from the bundled scripts` : `${rel}: appeared while this run was seeding it — the existing file stands`);
269
+ lines.push(...tmpNote(rel, result.tmpLeftBehind));
270
+ }
271
+ if (dryRun) {
272
+ const wouldSeed = lines.some((line) => line.includes('would be copied'));
273
+ return outcome('scripts', wouldSeed ? 'would-seed' : 'already-present', lines, false);
274
+ }
275
+ return outcome('scripts', anyCreated ? 'seeded' : 'already-present', lines, false);
276
+ };
277
+
278
+ // The op table the CLI walks — name → implementation, in ENSURE_OPS order.
279
+ export const ENSURE_IMPLEMENTATIONS = Object.freeze({
280
+ orchestration: ensureOrchestration,
281
+ gates: ensureGates,
282
+ autonomy: ensureAutonomy,
283
+ scripts: ensureScripts,
284
+ });
@@ -0,0 +1,71 @@
1
+ // ensure-vocabulary.mjs — the CLOSED vocabulary of the upgrade ensures: which operations exist, which
2
+ // outcome tokens they may print, which cause words may open a failure line, and which of those the
3
+ // mode doc teaches.
4
+ //
5
+ // It is a PURE LEAF (no imports at all) for one reason: the read-only doc-parity lint binds the
6
+ // relayed token set into `references/modes/upgrade.md`, and reading a vocabulary must never drag the
7
+ // ensure implementation — and through it the orchestration WRITER and the atomic-write core — into a
8
+ // read-only tool's import graph. Vocabulary here, behaviour in ensure-ops.mjs.
9
+
10
+ // The FIXED order the CLI runs them in — the order references/modes/upgrade.md already prescribed.
11
+ export const ENSURE_OPS = Object.freeze(['orchestration', 'gates', 'autonomy', 'scripts']);
12
+
13
+ // Tokens that assert a WRITE happened. --dry-run may never emit one of these (the CLI's contract test
14
+ // walks this set), and each has exactly one `would-` counterpart below.
15
+ export const WRITE_TOKENS = Object.freeze(['seeded', 'note-refreshed']);
16
+ export const DRY_RUN_TOKENS = Object.freeze(['would-seed', 'would-refresh-note']);
17
+
18
+ // The CLOSED outcome vocabulary. Closed at RUNTIME, not by convention: composing an outcome with a
19
+ // token outside this list throws, so an op cannot quietly invent a word the mode doc has never heard
20
+ // of and the caller has no idea how to relay. EVERY operational failure prints the ONE token
21
+ // `failed` and names its CAUSE at the head of its detail line — a specific token would read as
22
+ // vocabulary the mode doc never taught (both review backends found exactly that).
23
+ export const ENSURE_TOKENS = Object.freeze([
24
+ ...WRITE_TOKENS,
25
+ ...DRY_RUN_TOKENS,
26
+ 'already-current',
27
+ 'already-present',
28
+ 'customized-preserved',
29
+ 'malformed-preserved',
30
+ 'skipped-no-node',
31
+ 'old-adr-layout-migration-instructed',
32
+ 'failed',
33
+ ]);
34
+
35
+ // The closed CAUSE set — the word that opens a `failed` line. Closed for the same reason the tokens
36
+ // are: the doc promises "a failed line names its cause", and this is that list. `unexpected-error`
37
+ // is the honest cause for a throw nobody planned for: the failure is still named, never bare.
38
+ export const FAILURE_CAUSES = Object.freeze([
39
+ 'race-unresolved',
40
+ 'template-unreadable',
41
+ 'bundle-unreadable',
42
+ 'adr-layout-unverifiable',
43
+ 'wrong-node-kind',
44
+ 'write-refused',
45
+ 'unexpected-error',
46
+ ]);
47
+
48
+ // The subset references/modes/upgrade.md enumerates, so the agent relaying an upgrade knows every
49
+ // outcome by name. doc-parity binds each of these into that doc: a reworded doc that drops one fails
50
+ // the check instead of silently teaching an outcome set the tool no longer has. The dry-run pair is
51
+ // deliberately outside it — upgrade never runs the preview.
52
+ export const RELAYED_ENSURE_TOKENS = Object.freeze([
53
+ 'seeded',
54
+ 'note-refreshed',
55
+ 'already-current',
56
+ 'customized-preserved',
57
+ 'malformed-preserved',
58
+ 'already-present',
59
+ 'skipped-no-node',
60
+ 'old-adr-layout-migration-instructed',
61
+ 'failed',
62
+ ]);
63
+
64
+ // The four files the enforcement-script ensure seeds (AD-051's ADR cascade + the tokenizer it needs).
65
+ // Seed nothing else: the other tokenizer-era tests red beside an OLD archiver.
66
+ export const SEED_SCRIPTS = Object.freeze([
67
+ 'archive-decisions.mjs',
68
+ 'archive-decisions.test.mjs',
69
+ 'markdown-blocks.mjs',
70
+ 'markdown-blocks.test.mjs',
71
+ ]);
@@ -10,7 +10,7 @@ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
10
10
  import { join, isAbsolute } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { fail, loadConfig, CONFIG_REL } from './orchestration-config.mjs';
13
- import { matchesCoverageProducer } from './coverage-producer.mjs';
13
+ import { isCoverageProducerGate } from './coverage-producer.mjs';
14
14
  import { matchesSourceSizeGate } from './source-size-core.mjs';
15
15
 
16
16
  // The per-project declaration (strict JSON, hand-editable). cwd-relative — errors show a path the
@@ -23,33 +23,40 @@ const EXIT_MALFORMED = 5;
23
23
 
24
24
  const GATE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
25
25
  const GATE_KEYS = Object.freeze(['id', 'title', 'cmd']);
26
+ // The ONE optional gate key: the coverage-producer marker — a declared CLAIM that this gate writes
27
+ // the lcov the canonical checker reads (coverage-producer.mjs owns what the claim means and why
28
+ // recognition itself stays closed). Boolean only, and FORWARD-ONLY by design: an older kit has no
29
+ // such key and rejects a marker-carrying declaration loudly here, which is the honest failure — it
30
+ // could not honor the claim anyway.
31
+ export const LCOV_PRODUCER_KEY = 'lcovProducer';
32
+ const ALLOWED_GATE_KEYS = Object.freeze([...GATE_KEYS, LCOV_PRODUCER_KEY]);
26
33
 
27
34
  // ── declaration validation (malformed → exit 5, loud `path: reason`) ─────────────────
28
35
 
29
36
  // Validate a parsed gates.json object. Strict: only `_README` (string) + `gates` (array of
30
- // { id, title, cmd }) are allowed; unknown keys anywhere are rejected loudly — the declaration
31
- // names WHAT to check, never lanes/models/routing. Returns the validated gates array.
37
+ // { id, title, cmd, lcovProducer? }) are allowed; unknown keys anywhere are rejected loudly — the
38
+ // declaration names WHAT to check, never lanes/models/routing. Returns the validated gates array.
32
39
  export const validateDeclaration = (parsed) => {
33
40
  const reject = (reason) => {
34
41
  throw fail(EXIT_MALFORMED, `${GATES_REL}: ${reason}`);
35
42
  };
36
43
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
37
- reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd }, ...] }');
44
+ reject('must be a JSON object { "_README"?: string, "gates": [{ id, title, cmd, lcovProducer? }, ...] }');
38
45
  }
39
46
  for (const key of Object.keys(parsed)) {
40
47
  if (key !== '_README' && key !== 'gates') reject(`unknown top-level key "${key}" (allowed: _README, gates)`);
41
48
  }
42
49
  if (parsed._README !== undefined && typeof parsed._README !== 'string') reject('"_README" must be a string');
43
- if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd }');
50
+ if (!Array.isArray(parsed.gates)) reject('"gates" must be an array of { id, title, cmd, lcovProducer? }');
44
51
  const seenIds = new Set();
45
52
  parsed.gates.forEach((gate, index) => {
46
53
  const at = `gates[${index}]`;
47
54
  if (gate === null || typeof gate !== 'object' || Array.isArray(gate)) {
48
- reject(`${at}: must be an object { id, title, cmd }`);
55
+ reject(`${at}: must be an object { id, title, cmd, lcovProducer? }`);
49
56
  }
50
57
  for (const key of Object.keys(gate)) {
51
- if (!GATE_KEYS.includes(key)) {
52
- reject(`${at}: unknown key "${key}" (allowed: id, title, cmd — gates declare WHAT to check, never lane/model/routing)`);
58
+ if (!ALLOWED_GATE_KEYS.includes(key)) {
59
+ reject(`${at}: unknown key "${key}" (allowed: ${ALLOWED_GATE_KEYS.join(', ')} — gates declare WHAT to check, never lane/model/routing)`);
53
60
  }
54
61
  }
55
62
  for (const key of GATE_KEYS) {
@@ -57,6 +64,9 @@ export const validateDeclaration = (parsed) => {
57
64
  reject(`${at}: "${key}" must be a non-empty string`);
58
65
  }
59
66
  }
67
+ if (gate[LCOV_PRODUCER_KEY] !== undefined && typeof gate[LCOV_PRODUCER_KEY] !== 'boolean') {
68
+ reject(`${at}: "${LCOV_PRODUCER_KEY}" must be a boolean (only the literal true claims this gate writes the lcov the checker reads)`);
69
+ }
60
70
  if (/[\r\n]/.test(gate.cmd)) {
61
71
  reject(`${at}: "cmd" must be ONE bash command line — embedded newlines (a multi-line script) are rejected; chain with && or move the script into a file`);
62
72
  }
@@ -146,9 +156,12 @@ export const isFinalCapableDeclaration = (gates, projectDir) => {
146
156
  // index. ORDER is the whole question: a producer declared AFTER the checker writes the lcov too late,
147
157
  // so the checker reads nothing — or, worse, stale bytes an earlier run left behind — and still
148
158
  // passes. ONE home for the rule: the written-declaration defects below and the advisor's
149
- // inert-declaration item both decide through it, so they cannot drift apart.
159
+ // inert-declaration item both decide through it, so they cannot drift apart. Producer-ness itself is
160
+ // the canon's gate-level predicate (cmd closed-world OR the declared marker), so the slice is the
161
+ // only thing this function owns — and the slice is what keeps a marker on the CHECKER from
162
+ // self-pairing.
150
163
  export const coverageProducerPrecedes = (gates, checkerIndex) =>
151
- gates.slice(0, checkerIndex).some((gate) => matchesCoverageProducer(gate.cmd));
164
+ gates.slice(0, checkerIndex).some((gate) => isCoverageProducerGate(gate));
152
165
 
153
166
  // coverageDeclarationDefects(gates, projectDir) → the WRITTEN-declaration coverage rule, as a list
154
167
  // of named defects (empty = satisfied): at most ONE canonical coverage checker; if one is present
@@ -62,7 +62,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
62
62
  import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
63
63
  import { GATES_REL, validateDeclaration } from './run-gates.mjs';
64
64
  import { canonicalCheckerGates, coverageDeclarationDefects, isKitOwnedCheckerGate } from './gates-declaration.mjs';
65
- import { COVERAGE_PRODUCER_BODY, matchesCoverageProducer } from './coverage-producer.mjs';
65
+ import { COVERAGE_PRODUCER_BODY, isCoverageProducerGate } from './coverage-producer.mjs';
66
66
  import { loadConfig } from './orchestration-config.mjs';
67
67
  import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
68
68
  // The source-size practice, through its PURE READ core only (D-18): this module asks whether the
@@ -523,9 +523,12 @@ export const buildOffer = (cwd, deps = {}) => {
523
523
  // a declaration the user already wrote by hand, so the rule reads the merged picture; a
524
524
  // declaration this preview cannot read degrades to a stated note, never to a silent withhold.
525
525
  const existing = declaredGatesBestEffort(cwd, deps);
526
+ // Both sides ask the ONE gate-level predicate. The offer side can only ever be a recognized cmd
527
+ // (this preview never EMITS the marker — it offers nothing it cannot verify), but a declaration
528
+ // the user wrote by hand may carry it, and the two sides must not answer through two predicates.
526
529
  const producerPresent =
527
- scripts.entries.some((entry) => matchesCoverageProducer(entry.cmd)) ||
528
- existing.gates.some((gate) => matchesCoverageProducer(gate.cmd));
530
+ scripts.entries.some((entry) => isCoverageProducerGate(entry)) ||
531
+ existing.gates.some((gate) => isCoverageProducerGate(gate));
529
532
  const withholdCoverage = cc.candidate !== null && !producerPresent;
530
533
  const coverageNote = withholdCoverage
531
534
  ? `the coverage-check candidate was withheld: nothing would PRODUCE the lcov it reads — no offered or ` +