@sabaiway/agent-workflow-kit 5.8.0 → 5.10.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 (35) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/SKILL.md +1 -1
  3. package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
  4. package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
  5. package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
  9. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  10. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
  11. package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
  12. package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
  13. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
  14. package/capability.json +1 -1
  15. package/package.json +1 -1
  16. package/references/hooks/state-block-guard.mjs +107 -45
  17. package/references/modes/bootstrap.md +6 -2
  18. package/references/modes/set-recipe.md +8 -5
  19. package/references/modes/state-block-guard.md +39 -31
  20. package/references/modes/upgrade.md +8 -5
  21. package/references/scripts/check-docs-size-cli.test.mjs +7 -6
  22. package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
  23. package/references/scripts/check-docs-size.mjs +181 -30
  24. package/references/shared/composition-handoff.md +10 -0
  25. package/references/shared/report-footer.md +2 -2
  26. package/references/templates/agent_rules.md +1 -0
  27. package/tools/detect-backends.mjs +1 -0
  28. package/tools/doc-parity.mjs +5 -1
  29. package/tools/ensure-configs.mjs +37 -19
  30. package/tools/ensure-ops.mjs +79 -1
  31. package/tools/ensure-vocabulary.mjs +17 -3
  32. package/tools/known-footprint.mjs +10 -0
  33. package/tools/lens-region.mjs +13 -1
  34. package/tools/source-size-scope.mjs +3 -1
  35. package/tools/upgrade-runlist.mjs +1 -0
@@ -12,6 +12,11 @@
12
12
  // --check-index verify docs/ai/index.md is in sync with source frontmatter;
13
13
  // exit 1 (and print how to fix) if stale. Catches the silent
14
14
  // drift `--write-index` is supposed to prevent.
15
+ // --ensure-index the idempotent finalizer every deploy/upgrade path runs after its last
16
+ // docs/ai mutation: probe, write only when the navigator is missing or stale,
17
+ // print ONE outcome line (`ensure-index: regenerated|already-current` on
18
+ // stdout; `ensure-index: write-refused|probe-failed — <path>: …` on stderr).
19
+ // Exit 0 on either written state, 2 on a named refusal — never a stack trace.
15
20
  //
16
21
  // CLI overrides:
17
22
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
@@ -19,10 +24,11 @@
19
24
  // hook passes it so a rotation regenerates the right project's index
20
25
  // --quiet print only failures (and final summary)
21
26
 
22
- import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
23
- import { existsSync } from 'node:fs';
24
- import { dirname, resolve, relative, join, basename } from 'node:path';
27
+ import { readFile, writeFile, readdir, stat, rename, rm } from 'node:fs/promises';
28
+ import { existsSync, lstatSync } from 'node:fs';
29
+ import { dirname, resolve, relative, join, basename, sep } from 'node:path';
25
30
  import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { randomBytes } from 'node:crypto';
26
32
 
27
33
  const __filename = fileURLToPath(import.meta.url);
28
34
  const __dirname = dirname(__filename);
@@ -34,10 +40,17 @@ const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
34
40
  // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
35
41
  // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
36
42
  // an arbitrary root's index without ever touching the real repo tree.
37
- const pathsFor = (root) => ({ root, docsDir: resolve(root, 'docs/ai'), indexPath: resolve(root, 'docs/ai/index.md') });
43
+ const pathsFor = (root) => {
44
+ const base = resolve(root);
45
+ return { root: base, docsDir: resolve(base, 'docs/ai'), indexPath: resolve(base, 'docs/ai/index.md') };
46
+ };
38
47
 
39
48
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
40
49
 
50
+ // The one token every `--ensure-index` outcome line opens with — deploy/upgrade prose relays it
51
+ // verbatim and the kit's ensure op reads it, so it is a contract, not a message.
52
+ const ENSURE_INDEX_PREFIX = 'ensure-index:';
53
+
41
54
  // Project-name + footer links for the index are auto-discovered (no hardcoding):
42
55
  // project name ← package.json "name" (fallback: repo dir basename)
43
56
  // hierarchical ← every AGENTS.md / CLAUDE.md below the repo root
@@ -45,18 +58,30 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000;
45
58
  const DEFAULT_PROJECT_NAME = 'this project';
46
59
  const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
47
60
 
48
- const walkForName = async (dir, name, acc = [], depth = 0) => {
61
+ // `strict` is the finalizer's lens on the SAME walk: for a report, an unreadable subtree is fairly
62
+ // skipped, but a run that WRITES the navigator may not silently treat "could not read" as "nothing
63
+ // there" — it would publish an index missing whatever it could not see and call that success. Only
64
+ // a genuine ENOENT stays an absence; every other fs error propagates.
65
+ // Only a genuine ENOENT is an absence. A code-LESS throw (an injected reader, a wrapped client) is
66
+ // not evidence of absence either, so it propagates too — "unknown" must never read as "empty".
67
+ const rethrowUnlessAbsent = (err, strict) => {
68
+ if (strict && err?.code !== 'ENOENT') throw err;
69
+ };
70
+
71
+ const walkForName = async (dir, name, acc = [], depth = 0, strict = false, deps = {}) => {
49
72
  if (depth > 6) return acc;
73
+ const readDir = deps.readdir ?? readdir;
50
74
  let entries;
51
75
  try {
52
- entries = await readdir(dir, { withFileTypes: true });
53
- } catch {
76
+ entries = await readDir(dir, { withFileTypes: true });
77
+ } catch (err) {
78
+ rethrowUnlessAbsent(err, strict);
54
79
  return acc;
55
80
  }
56
81
  for (const entry of entries) {
57
82
  if (entry.isDirectory()) {
58
83
  if (SKIP_DIRS.has(entry.name)) continue;
59
- await walkForName(join(dir, entry.name), name, acc, depth + 1);
84
+ await walkForName(join(dir, entry.name), name, acc, depth + 1, strict, deps);
60
85
  } else if (entry.isFile() && entry.name === name) {
61
86
  acc.push(join(dir, entry.name));
62
87
  }
@@ -64,16 +89,29 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
64
89
  return acc;
65
90
  };
66
91
 
67
- export const discoverMeta = async (root = ROOT) => {
92
+ export const discoverMeta = async (root = ROOT, { strict = false, deps = {} } = {}) => {
93
+ const read = deps.readFile ?? readFile;
94
+ const readDir = deps.readdir ?? readdir;
68
95
  let projectName = basename(root);
96
+ // The READ and the PARSE are separate on purpose: an unreadable package.json is a tree this run
97
+ // could not see (strict propagates it), while a MALFORMED one is authored content — the basename
98
+ // fallback, under strict too.
99
+ let manifest = null;
69
100
  try {
70
- const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
71
- if (pkg.name) projectName = pkg.name;
72
- } catch {
73
- /* no package.json — keep dir basename */
101
+ manifest = await read(resolve(root, 'package.json'), 'utf8');
102
+ } catch (err) {
103
+ rethrowUnlessAbsent(err, strict);
74
104
  }
75
- const agentsFiles = await walkForName(root, 'AGENTS.md');
76
- const claudeFiles = await walkForName(root, 'CLAUDE.md');
105
+ if (manifest !== null) {
106
+ try {
107
+ const pkg = JSON.parse(manifest);
108
+ if (pkg.name) projectName = pkg.name;
109
+ } catch {
110
+ /* malformed package.json — keep the dir basename */
111
+ }
112
+ }
113
+ const agentsFiles = await walkForName(root, 'AGENTS.md', [], 0, strict, deps);
114
+ const claudeFiles = await walkForName(root, 'CLAUDE.md', [], 0, strict, deps);
77
115
  const rootAgents = resolve(root, 'AGENTS.md');
78
116
  const rootClaude = resolve(root, 'CLAUDE.md');
79
117
  // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
@@ -91,26 +129,28 @@ export const discoverMeta = async (root = ROOT) => {
91
129
  .map((rel) => `[\`${rel}\`](../../${rel})`);
92
130
  let onDemandLinks = [];
93
131
  try {
94
- const skillDirs = await readdir(resolve(root, '.agents/skills'), { withFileTypes: true });
132
+ const skillDirs = await readDir(resolve(root, '.agents/skills'), { withFileTypes: true });
95
133
  onDemandLinks = skillDirs
96
134
  .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
97
135
  .map((dirent) => dirent.name)
98
136
  .sort()
99
137
  .map((name) => `[\`${name}\`](../../.agents/skills/${name}/SKILL.md)`);
100
- } catch {
101
- /* no .agents/skills — omit the section */
138
+ } catch (err) {
139
+ // No .agents/skills — omit the section (under strict, only a real absence may omit it).
140
+ rethrowUnlessAbsent(err, strict);
102
141
  }
103
142
  return { projectName, hierarchicalLinks, onDemandLinks };
104
143
  };
105
144
 
106
145
  // Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
107
146
  const parseArgs = (argv) => {
108
- const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
147
+ const flags = { report: false, writeIndex: false, checkIndex: false, ensureIndex: false, quiet: false };
109
148
  const opts = { today: null, root: null };
110
149
  for (const arg of argv) {
111
150
  if (arg === '--report') flags.report = true;
112
151
  else if (arg === '--write-index') flags.writeIndex = true;
113
152
  else if (arg === '--check-index') flags.checkIndex = true;
153
+ else if (arg === '--ensure-index') flags.ensureIndex = true;
114
154
  else if (arg === '--quiet') flags.quiet = true;
115
155
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
116
156
  else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
@@ -334,9 +374,74 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
334
374
  return { fresh: expected === onDiskText, expected };
335
375
  };
336
376
 
337
- const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
377
+ // The navigator is a GENERATED artifact, so its write must land on the deployment's own file and
378
+ // nowhere else: every component of <root>/docs/ai/index.md is lstat'ed no-follow (a symlinked root,
379
+ // `docs`, `docs/ai` or leaf REFUSES — publishing through one would clobber whatever it points at),
380
+ // the body goes out through a unique exclusive-create temp renamed into place with the chain
381
+ // re-checked immediately before the rename, and the temp never survives a failure. The kit runs the
382
+ // same discipline in atomic-write.mjs; this deployment script ships dependency-free, so the
383
+ // semantics are REIMPLEMENTED here rather than imported.
384
+ export const INDEX_WRITE_REFUSED = 'INDEX_WRITE_REFUSED';
385
+ const refuse = (message) => Object.assign(new Error(message), { code: INDEX_WRITE_REFUSED });
386
+
387
+ const lstatNoFollow = (target, lstat) => {
388
+ try {
389
+ return lstat(target);
390
+ } catch (err) {
391
+ if (err && err.code === 'ENOENT') return null;
392
+ throw err;
393
+ }
394
+ };
395
+
396
+ // The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
397
+ // in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
398
+ const assertContainedNoSymlink = (root, target, lstat) => {
399
+ const rel = relative(root, target);
400
+ if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
401
+ throw refuse(`${root} is a symlink — refusing to write the navigator through it`);
402
+ }
403
+ rel.split(sep).filter(Boolean).reduce((walked, part) => {
404
+ const current = join(walked, part);
405
+ if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
406
+ throw refuse(`${current} is a symlink — refusing to write the navigator through it`);
407
+ }
408
+ return current;
409
+ }, root);
410
+ };
411
+
412
+ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PATH, deps = {} } = {}) => {
413
+ const lstat = deps.lstat ?? lstatSync;
414
+ const write = deps.writeFile ?? writeFile;
415
+ const publish = deps.rename ?? rename;
416
+ const remove = deps.rm ?? rm;
417
+ const uniqueSuffix = deps.rand ?? (() => randomBytes(6).toString('hex'));
338
418
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
339
- await writeFile(indexPath, body, 'utf8');
419
+ assertContainedNoSymlink(root, indexPath, lstat);
420
+ const tmp = `${indexPath}.${uniqueSuffix()}.tmp`;
421
+ assertContainedNoSymlink(root, tmp, lstat);
422
+ const discardTemp = async (err) => {
423
+ try {
424
+ await remove(tmp, { force: true });
425
+ } catch (cleanupErr) {
426
+ throw refuse(`${err.message} — and its temp file could not be removed, delete it by hand: ${tmp} (${cleanupErr.message})`);
427
+ }
428
+ throw err;
429
+ };
430
+ try {
431
+ await write(tmp, body, { encoding: 'utf8', flag: 'wx' });
432
+ } catch (err) {
433
+ // EEXIST means the name is SOMEONE ELSE's file: exclusive-create refused, this run wrote
434
+ // nothing, and removing it would delete a file we never made. Every other failure can leave a
435
+ // partial temp behind, and that one is ours to discard.
436
+ if (err && err.code === 'EEXIST') throw err;
437
+ await discardTemp(err);
438
+ }
439
+ try {
440
+ assertContainedNoSymlink(root, indexPath, lstat);
441
+ await publish(tmp, indexPath);
442
+ } catch (err) {
443
+ await discardTemp(err);
444
+ }
340
445
  };
341
446
 
342
447
  // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
@@ -344,15 +449,46 @@ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
344
449
  // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
345
450
  // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
346
451
  // index path + row count. No second index implementation exists.
347
- export const regenerateIndex = async (root, todayStr = null) => {
348
- const { docsDir, indexPath } = pathsFor(root);
452
+ export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
453
+ const paths = pathsFor(root);
349
454
  const today = computeToday(todayStr);
350
- const files = (await walkMarkdownFiles(docsDir)).sort();
351
- const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
455
+ const files = (await walkMarkdownFiles(paths.docsDir)).sort();
456
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
352
457
  const rows = inspected.map(formatRow);
353
- const meta = await discoverMeta(root);
354
- await writeIndex(rows, today, meta, indexPath);
355
- return { indexPath, files: rows.length };
458
+ const meta = await discoverMeta(paths.root);
459
+ await writeIndex(rows, today, meta, { root: paths.root, indexPath: paths.indexPath, deps });
460
+ return { indexPath: paths.indexPath, files: rows.length };
461
+ };
462
+
463
+ // The finalizer promises its caller EXACTLY ONE outcome line, so every step it owns — the walk, the
464
+ // metadata discovery, the freshness read and the write — runs inside one classified error path: an
465
+ // unreadable docs/ai is a NAMED refusal, never a stack trace. The containment guard runs BEFORE the
466
+ // freshness read for the same reason `already-present` needs a kind probe: a symlinked navigator
467
+ // whose target happens to hold current bytes would otherwise report `already-current` over a file
468
+ // this mode refuses to write through — an exit 0 proving nothing about the deployment's own file.
469
+ const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
470
+ const lstat = deps.lstat ?? lstatSync;
471
+ const read = deps.readFile ?? readFile;
472
+ const line = (token) => `${ENSURE_INDEX_PREFIX} ${token} — ${relative(root, indexPath)}`;
473
+ // The two refusals name STAGES, not error codes: once the write has been entered, ANY failure —
474
+ // a containment refusal, EIO, EACCES — is a write refusal, because that is what the reader has to
475
+ // act on. A raw fs error reported as a failed PROBE would send them to the wrong half of the run.
476
+ let writing = false;
477
+ try {
478
+ assertContainedNoSymlink(root, indexPath, lstat);
479
+ const files = (await walkMarkdownFiles(docsDir)).sort();
480
+ const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
481
+ const rows = inspected.map(formatRow);
482
+ const meta = await discoverMeta(root, { strict: true, deps });
483
+ const onDisk = existsSync(indexPath) ? await read(indexPath, 'utf8') : null;
484
+ if (checkIndexFreshness(rows, onDisk, meta).fresh) return { code: 0, out: line('already-current') };
485
+ writing = true;
486
+ await writeIndex(rows, today, meta, { root, indexPath, deps });
487
+ return { code: 0, out: line('regenerated') };
488
+ } catch (err) {
489
+ const cause = writing || err?.code === INDEX_WRITE_REFUSED ? 'write-refused' : 'probe-failed';
490
+ return { code: 2, err: `${ENSURE_INDEX_PREFIX} ${cause} — ${indexPath}: ${err.message}` };
491
+ }
356
492
  };
357
493
 
358
494
  // The return-code entry point (no process.argv / process.exit / console inside): argv[] →
@@ -370,7 +506,7 @@ export const runCli = async (argv, deps = {}) => {
370
506
 
371
507
  const { flags, opts, help, error } = parseArgs(argv);
372
508
  if (help) {
373
- log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
509
+ log('Usage: check-docs-size.mjs [--report|--write-index|--check-index|--ensure-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
374
510
  return result(0);
375
511
  }
376
512
  if (error) {
@@ -379,6 +515,16 @@ export const runCli = async (argv, deps = {}) => {
379
515
  }
380
516
  const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
381
517
  const today = computeToday(opts.today);
518
+
519
+ // The finalizer owns its whole pipeline (above), so it returns BEFORE the shared walk: a tree the
520
+ // walk would throw on must still close with one outcome line.
521
+ if (flags.ensureIndex) {
522
+ const { code, out, err } = await runEnsureIndex({ root, docsDir, indexPath, today, deps });
523
+ if (out) log(out);
524
+ if (err) logError(err);
525
+ return result(code);
526
+ }
527
+
382
528
  const files = (await walkMarkdownFiles(docsDir)).sort();
383
529
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
384
530
  const rows = inspected.map(formatRow);
@@ -386,7 +532,12 @@ export const runCli = async (argv, deps = {}) => {
386
532
  const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
387
533
 
388
534
  if (flags.writeIndex) {
389
- await writeIndex(rows, today, meta, indexPath);
535
+ try {
536
+ await writeIndex(rows, today, meta, { root, indexPath, deps });
537
+ } catch (err) {
538
+ logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
539
+ return result(2);
540
+ }
390
541
  log(`Wrote ${relative(root, indexPath)}`);
391
542
  const after = await stat(indexPath);
392
543
  if (after.size === 0) {
@@ -59,3 +59,13 @@ a body matching the current canon is reported *already current*; a body matching
59
59
  is refreshed; a custom body is preserved + noted; an absent section is a stated note (never an
60
60
  insert); an over-cap refresh is refused; and an unreadable bundled template canon is its own
61
61
  loud STOP naming the kit reinstall command.
62
+
63
+ **Navigator finalizer (runs in BOTH paths, AFTER the lens reconcile above).** `docs/ai/index.md` is
64
+ a GENERATED artifact the entry point declares always-loaded, and the reconcile above may have just
65
+ rewritten a `docs/ai` file — so the finalizer runs at the LAST `docs/ai` mutation of the deploy,
66
+ before the stamp and the report. ONE command:
67
+ `node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<project>`.
68
+ Relay its one outcome line (*regenerated* / *already current*); a `write-refused` (the write) or
69
+ `probe-failed` (the tree could not be read) line names the offending path and is a loud STOP,
70
+ never a note. It is idempotent, so an earlier run in the
71
+ delegated path (the substrate's own fill step) is never a reason to skip it here.
@@ -51,8 +51,8 @@ the project's internal `docs/ai` structure version, the stamp filename, or the i
51
51
  vocabulary — that number is inert here and only confuses; it belongs to *Version disclosure* (below).
52
52
  Frame the success itself plainly, in the **user's conversational language** (never hardcode a phrase):
53
53
  - a **zero-diff no-op `upgrade`** (step 4) → **settings already current — no update is required**
54
- (illustrative tone for a Russian-speaking user, an example of the meaning, not a literal string to
55
- embed: *«Настройки уже актуальны — обновление не требуется»*);
54
+ (that is the MEANING to convey, not a literal string to embed say it in the user's conversational
55
+ language, in your own words);
56
56
  - a **fresh `bootstrap`** → its normal "deployed and ready" success, minus the number.
57
57
 
58
58
  **Version block — the installed package versions, fed from `--json`** (the `docs/ai` structure version
@@ -71,6 +71,7 @@ Apply this as part of §2 before any user-facing summary:
71
71
  - **No condescension, no filler.** Own a miss plainly and fix it in the same message.
72
72
  - **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
73
73
  - **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.
74
+ - **The closing state block answers three DIFFERENT questions.** Close a user-facing message with three labelled slots — *now* · *what I need from you* · *what's next*. The slot LABELS stay ENGLISH — an English label is what lets a state-block checker FIND the block and its slots at all; everything written INTO a slot is in the project's dialogue language; when that language is not English, the checker's English phrase sets do not judge those values. **Now** = the state at this instant: what is RUNNING, or what the work is stopped on. It is **never a report of finished work** — what you completed goes in the message BODY, above the block. **From you** = the real unblocker, named; a turn that is ENDING always has one. **Next** = what follows. A *now* slot that opens with what was completed buries the one fact the reader opened the message for, and the three slots collapse into one restatement.
74
75
 
75
76
  ### 2.6. Planning, review & process-fidelity invariants
76
77
  Apply these when authoring a plan, reviewing, folding a finding, or editing code — the layer read **before any code change**. (Full canon: the project's planning / workflow-methodology + orchestration canon. This section is rendered from that canon and refreshed on upgrade; a custom edit is preserved verbatim, but flagged.)
@@ -141,6 +141,7 @@ const RAW_BACKENDS = [
141
141
  ],
142
142
  receipt: "side effect — a successful review appends one JSON receipt line to <git dir>/agent-workflow-review-receipts.jsonl (AW_REVIEW_RECEIPTS overrides; plan/diff outside a git tree: warn + skip unless overridden): fingerprint = sha256 over the canonical uncommitted-state payload (staged diff + unstaged diff + untracked-not-ignored contents — the review-payload domain; never-committable untracked paths — character/block devices, FIFOs, sockets — are excluded from the domain entirely, untracked symlinks/directories ride as name-only notes) in code mode, the artifact-file sha256 in plan/diff mode; verdict recorded verbatim from the mandated '### Verdict' section (SHIP / SHIP WITH NITS / REWORK); grounded = whether a NON-EMPTY --facts payload was supplied (code mode refuses pre-spend without one — no run, no receipt — unless --ungrounded/AGY_PROBE=1; in plan/diff an empty payload records grounded:false — fail-closed, the state gate rejects it), factsHash = sha256 of the facts payload; a continuation receipt is fresh:false (informational-only — it cannot attest the folded tree); probe = whether the run relaxed the quality guards (AGY_PROBE=1), written on EVERY receipt so it self-declares — the kit's review-state gate rejects a probe-marked receipt (a probe review never attests) and equally rejects an unmarked one (silence is not a declaration); posture = the ACTUAL run posture {model} (agy has no tier), written on EVERY receipt (D5) — the gate rejects a receipt with an absent/invalid posture (a pre-D5 wrapper minted it; re-run the review), one stderr banner line states the same posture, an ATTESTING review with AGY_MODEL explicitly emptied refuses pre-spend, and a model string carrying control bytes refuses pre-spend in every mode; delivery = how the change set REACHED the model, currently emitted as 'inline' (the whole set rode one prompt — proven by construction) or 'fed' (a chunked feed whose per-part echo proof verified); REQUIRED on every agy code receipt and its ABSENCE is what stops a pre-fed-lane receipt attesting, while the gate accepts any well-formed declaration rather than a particular value; absent by construction on plan/diff/continuation receipts, which carry no change set; a run whose output carries NO recognized '### Verdict' section — empty output included — exits 4 with NO receipt (D4: a FAILED review to RE-RUN, never a fatal session error); when the dispatch nonce seam is supplied — the AW_REVIEW_NONCE environment value or its plain-argument equivalent --nonce <n> (one seam: the flag assigns the same value; supplying both with different values refuses pre-spend) — under the safe grammar [A-Za-z0-9._-]{1,64} (anything else refuses pre-spend), the wrapper first mints the finding MANIFEST {schema, backend, nonce, fingerprint, findings} beside the receipts file (agent-workflow-finding-manifest-<backend>-<nonce>.json; atomic, no-clobber — a byte-identical rewrite is an idempotent no-op, different bytes refuse loudly) ORDERED before the receipt append — a failed manifest write EXCLUDES the receipt append, so a nonce-supplied dispatch can never land a receipt without its readable manifest; a nonce-less invocation adds NO nonce field and mints NO finding manifest (the existing wrapperVersion field still changes with each bridge release); a write failure warns, never fails the review",
143
143
  notes: [
144
+ 'transport: every review dispatch drives the CLI in --output-format json (plus --disable-slash-commands) and the returned envelope is parsed in node (bin/agy-envelope.mjs) — the operator-facing invocations and flags above do NOT change, and on a ZERO exit the wrapper still PRINTS the review text, never JSON. A missing or unreadable envelope on a zero exit is a loud failure with NO receipt, never a downgraded verdict and never a fallback to raw-stdout parsing; a non-zero CLI exit keeps its own code and message, and publishes the captured stdout unchanged from the SINGLE dispatch or the FINAL fed turn (which may therefore be a JSON or partial payload — the envelope is parsed only on a zero exit); an INTERMEDIATE feed turn is the exception, its output stays private (Invariant E) and its failure prints only a named error. Enforced by a PRE-SPEND capability probe, not a version floor: agy --help must advertise --output-format and --disable-slash-commands, node must be >= 22, and bin/agy-envelope.mjs must be present — otherwise the review refuses before any run is spent and names the missing capability',
144
145
  'pre-dispatch host-diff: before the FIRST dispatch of this bridge, diff its declared networkHosts against the live sandbox allow-list — a missing host is surfaced to the maintainer BEFORE dispatching, never fired into a known prompt',
145
146
  'the review posture banner appends a banner-only timeout=<duration> field — exactly the duration agy-run hands to timeout(1); the hard-timeout preflight fails CLOSED when no timeout/gtimeout binary exists (the wrapper refuses by name before any CLI run, so an uncapped review run can no longer happen), and the field never enters the receipt posture or the D5 banner↔receipt parity',
146
147
  'quote the posture banner verbatim when labeling this dispatch — the banner is the machine-stated posture; a prose re-type drifts',
@@ -56,7 +56,7 @@ import { COVERAGE_PRODUCER_BODY } from './coverage-producer.mjs';
56
56
  // tool renames or drops must fail here rather than leave the doc teaching a vocabulary nobody emits.
57
57
  // Imported from the VOCABULARY leaf, never from the ops: a read-only lint must not pull the ensure
58
58
  // implementation — and through it the orchestration writer — into its import graph.
59
- import { RELAYED_ENSURE_TOKENS } from './ensure-vocabulary.mjs';
59
+ import { RELAYED_ENSURE_TOKENS, RELAYED_FAILURE_CAUSES } from './ensure-vocabulary.mjs';
60
60
 
61
61
  const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
62
62
 
@@ -138,6 +138,10 @@ export const BINDINGS = Object.freeze([
138
138
  // free. Now one command performs them and the doc enumerates its tokens — backticked, so a bare
139
139
  // word in a sentence cannot pass for the pinned outcome.
140
140
  ...RELAYED_ENSURE_TOKENS.map((token) => valueBinding(`ensure-outcome:${token}`, token, `\`${token}\``, [UPGRADE_DOC])),
141
+ // And the CAUSE half of the same promise (index-navigator hotfix / D9): the doc says a `failed`
142
+ // line OPENS with its cause, so every word that can open one is pinned into the doc that relays it
143
+ // — otherwise a new cause ships with no doc anyone could have read. Backticked, same reason.
144
+ ...RELAYED_FAILURE_CAUSES.map((cause) => valueBinding(`ensure-cause:${cause}`, cause, `\`${cause}\``, [UPGRADE_DOC])),
141
145
  // The "the tool knows and does not say" contract: a clean-tree PASS must still name a latent arm.
142
146
  // It was a prose-only bar a doc could silently drop, so it is pinned to the live string the tool
143
147
  // actually emits — a reworded doc dropping the notice fails this pin plus the gate.
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
- // ensure-configs.mjs — ONE runnable command for the four stamp-independent upgrade ensures:
2
+ // ensure-configs.mjs — ONE runnable command for the five stamp-independent upgrade ensures:
3
3
  //
4
4
  // orchestration docs/ai/orchestration.json seed, or refresh a still-canonical onboarding note
5
5
  // gates docs/ai/gates.json seed-if-missing (an existing declaration is authored content)
6
6
  // autonomy docs/ai/autonomy.json seed-if-missing (same)
7
7
  // scripts scripts/<ADR enforcement> seed-if-missing, ADR-layout detect FIRST
8
+ // index docs/ai/index.md regenerate-if-missing-or-stale (a GENERATED artifact)
8
9
  //
9
10
  // 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.
11
+ // of five is deliberate: five independent runs would be five chances to skip one, and the mode doc now
12
+ // has a single invocation point whose five outcome lines it relays.
12
13
  //
13
14
  // The contract (pinned by this module's tests):
14
15
  // • --reconcile is REQUIRED. A bare run is a usage error, so nothing writes by accident.
@@ -16,7 +17,7 @@
16
17
  // • The ops run in a FIXED order and one op's failure NEVER skips the rest: every op reports its own
17
18
  // token, and the exit is non-zero when any of them failed.
18
19
  // • 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
+ // with the gate's own message rather than five copies of it.
20
21
  //
21
22
  // Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
22
23
  // Exit codes: 0 every op fine · 1 an op failed, or the deployment gate stopped the run · 2 usage.
@@ -39,20 +40,25 @@ const EXIT_USAGE = 2;
39
40
  const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
40
41
 
41
42
  const EMPTY_CWD = '--cwd needs a path argument (an empty value would silently mean the current directory)';
43
+ const EMPTY_ONLY = `--only needs one operation name (${ENSURE_OPS.join(' | ')}) — an empty value would silently widen the run`;
44
+ const REPEATED_ONLY = '--only was passed more than once — this selector names exactly ONE operation';
45
+ const unknownOp = (value) => `--only ${value}: no such operation (${ENSURE_OPS.join(' | ')}) — nothing was run`;
42
46
 
43
- const HELP = `ensure-configs — the four stamp-independent upgrade ensures, as ONE command.
47
+ const HELP = `ensure-configs — the five stamp-independent upgrade ensures, as ONE command.
44
48
 
45
49
  Usage:
46
- node ensure-configs.mjs --reconcile [--dry-run] [--cwd <project>]
50
+ node ensure-configs.mjs --reconcile [--dry-run] [--only <op>] [--cwd <project>]
47
51
 
48
- --reconcile required — run the four ensures (orchestration, gates, autonomy, scripts)
52
+ --reconcile required — run the five ensures (${ENSURE_OPS.join(', ')})
49
53
  --dry-run report what each ensure WOULD do; write nothing
54
+ --only <op> run EXACTLY ONE of them (an unknown, missing or repeated value is a usage error)
50
55
  --cwd <dir> the target project (default: the current directory)
51
56
  --help, -h this help
52
57
 
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
58
+ Every SEED is CREATE-ONLY: an existing file is preserved byte-for-byte, never clobbered and never
59
+ refreshed in place. Two ops refresh instead: the orchestration onboarding note, only while it still
60
+ matches a canonical the kit shipped (your own wording is preserved verbatim), and the navigator
61
+ index — a GENERATED artifact, regenerated whenever it is missing or stale. The enforcement-script
56
62
  ensure detects an older ADR-store layout FIRST and instructs the opt-in migration instead of seeding.
57
63
 
58
64
  Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no deployment here; 2 usage.`;
@@ -60,13 +66,24 @@ Exit codes: 0 every op fine; 1 an op failed (its line says so) or there is no de
60
66
  // argv → { reconcile, dryRun, cwd, help }. Order-independent; an unknown flag or a missing --cwd
61
67
  // value is a usage error, never a silently-ignored argument.
62
68
  export const parseArgs = (argv) => {
63
- const out = { reconcile: false, dryRun: false, cwd: undefined, help: false };
69
+ const out = { reconcile: false, dryRun: false, cwd: undefined, only: undefined, help: false };
64
70
  for (let i = 0; i < argv.length; i += 1) {
65
71
  const a = argv[i];
66
72
  if (a === '--help' || a === '-h') out.help = true;
67
73
  else if (a === '--reconcile') out.reconcile = true;
68
74
  else if (a === '--dry-run') out.dryRun = true;
69
- else if (a === '--cwd') {
75
+ else if (a === '--only' || a.startsWith('--only=')) {
76
+ // A selector that cannot be honoured EXACTLY as asked is a usage error, never a wider run:
77
+ // narrowing is the whole point, so a missing value, a repeat, or an op that does not exist
78
+ // must stop the run before any op writes.
79
+ if (out.only !== undefined) throw fail(EXIT_USAGE, REPEATED_ONLY);
80
+ const inline = a.startsWith('--only=');
81
+ const value = inline ? a.slice('--only='.length) : argv[i + 1];
82
+ if (value === undefined || value === '' || (!inline && value.startsWith('-'))) throw fail(EXIT_USAGE, EMPTY_ONLY);
83
+ if (!ENSURE_OPS.includes(value)) throw fail(EXIT_USAGE, unknownOp(value));
84
+ out.only = value;
85
+ if (!inline) i += 1;
86
+ } else if (a === '--cwd') {
70
87
  // An EMPTY value resolves to the ambient cwd — a writing CLI would then act on a different
71
88
  // project than the caller named, silently. Both spellings refuse it.
72
89
  const next = argv[i + 1];
@@ -88,8 +105,8 @@ export const parseArgs = (argv) => {
88
105
  // Run every op in ENSURE_OPS order. A throw from one op becomes THAT op's failed outcome — the
89
106
  // remaining ops still run, because a project missing its gate declaration should not also be left
90
107
  // 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) => {
108
+ export const runEnsures = ({ cwd, kitRoot, dryRun, deps, only }) =>
109
+ (only ? [only] : ENSURE_OPS).map((op) => {
93
110
  try {
94
111
  return ENSURE_IMPLEMENTATIONS[op]({ cwd, kitRoot, dryRun, deps });
95
112
  } catch (err) {
@@ -97,10 +114,11 @@ export const runEnsures = ({ cwd, kitRoot, dryRun, deps }) =>
97
114
  }
98
115
  });
99
116
 
100
- const render = (outcomes, dryRun) => {
117
+ const render = (outcomes, dryRun, only) => {
101
118
  // The banner names the tool + the flag it ran under (both machine tokens the L2 rule exempts);
102
119
  // 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)'];
120
+ const scope = only ? `, --only ${only}` : '';
121
+ const lines = [dryRun ? `ensure-configs (--reconcile${scope}, dry run — nothing written)` : `ensure-configs (--reconcile${scope})`];
104
122
  for (const o of outcomes) {
105
123
  lines.push(` ${o.op}: ${o.token}`);
106
124
  for (const detail of o.lines) lines.push(` ${detail}`);
@@ -120,12 +138,12 @@ export const main = (argv = [], ctx = {}) => {
120
138
  const cwd = resolve(args.cwd ?? ctx.cwd ?? process.cwd());
121
139
  const deps = ctx.deps ?? {};
122
140
  // 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.
141
+ // reconcile, and five identical STOPs would read as five separate problems.
124
142
  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 });
143
+ const outcomes = runEnsures({ cwd, kitRoot: ctx.kitRoot ?? KIT_ROOT, dryRun: args.dryRun, deps, only: args.only });
126
144
  return {
127
145
  code: outcomes.some((o) => o.failed) ? EXIT_FAILED : EXIT_OK,
128
- stdout: render(outcomes, args.dryRun),
146
+ stdout: render(outcomes, args.dryRun, args.only),
129
147
  stderr: '',
130
148
  };
131
149
  } catch (err) {
@@ -1,4 +1,4 @@
1
- // ensure-ops.mjs — the FOUR upgrade ensure operations, one function each, behind one shared outcome
1
+ // ensure-ops.mjs — the FIVE upgrade ensure operations, one function each, behind one shared outcome
2
2
  // shape. The CLI that orders and runs them is ensure-configs.mjs; this module owns what each ensure
3
3
  // DOES and, more importantly, what it is allowed to CLAIM.
4
4
  //
@@ -23,6 +23,7 @@
23
23
  // Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*). No side effects on import.
24
24
 
25
25
  import { readFileSync, lstatSync } from 'node:fs';
26
+ import { spawnSync } from 'node:child_process';
26
27
  import { join } from 'node:path';
27
28
  import { CANON_README, CONFIG_REL, SEED_CONFIG, loadConfig, normalizeCanonical, refreshReadme } from './orchestration-config.mjs';
28
29
  import { seedConfig, writeConfig } from './orchestration-write.mjs';
@@ -41,6 +42,7 @@ export {
41
42
  DRY_RUN_TOKENS,
42
43
  FAILURE_CAUSES,
43
44
  RELAYED_ENSURE_TOKENS,
45
+ RELAYED_FAILURE_CAUSES,
44
46
  SEED_SCRIPTS,
45
47
  WRITE_TOKENS,
46
48
  } from './ensure-vocabulary.mjs';
@@ -275,10 +277,86 @@ export const ensureScripts = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
275
277
  return outcome('scripts', anyCreated ? 'seeded' : 'already-present', lines, false);
276
278
  };
277
279
 
280
+ // ── 5. docs/ai/index.md — the GENERATED navigator, regenerated when missing or stale ───────────────
281
+
282
+ // The only ensure whose target is generated rather than authored: there is nothing to preserve, and
283
+ // nothing to seed from either — the bundled generator IS the writer, driven through its idempotent
284
+ // finalizer mode. Sync-over-async by the same precedent the ADR rotator uses (spawnSync the CLI):
285
+ // the ensure framework is synchronous, and a second index implementation here would be the drift
286
+ // the one-generator rule exists to prevent.
287
+ const INDEX_REL = 'docs/ai/index.md';
288
+ const GENERATOR_PATH = ['references', 'scripts', 'check-docs-size.mjs'];
289
+ const ENSURE_INDEX_OUTCOME = /^ensure-index: (regenerated|already-current)\b/m;
290
+ const ENSURE_INDEX_REFUSAL = /^ensure-index: (write-refused|probe-failed)\b/m;
291
+ // The probe has no machine line, so its two ANSWERS are matched against the canonical sentences it
292
+ // composes — anchored, not a loose substring: a failure that merely CONTAINS "is stale" (a path, an
293
+ // error quoting the checker's own advice) would otherwise pass for a stale verdict and fail OPEN.
294
+ const PROBE_FRESH = /^\[check-docs-size\] OK — .+ is in sync with source frontmatter\./m;
295
+ const PROBE_STALE = /^\[check-docs-size\] FAIL: .+ is stale \(out of sync with source frontmatter\)\./m;
296
+ // Only the LAUNCH and the pre-write probe are provably zero-write. Once the generator has run, a
297
+ // failure must say that a write may already have landed — the reader's next step depends on it.
298
+ const MAY_HAVE_WRITTEN = 'the navigator may already have been written — re-read it before re-running';
299
+
300
+ const describeExit = (result) => (result.signal ? `on signal ${result.signal}` : `with code ${result.status}`);
301
+ const bothStreams = (result) => `${String(result.stdout ?? '')}${String(result.stderr ?? '')}`.trim();
302
+
303
+ export const ensureIndex = ({ cwd, kitRoot, dryRun = false, deps = {} }) => {
304
+ const lstat = deps.lstat ?? lstatSync;
305
+ const spawn = deps.spawnSync ?? spawnSync;
306
+ const generator = join(kitRoot, ...GENERATOR_PATH);
307
+ const drive = (mode) => spawn(process.execPath, [generator, mode, `--root=${cwd}`], { encoding: 'utf8' });
308
+ const unlaunchable = (result) =>
309
+ loud('index', 'generator-unlaunchable', `${INDEX_REL}: the bundled generator could not be started, so nothing was probed or written — reinstall the kit. ${causeOf(result.error)}`);
310
+
311
+ const probe = probeSeedTarget(join(cwd, INDEX_REL), lstat);
312
+ if (probe.wrongKind) {
313
+ return loud('index', 'wrong-node-kind', `${INDEX_REL}: exists but is ${probe.wrongKind} — the navigator is a generated file and this is not one; nothing was read or written, resolve it by hand and re-run`);
314
+ }
315
+
316
+ if (dryRun) {
317
+ const check = drive('--check-index');
318
+ if (check.error) return unlaunchable(check);
319
+ const out = bothStreams(check);
320
+ if (check.status === 0 && PROBE_FRESH.test(out)) return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing would be written`);
321
+ if (check.status === 1 && PROBE_STALE.test(out)) return ok('index', 'would-regenerate', `${INDEX_REL}: missing or stale — would be written from the source frontmatter`);
322
+ return loud('index', 'index-probe-failed', `${INDEX_REL}: the freshness probe answered neither fresh nor stale (exited ${describeExit(check)}), so nothing was written. ${out}`);
323
+ }
324
+
325
+ const run = drive('--ensure-index');
326
+ if (run.error) return unlaunchable(run);
327
+ const outcome = String(run.stdout ?? '').match(ENSURE_INDEX_OUTCOME);
328
+ const refusal = String(run.stderr ?? '').match(ENSURE_INDEX_REFUSAL);
329
+ if (run.status === 2 && refusal) {
330
+ // The generator's OWN closed refusals: a write it would not publish, and a probe that could not
331
+ // answer. Each keeps its identity here rather than collapsing into "the generator failed".
332
+ const cause = refusal[1] === 'write-refused' ? 'write-refused' : 'index-probe-failed';
333
+ return loud('index', cause, `${INDEX_REL}: ${bothStreams(run)}`);
334
+ }
335
+ if (run.status !== 0 || !outcome) {
336
+ return loud('index', 'generator-failed', `${INDEX_REL}: the generator exited ${describeExit(run)} without a recognized outcome line — ${MAY_HAVE_WRITTEN}. ${bothStreams(run)}`);
337
+ }
338
+ if (outcome[1] === 'already-current') return ok('index', 'already-current', `${INDEX_REL}: in sync with the source frontmatter — nothing written`);
339
+
340
+ // A claimed regeneration is not a verified one: re-probe, so `regenerated` names a state this run
341
+ // PROVED rather than a line the generator printed. The verdict is read from the probe's own two
342
+ // ANSWERS — an exit code alone would turn a probe that merely FAILED (exit 1 on an unreadable
343
+ // tree) into a false "still stale after the write".
344
+ const verify = drive('--check-index');
345
+ const verdict = verify.error ? '' : bothStreams(verify);
346
+ if (!verify.error && verify.status === 0 && PROBE_FRESH.test(verdict)) {
347
+ return ok('index', 'regenerated', `${INDEX_REL}: written from the source frontmatter — the navigator is in sync again`);
348
+ }
349
+ if (!verify.error && verify.status === 1 && PROBE_STALE.test(verdict)) {
350
+ return loud('index', 'index-stale-after-write', `${INDEX_REL}: the generator reported a regeneration, but the re-probe still reads the navigator as missing or stale — ${MAY_HAVE_WRITTEN}. ${verdict}`);
351
+ }
352
+ return loud('index', 'index-probe-failed', `${INDEX_REL}: the generator reported a regeneration, but the verifying probe answered neither fresh nor stale — ${MAY_HAVE_WRITTEN}. ${verify.error ? causeOf(verify.error) : verdict}`);
353
+ };
354
+
278
355
  // The op table the CLI walks — name → implementation, in ENSURE_OPS order.
279
356
  export const ENSURE_IMPLEMENTATIONS = Object.freeze({
280
357
  orchestration: ensureOrchestration,
281
358
  gates: ensureGates,
282
359
  autonomy: ensureAutonomy,
283
360
  scripts: ensureScripts,
361
+ index: ensureIndex,
284
362
  });