@sabaiway/agent-workflow-kit 5.10.0 → 5.11.1

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 (74) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +2 -2
  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 +13 -2
  7. package/references/hooks/state-block-guard.mjs +14 -2
  8. package/references/modes/commit-guard.md +11 -8
  9. package/references/modes/core-evidence.md +1 -1
  10. package/references/modes/dispatch.md +32 -10
  11. package/references/modes/worktrees.md +47 -3
  12. package/references/scripts/archive-changelog.mjs +14 -3
  13. package/references/scripts/archive-decisions.mjs +14 -3
  14. package/references/scripts/archive-issues.mjs +14 -3
  15. package/references/scripts/check-docs-size.mjs +14 -3
  16. package/references/scripts/migrate-gates.mjs +13 -2
  17. package/tools/ack-write.mjs +3 -3
  18. package/tools/advisor-matrix.mjs +165 -0
  19. package/tools/autonomy-doctor.mjs +2 -3
  20. package/tools/bridge-settings.mjs +2 -3
  21. package/tools/cheap-agents.mjs +3 -3
  22. package/tools/commands.mjs +4 -5
  23. package/tools/commit-guard.mjs +77 -20
  24. package/tools/core-evidence.mjs +12 -3
  25. package/tools/coverage-check.mjs +2 -3
  26. package/tools/delegation.mjs +2 -3
  27. package/tools/detect-backends.mjs +2 -3
  28. package/tools/dispatch-advisor.mjs +323 -0
  29. package/tools/dispatch.mjs +174 -109
  30. package/tools/doc-parity.mjs +69 -16
  31. package/tools/family-registry.mjs +3 -3
  32. package/tools/flow-adoption-mint.mjs +70 -0
  33. package/tools/flow-append.mjs +309 -0
  34. package/tools/flow-chain-state.mjs +91 -0
  35. package/tools/flow-check-cores.mjs +35 -6
  36. package/tools/flow-check-rungs.mjs +20 -2
  37. package/tools/flow-check.mjs +22 -8
  38. package/tools/flow-delta-proof.mjs +307 -0
  39. package/tools/flow-record.mjs +1 -1
  40. package/tools/flow-store-read.mjs +3 -3
  41. package/tools/flow-store.mjs +35 -812
  42. package/tools/flow-subset-budget.mjs +81 -0
  43. package/tools/flow-writer.mjs +3 -3
  44. package/tools/gate-hook.mjs +3 -3
  45. package/tools/gates-init.mjs +3 -3
  46. package/tools/grounding.mjs +2 -3
  47. package/tools/hide-footprint.mjs +2 -3
  48. package/tools/inject-methodology.mjs +2 -3
  49. package/tools/lens-region.mjs +2 -3
  50. package/tools/manifest/validate.mjs +2 -3
  51. package/tools/migrate-adr-store.mjs +3 -3
  52. package/tools/observation-builder.mjs +123 -0
  53. package/tools/path-inventory.mjs +2 -3
  54. package/tools/procedures.mjs +3 -3
  55. package/tools/receipt-deadline.mjs +2 -3
  56. package/tools/recipes.mjs +2 -3
  57. package/tools/recommendations.mjs +3 -3
  58. package/tools/release-scan.mjs +2 -3
  59. package/tools/repo-search.mjs +2 -3
  60. package/tools/review-state.mjs +3 -3
  61. package/tools/run-gates.mjs +2 -3
  62. package/tools/sandbox-masks.mjs +3 -3
  63. package/tools/satellite-locator.mjs +179 -0
  64. package/tools/set-autonomy.mjs +2 -3
  65. package/tools/set-flow.mjs +3 -3
  66. package/tools/set-recipe.mjs +2 -3
  67. package/tools/setup-backends.mjs +3 -3
  68. package/tools/store-append.mjs +2 -2
  69. package/tools/uninstall.mjs +2 -3
  70. package/tools/velocity-profile.mjs +3 -3
  71. package/tools/worktree-handoff-return.mjs +369 -0
  72. package/tools/worktree-prompt.mjs +190 -0
  73. package/tools/worktrees-record.mjs +171 -0
  74. package/tools/worktrees.mjs +311 -300
@@ -32,9 +32,9 @@
32
32
  // --warm-days=N (default 30)
33
33
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
34
34
 
35
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'node:fs';
35
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, realpathSync } from 'node:fs';
36
36
  import { dirname, resolve, basename } from 'node:path';
37
- import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { fileURLToPath } from 'node:url';
38
38
  import { tokenizeMarkdown, findParagraphBreak, fail } from './markdown-blocks.mjs';
39
39
 
40
40
  const __filename = fileURLToPath(import.meta.url);
@@ -542,5 +542,16 @@ export const runCli = (argv, deps = {}) => {
542
542
  }
543
543
  };
544
544
 
545
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
545
+ // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
546
+ // reached through a symlink resolves to its target, so a raw string compare reads the two as
547
+ // different and the CLI never runs. realpathSync collapses the link so both sides match.
548
+ const isDirectRun = (() => {
549
+ const invoked = process.argv[1];
550
+ if (!invoked) return false;
551
+ try {
552
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
553
+ } catch {
554
+ return false;
555
+ }
556
+ })();
546
557
  if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
@@ -75,9 +75,9 @@
75
75
  //
76
76
  // Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
77
77
 
78
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
78
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, realpathSync } from 'node:fs';
79
79
  import { dirname, resolve, join, posix } from 'node:path';
80
- import { fileURLToPath, pathToFileURL } from 'node:url';
80
+ import { fileURLToPath } from 'node:url';
81
81
  import { spawnSync } from 'node:child_process';
82
82
  import { createHash } from 'node:crypto';
83
83
  import { tmpdir } from 'node:os';
@@ -1195,5 +1195,16 @@ export const runCli = (argv, deps = {}) => {
1195
1195
  }
1196
1196
  };
1197
1197
 
1198
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1198
+ // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
1199
+ // reached through a symlink resolves to its target, so a raw string compare reads the two as
1200
+ // different and the CLI never runs. realpathSync collapses the link so both sides match.
1201
+ const isDirectRun = (() => {
1202
+ const invoked = process.argv[1];
1203
+ if (!invoked) return false;
1204
+ try {
1205
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
1206
+ } catch {
1207
+ return false;
1208
+ }
1209
+ })();
1199
1210
  if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
@@ -31,9 +31,9 @@
31
31
  // --cutoff-days=N (default 14)
32
32
  // --today=YYYY-MM-DD (default UTC today)
33
33
 
34
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
34
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
35
35
  import { dirname, resolve, basename } from 'node:path';
36
- import { fileURLToPath, pathToFileURL } from 'node:url';
36
+ import { fileURLToPath } from 'node:url';
37
37
  import { tokenizeMarkdown, fail } from './markdown-blocks.mjs';
38
38
 
39
39
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -411,5 +411,16 @@ export const runCli = (argv, deps = {}) => {
411
411
  }
412
412
  };
413
413
 
414
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
414
+ // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
415
+ // reached through a symlink resolves to its target, so a raw string compare reads the two as
416
+ // different and the CLI never runs. realpathSync collapses the link so both sides match.
417
+ const isDirectRun = (() => {
418
+ const invoked = process.argv[1];
419
+ if (!invoked) return false;
420
+ try {
421
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
422
+ } catch {
423
+ return false;
424
+ }
425
+ })();
415
426
  if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
@@ -25,9 +25,9 @@
25
25
  // --quiet print only failures (and final summary)
26
26
 
27
27
  import { readFile, writeFile, readdir, stat, rename, rm } from 'node:fs/promises';
28
- import { existsSync, lstatSync } from 'node:fs';
28
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
29
29
  import { dirname, resolve, relative, join, basename, sep } from 'node:path';
30
- import { fileURLToPath, pathToFileURL } from 'node:url';
30
+ import { fileURLToPath } from 'node:url';
31
31
  import { randomBytes } from 'node:crypto';
32
32
 
33
33
  const __filename = fileURLToPath(import.meta.url);
@@ -571,7 +571,18 @@ export const runCli = async (argv, deps = {}) => {
571
571
  return result(errorCount > 0 && !flags.report ? 1 : 0);
572
572
  };
573
573
 
574
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
574
+ // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
575
+ // reached through a symlink resolves to its target, so a raw string compare reads the two as
576
+ // different and the CLI never runs. realpathSync collapses the link so both sides match.
577
+ const isDirectRun = (() => {
578
+ const invoked = process.argv[1];
579
+ if (!invoked) return false;
580
+ try {
581
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
582
+ } catch {
583
+ return false;
584
+ }
585
+ })();
575
586
  if (isDirectRun) {
576
587
  const { code, stdout, stderr } = await runCli(process.argv.slice(2));
577
588
  if (stdout) process.stdout.write(stdout);
@@ -28,7 +28,7 @@
28
28
 
29
29
  import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
30
30
  import { join, resolve, isAbsolute } from 'node:path';
31
- import { pathToFileURL, fileURLToPath } from 'node:url';
31
+ import { fileURLToPath } from 'node:url';
32
32
  import { randomBytes } from 'node:crypto';
33
33
  import { spawnSync } from 'node:child_process';
34
34
 
@@ -718,5 +718,16 @@ export const main = (argv = process.argv.slice(2), io = {}) => {
718
718
  }
719
719
  };
720
720
 
721
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
721
+ // Run main() only when executed directly, never on import. Compare by REAL path: an entry point
722
+ // reached through a symlink resolves to its target, so a raw string compare reads the two as
723
+ // different and the CLI never runs. realpathSync collapses the link so both sides match.
724
+ const isDirectRun = (() => {
725
+ const invoked = process.argv[1];
726
+ if (!invoked) return false;
727
+ try {
728
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
729
+ } catch {
730
+ return false;
731
+ }
732
+ })();
722
733
  if (isDirectRun) process.exitCode = main();
@@ -27,7 +27,8 @@
27
27
 
28
28
  import { lstatSync, readFileSync } from 'node:fs';
29
29
  import { dirname, join, resolve } from 'node:path';
30
- import { fileURLToPath, pathToFileURL } from 'node:url';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { isDirectRun } from './direct-run.mjs';
31
32
  import { ACKS_FILE, ACK_LANES } from './recommendations.mjs';
32
33
  import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
33
34
  import { shellQuoteArg } from './review-state.mjs';
@@ -207,5 +208,4 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
207
208
  }
208
209
  };
209
210
 
210
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
211
- if (isDirectRun) process.exit(main(process.argv.slice(2)));
211
+ if (isDirectRun(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -0,0 +1,165 @@
1
+ // advisor-matrix.mjs — the advisor-matrix STRUCTURE check (delegation Plan 3, Phase 1), as a leaf.
2
+ //
3
+ // A doc-parity BINDING proves a token is somewhere in a file. Correspondence is a different claim,
4
+ // and it is the one this table needs: the dispatch mode doc's routing matrix must carry one row per
5
+ // registry row, in registry order, with every CELL equal. A reorder, a duplicate, a dropped row, a
6
+ // mis-bound vehicle and a drifted availability or returns cell all leave every token present — so a
7
+ // token check passes every one of them, which is exactly why this exists beside the bindings rather
8
+ // than as more of them.
9
+ //
10
+ // Its own module rather than more lines in doc-parity.mjs: the lint's identity is "a closed registry
11
+ // of value bindings plus the runner over them", and a table parser with its own refusal vocabulary is
12
+ // a second thing. Split, each is a file you can hold whole — and the parser gets its own test file.
13
+ //
14
+ // Read-only: never writes, never commits, spawns nothing. Node built-ins plus the advisor registry
15
+ // only. No side effects on import; no CLI (it is reached through doc-parity).
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { ADVISOR_ROWS, ADVISOR_MATRIX_HEADER, ADVISOR_MATRIX_COLUMNS, renderAdvisorMatrix } from './dispatch-advisor.mjs';
21
+
22
+ const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
23
+
24
+ export const ADVISOR_MATRIX_DOC = 'references/modes/dispatch.md';
25
+
26
+ // The table is found through an ANCHORED surface, not by "the first header line anywhere". Header
27
+ // search alone is maskable in a way even an exactly-one rule does not close: a faithful copy of the
28
+ // table plus a canonical one whose HEADER drifted leaves exactly one matching header — the decoy's —
29
+ // and the check then reads the decoy and passes. The markers make the checked surface a property of
30
+ // the DOC, so a copy outside them can neither stand in for the table nor hide its drift, and a
31
+ // drifted header INSIDE them leaves the surface with zero headers and fails closed.
32
+ export const ADVISOR_MATRIX_BEGIN = '<!-- advisor-matrix:begin -->';
33
+ export const ADVISOR_MATRIX_END = '<!-- advisor-matrix:end -->';
34
+
35
+ export const readKitDoc = (rel) => readFileSync(resolve(KIT_ROOT, rel), 'utf8');
36
+
37
+ // Both line endings are ordinary here. Splitting on '\n' alone leaves a trailing '\r' on every line
38
+ // of a CRLF-authored doc, and the marker match survives it (it trims) while the exact header match
39
+ // does not — so the check would fail a CORRECT document while naming a drifted header. One split,
40
+ // before anything compares.
41
+ const splitLines = (text) => String(text).split(/\r?\n/);
42
+
43
+ const linesMatching = (lines, marker) => lines.flatMap((line, i) => (line.trim() === marker ? [i] : []));
44
+
45
+ // Only leading and trailing BLANK lines are dropped. Trimming the joined block with String.trim()
46
+ // would also eat significant edge whitespace INSIDE the first and last lines, which is drift the
47
+ // comparison is supposed to see.
48
+ const trimBlankEdges = (lines) => {
49
+ let start = 0;
50
+ let end = lines.length;
51
+ while (start < end && lines[start].trim() === '') start += 1;
52
+ while (end > start && lines[end - 1].trim() === '') end -= 1;
53
+ return lines.slice(start, end);
54
+ };
55
+
56
+ // parseAdvisorMatrix(text) → { ok: true, lines, rows } | { ok: false, reason }. `lines` is the whole
57
+ // anchored block; `rows` are its class rows, parsed for the DIAGNOSIS only — the verdict is the
58
+ // whole-block comparison in checkMatrixStructure, so a deleted alignment rule, a rewritten harness
59
+ // lane and an extra row are all caught, none of which a class-row walk would ever see.
60
+ export const parseAdvisorMatrix = (text) => {
61
+ const lines = splitLines(text);
62
+ const begins = linesMatching(lines, ADVISOR_MATRIX_BEGIN);
63
+ const ends = linesMatching(lines, ADVISOR_MATRIX_END);
64
+ if (begins.length !== 1 || ends.length !== 1) {
65
+ return { ok: false, reason: `the anchored matrix surface is not unique — found ${begins.length} "${ADVISOR_MATRIX_BEGIN}" and ${ends.length} "${ADVISOR_MATRIX_END}" (exactly one of each is required)` };
66
+ }
67
+ if (ends[0] < begins[0]) {
68
+ return { ok: false, reason: 'the matrix end marker precedes its begin marker — the anchored surface is inverted' };
69
+ }
70
+ const surface = trimBlankEdges(lines.slice(begins[0] + 1, ends[0]));
71
+ const headers = surface.filter((line) => line === ADVISOR_MATRIX_HEADER);
72
+ if (headers.length !== 1) {
73
+ return { ok: false, reason: `the anchored matrix surface carries ${headers.length} header line(s) equal to "${ADVISOR_MATRIX_HEADER}" — exactly one is required` };
74
+ }
75
+ const rows = [];
76
+ for (const line of surface.slice(surface.indexOf(ADVISOR_MATRIX_HEADER) + 1)) {
77
+ if (!line.startsWith('|')) continue;
78
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
79
+ // Arity refuses OUTRIGHT rather than skipping the row: a row whose cell count disagrees with the
80
+ // header's is malformed whatever it says, and skipping it would report the drift as a MISSING
81
+ // class row — a true verdict reached through a misleading sentence.
82
+ if (cells.length !== ADVISOR_MATRIX_COLUMNS.length) {
83
+ return { ok: false, reason: `a matrix row carries ${cells.length} cell(s), the table has ${ADVISOR_MATRIX_COLUMNS.length} columns: ${line.trim()}` };
84
+ }
85
+ const classCell = /^`(.+)`$/.exec(cells[0]);
86
+ // A row whose first cell is not a backticked class joins no CLASS comparison — the harness lane,
87
+ // or an interloper. Neither escapes: the whole-block equality below sees every line.
88
+ if (classCell === null) continue;
89
+ rows.push(Object.fromEntries(ADVISOR_MATRIX_COLUMNS.map(({ key }, i) => [key, i === 0 ? classCell[1] : cells[i]])));
90
+ }
91
+ return { ok: true, lines: surface, rows };
92
+ };
93
+
94
+ const quoted = (classes) => classes.map((c) => `\`${c}\``).join(', ');
95
+
96
+ // The DIAGNOSIS over the class rows, and the ORDER of its questions is the point. A POSITIONAL walk
97
+ // reads a deleted middle row as a corrupted step-class cell in the row that slid up behind it —
98
+ // technically a difference at that index, and a useless pointer for whoever has to fix the doc. So
99
+ // membership is settled first (duplicated / missing / unregistered), then ORDER, and only over rows
100
+ // that agree on both does a cell comparison run — where the first differing CELL is named, because
101
+ // "row 3 disagrees" leaves the reader to diff four columns by eye.
102
+ const rowDrift = (actual, expected) => {
103
+ const actualClasses = actual.map((r) => r.stepClass);
104
+ const expectedClasses = expected.map((r) => r.stepClass);
105
+
106
+ const duplicated = actualClasses.filter((c, i) => actualClasses.indexOf(c) !== i);
107
+ if (duplicated.length > 0) return `the advisor matrix names ${quoted([...new Set(duplicated)])} more than once — the registry has exactly one row per step class`;
108
+
109
+ const missing = expectedClasses.filter((c) => !actualClasses.includes(c));
110
+ if (missing.length > 0) return `the advisor matrix is missing ${missing.length} registry row(s): ${quoted(missing)}`;
111
+
112
+ const unregistered = actualClasses.filter((c) => !expectedClasses.includes(c));
113
+ if (unregistered.length > 0) return `the advisor matrix names ${unregistered.length} row(s) the registry does not: ${quoted(unregistered)}`;
114
+
115
+ const outOfOrder = actualClasses.findIndex((c, i) => c !== expectedClasses[i]);
116
+ if (outOfOrder !== -1) return `the advisor matrix is out of registry order — row ${outOfOrder + 1} is \`${actualClasses[outOfOrder]}\`, the registry has \`${expectedClasses[outOfOrder]}\``;
117
+
118
+ for (const [i, row] of actual.entries()) {
119
+ const e = expected[i];
120
+ const differing = ADVISOR_MATRIX_COLUMNS.find(({ key }) => row[key] !== e[key]);
121
+ if (differing !== undefined) {
122
+ return `matrix row ${i + 1} (\`${row.stepClass}\`): the ${differing.label} cell reads "${row[differing.key]}", the registry has "${e[differing.key]}"`;
123
+ }
124
+ }
125
+ return null;
126
+ };
127
+
128
+ // blockDrift(actual, expected) → null when the two blocks are IDENTICAL, else the first line that
129
+ // disagrees. It is the verdict and the fallback diagnosis in one: "the blocks are equal" is exactly
130
+ // "no line disagrees", so there is no second comparison to keep in step with this one — and the
131
+ // null return is the path every green run takes, not an unreachable defensive branch.
132
+ const blockDrift = (actual, expected) => {
133
+ for (let i = 0; i < Math.max(actual.length, expected.length); i += 1) {
134
+ if (actual[i] === expected[i]) continue;
135
+ if (actual[i] === undefined) return `the anchored matrix is missing line ${i + 1}, which the canonical table renders as ${JSON.stringify(expected[i])}`;
136
+ if (expected[i] === undefined) return `the anchored matrix carries an extra line ${i + 1}: ${JSON.stringify(actual[i])}`;
137
+ return `matrix line ${i + 1} reads ${JSON.stringify(actual[i])}, the canonical table renders ${JSON.stringify(expected[i])}`;
138
+ }
139
+ return null;
140
+ };
141
+
142
+ // checkMatrixStructure(readText) → the same shape a doc-parity binding result carries, so the report,
143
+ // the --check line and the --json payload all render it through their existing paths. The VERDICT is
144
+ // whole-block equality against the canonical render: every way the doc's table can stop being the
145
+ // registry's table is one comparison, and the enumeration of those ways never has to be maintained.
146
+ export const checkMatrixStructure = (readText = readKitDoc) => {
147
+ const rel = ADVISOR_MATRIX_DOC;
148
+ const expected = ADVISOR_ROWS.map(({ stepClass, vehicle, availabilityNote, returns }) => ({ stepClass, vehicle, availabilityNote, returns }));
149
+ let text;
150
+ try {
151
+ text = readText(rel);
152
+ } catch (err) {
153
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: false, reason: `unreadable (${(err && err.code) || (err && err.message) || 'read failed'})` }], ok: false };
154
+ }
155
+ const parsed = parseAdvisorMatrix(text);
156
+ if (parsed.ok === false) {
157
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: false, reason: parsed.reason }], ok: false };
158
+ }
159
+ // The VERDICT is the block comparison; the row walk only refines the MESSAGE when it can point at a
160
+ // class row. A block difference the row walk cannot explain (the alignment rule, the harness lane,
161
+ // an interloping row, whitespace) keeps the line-level pointer.
162
+ const drift = blockDrift(parsed.lines, splitLines(renderAdvisorMatrix()));
163
+ const reason = drift === null ? null : (rowDrift(parsed.rows, expected) ?? drift);
164
+ return { constant: 'advisor-matrix-structure', files: [{ rel, ok: reason === null, reason }], ok: reason === null };
165
+ };
@@ -40,7 +40,7 @@
40
40
  import { spawnSync } from 'node:child_process';
41
41
  import { closeSync, lstatSync, openSync } from 'node:fs';
42
42
  import { join } from 'node:path';
43
- import { pathToFileURL } from 'node:url';
43
+ import { isDirectRun } from './direct-run.mjs';
44
44
  import { isExecutableFile, probeSandboxAvailability } from './velocity-profile.mjs';
45
45
  import { assertDocsAiDeployment } from './atomic-write.mjs';
46
46
 
@@ -484,5 +484,4 @@ export const main = (argv, deps = {}) => {
484
484
  return finish(applied.status, applied.finalPlan);
485
485
  };
486
486
 
487
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
488
- if (isDirectRun) process.exit(main(process.argv.slice(2)));
487
+ if (isDirectRun(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -23,7 +23,7 @@
23
23
  //
24
24
  // Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
25
25
 
26
- import { pathToFileURL } from 'node:url';
26
+ import { isDirectRun } from './direct-run.mjs';
27
27
  import { settingValueValid } from './manifest/validate.mjs';
28
28
  import { writeHostConfigFileAtomic } from './atomic-write.mjs';
29
29
  import {
@@ -294,8 +294,7 @@ export const main = (argv = [], ctx = {}) => {
294
294
  }
295
295
  };
296
296
 
297
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
298
- if (isDirectRun) {
297
+ if (isDirectRun(import.meta.url)) {
299
298
  const r = main(process.argv.slice(2));
300
299
  if (r.stdout) console.log(r.stdout);
301
300
  if (r.stderr) console.error(r.stderr);
@@ -27,7 +27,8 @@
27
27
 
28
28
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
29
29
  import { join, resolve, dirname } from 'node:path';
30
- import { fileURLToPath, pathToFileURL } from 'node:url';
30
+ import { fileURLToPath } from 'node:url';
31
+ import { isDirectRun } from './direct-run.mjs';
31
32
  import { shellQuoteArg } from './repo-lex.mjs';
32
33
 
33
34
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -251,5 +252,4 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
251
252
  }
252
253
  };
253
254
 
254
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
255
- if (isDirectRun) process.exit(main(process.argv.slice(2)));
255
+ if (isDirectRun(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -19,7 +19,7 @@
19
19
  // the `### Mode:` headers in SKILL.md, so the catalog cannot silently drift from the documented modes.
20
20
  // Pure, dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
21
21
 
22
- import { pathToFileURL } from 'node:url';
22
+ import { isDirectRun } from './direct-run.mjs';
23
23
 
24
24
  const SKILL_NAME = 'agent-workflow-kit';
25
25
  const BARE_INVOCATION = `/${SKILL_NAME}`;
@@ -264,7 +264,7 @@ const CATALOG = [
264
264
  invocation: invocationOf('dispatch'),
265
265
  group: 'Orchestrate',
266
266
  kind: WRITER,
267
- oneLine: 'Measure delegation: check a sub-task brief’s contract block (form only — never whether the task is genuinely bounded), pre-register an acceptance wave with its thresholds, record one observation, open a delegated thread from that brief, wait for that one dispatch to answer — a wait that ends without an answer says so and authorizes nothing — absorb the wrapper’s receipt back into the ledger, fold the returned work or close the thread with a recorded degrade, and print the per-class report of how much a delegated sub-task actually bought, derived from what was dispatched, returned and folded. Writes only its own ledger file beside the repo; never commits.',
267
+ oneLine: 'Measure delegation: check a sub-task brief’s contract block (form only — never whether the task is genuinely bounded), ask which vehicle should carry that kind of sub-task on THIS machine and what past threads of that kind actually did — advice you may ignore, never a gate, printed on its own and again under a valid contract check, pre-register an acceptance wave with its thresholds, record one observation, open a delegated thread from that brief, wait for that one dispatch to answer — a wait that ends without an answer says so and authorizes nothing — absorb the wrapper’s receipt back into the ledger, fold the returned work or close the thread with a recorded degrade, and print the per-class report of how much a delegated sub-task actually bought, derived from what was dispatched, returned and folded. After a landing, deliver the handoff verbatim and count only what is fully measurable. Writes only its own ledger file beside the repo; never commits.',
268
268
  },
269
269
  {
270
270
  // NEVER `guarded` — that kind promises dry-run-first, which these writers do not have; the
@@ -273,7 +273,7 @@ const CATALOG = [
273
273
  invocation: invocationOf('worktrees'),
274
274
  group: 'Orchestrate',
275
275
  kind: WRITER,
276
- oneLine: 'Run features in parallel git worktrees: provision an isolated sibling copy, list them, stage a finished one back onto clean main (the commit still asks in dialogue), and remove a live-verified landed one. No preview step; list is read-only; cleanup --abandon destroys unlanded work.',
276
+ oneLine: 'Run features in parallel git worktrees: provision an isolated sibling copy, list them, print the cold-start prompt a fresh session in one of them needs — where it is, what MAIN answers NOW rather than what the record froze, the handoff as the one way back, and the bars nothing enforces — stage a finished one back onto clean main (the commit still asks in dialogue), and remove a live-verified landed one. No preview step; list is read-only and so is prompt; cleanup --abandon destroys unlanded work.',
277
277
  },
278
278
  ];
279
279
 
@@ -380,7 +380,6 @@ const main = (argv) => {
380
380
  console.log(formatHelp());
381
381
  };
382
382
 
383
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
384
- if (isDirectRun) main(process.argv.slice(2));
383
+ if (isDirectRun(import.meta.url)) main(process.argv.slice(2));
385
384
 
386
385
  export { KINDS, SKILL_NAME, BARE_INVOCATION };
@@ -11,7 +11,15 @@
11
11
  // up to INDEX_LAG_PATH_CAP with the remainder stated. A dirty tracked SUBMODULE is named
12
12
  // separately with its own recovery. Fail-closed on an undecidable probe. This BLOCKS the
13
13
  // deliberate partial commit by design — `--no-verify` is the stated residual, not a flag;
14
- // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing);
14
+ // 1. recomputes the CURRENT tree fingerprint (the review-state export — read-only git plumbing),
15
+ // and decides the two CONTENT-FREE lanes here, because no store read can answer them: a
16
+ // payload with no bytes yields the ONE fingerprint every clean moment of every repository
17
+ // shares, so any receipt at it was minted elsewhere and may attest nothing. With a DIRTY
18
+ // index that means staged content the payload cannot see (a gitlink hidden by
19
+ // `submodule.<name>.ignore` / `diff.ignoreSubmodules`) and the guard REFUSES, naming the
20
+ // configuration rather than re-staging; with a clean index the commit introduces no bytes
21
+ // (`--allow-empty`, a message-only `--amend`, an empty merge) and the guard PASSES while
22
+ // stating that it attests NOTHING — the receipt arms are skipped, never satisfied;
15
23
  // 2. reads the LATEST completed final-run record from the core-evidence store (only the latest
16
24
  // attempt at a fingerprint is authoritative — a green receipt is DEAD once a later attempt at
17
25
  // the same fingerprint went red) and refuses on: no record for the current fingerprint · a
@@ -34,11 +42,15 @@
34
42
 
35
43
  import { readFileSync, lstatSync } from 'node:fs';
36
44
  import { resolve } from 'node:path';
37
- import { pathToFileURL, fileURLToPath } from 'node:url';
45
+ import { fileURLToPath } from 'node:url';
38
46
  import { spawnSync } from 'node:child_process';
39
47
  import { createHash } from 'node:crypto';
48
+ import { isDirectRun } from './direct-run.mjs';
40
49
  import { computeTreeFingerprint, buildState, decideCheck, quoteReportName, shellQuoteArg } from './review-state.mjs';
41
- import { resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization, computeWorkingState } from './core-evidence.mjs';
50
+ import {
51
+ resolveEvidencePath, readEvidence, authoritativeOfKind, canonicalKindSerialization,
52
+ computeWorkingState, CONTENT_FREE_FINGERPRINT,
53
+ } from './core-evidence.mjs';
42
54
  import { resolveLcovPath } from './coverage-check.mjs';
43
55
  import { GATES_REL, loadDeclaration } from './run-gates.mjs';
44
56
  import { computeFlowDecision } from './flow-check.mjs';
@@ -177,15 +189,42 @@ export const decideIndexLag = (state) => {
177
189
  };
178
190
 
179
191
  // runGuard({ cwd, env }) → { code, lines }. Every refusal names its recovery.
192
+ // The flow decision's two renders, shared by every lane that consults it — the empty-commit lane
193
+ // reaches the same store through the same consumer mode, so its wording can never drift from the
194
+ // byte-carrying one.
195
+ const flowRefusalLines = (flow) => [
196
+ `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
197
+ ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
198
+ ];
199
+ const flowAdvisoryLines = (flow) => (flow.present && flow.armed
200
+ ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
201
+ : []);
202
+
180
203
  export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
181
204
  const rootTop = gitLine(['rev-parse', '--show-toplevel'], cwd);
182
205
  if (rootTop == null) return { code: 1, lines: ['commit-guard: not a git work tree — nothing to guard'] };
183
206
  // FIRST: a pure tree property needing no store read. Its recovery re-stages the tree and re-mints
184
207
  // the receipt, so every arm below is re-decided anyway — naming a stale fingerprint ahead of it
185
208
  // would send the operator down a recovery they must redo.
186
- const indexLag = decideIndexLag(computeWorkingState(cwd));
209
+ const working = computeWorkingState(cwd);
210
+ const indexLag = decideIndexLag(working);
187
211
  if (indexLag !== null) return indexLag;
188
212
  const fingerprint = computeTreeFingerprint(cwd);
213
+ // The CONTENT-FREE lanes — the second pure tree property, decided here for the same reason the
214
+ // index lag is: no store read can answer it. A payload with no bytes states nothing about what
215
+ // this commit will carry, and its fingerprint is the ONE value every clean moment of every
216
+ // repository shares, so a receipt found at it was minted by some other moment, possibly at
217
+ // another base. Such evidence must therefore decide NOTHING here — neither refuse nor attest
218
+ // (the same fact flow-check-rungs.mjs applies to a red final). The index tells the two lanes
219
+ // apart, and `computeWorkingState` probes it with --ignore-submodules=none precisely so a
220
+ // config-hidden gitlink cannot pass for a clean one.
221
+ const contentFree = fingerprint === CONTENT_FREE_FINGERPRINT;
222
+ if (contentFree && working.stagedDirty) {
223
+ return {
224
+ code: 1,
225
+ lines: [`commit-guard: REFUSED — the index carries staged content the fingerprint domain cannot see (a submodule gitlink hidden from \`git diff\` by \`submodule.<name>.ignore\` or \`diff.ignoreSubmodules\`), so no final receipt can describe what this commit will carry. Recovery: clear that ignore setting (or set it to \`none\`) until \`git diff --cached --no-ext-diff\` shows the change, then re-run node ${FINAL_RUN_TOOL} --final`],
226
+ };
227
+ }
189
228
  // The guard's OWN reads resolve FIXED git-dir paths — a stray AW_CORE_EVIDENCE / AW_LCOV_FILE
190
229
  // in the committing shell must never redirect the LAST line of defense to a forged artifact
191
230
  // (the env stays a test seam for the producers, never for this consumer).
@@ -194,6 +233,27 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
194
233
  if ((read.malformed ?? 0) > 0 || read.readError) {
195
234
  return { code: 1, lines: [`commit-guard: REFUSED — evidence store unavailable (${read.malformed} malformed line(s)${read.readError ? `, read error: ${read.readError}` : ''}); inspect ${storePath}`] };
196
235
  }
236
+ // The empty-commit lane: the index equals HEAD, so this commit introduces no bytes at all
237
+ // (`git commit --allow-empty`, a message- or signature-only `--amend`, an empty merge). The
238
+ // guard's whole claim is about bytes, so here it has none to make and says so. The receipt arms
239
+ // are SKIPPED rather than satisfied — consulting a content-free receipt would make the outcome
240
+ // depend on which stray clean moment happened to be recorded last. The flow arm still runs: an
241
+ // empty commit still moves HEAD, and the chain bookkeeping is about that, not about bytes; its
242
+ // own fingerprint-keyed correlations (the D10 flow→final binding, receipt and degrade coverage)
243
+ // drop out inside flow-check on the same fact, so no stray content-free record decides here
244
+ // either. Store HEALTH is deliberately NOT waived above: an unreadable store is not a
245
+ // correlation, and a store that cannot be read cannot answer the chain questions either.
246
+ if (contentFree) {
247
+ const emptyFlow = computeFlowDecision({ cwd, consumer: 'commit-guard', treeCarriesBytes: false });
248
+ if (emptyFlow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(emptyFlow) };
249
+ return {
250
+ code: 0,
251
+ lines: [
252
+ 'commit-guard: PASS — this commit changes no tree content (the index contributes no tree-content delta and the work tree adds nothing), so the guard attests NOTHING about it: a receipt found at the shared content-free fingerprint cannot be correlated to THIS moment or base',
253
+ ...flowAdvisoryLines(emptyFlow),
254
+ ],
255
+ };
256
+ }
197
257
  const finals = authoritativeOfKind(read.records, 'final');
198
258
  const current = finals.find((r) => r.fingerprintBefore === fingerprint) ?? null;
199
259
  if (!current) {
@@ -255,15 +315,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
255
315
  // evidenceHashes.flow and the store has since VANISHED (present=false) — a deletion must
256
316
  // never un-arm the binding. A no-store repo with no flow-bearing receipt still yields zero
257
317
  // refusals (byte-exact pre-flow behavior).
258
- if (flow.refusals.length > 0) {
259
- return {
260
- code: 1,
261
- lines: [
262
- `commit-guard: REFUSED — the flow store refuses this commit: ${flow.refusals[0]}`,
263
- ...flow.refusals.slice(1).map((r) => `commit-guard: flow refusal — ${r}`),
264
- ],
265
- };
266
- }
318
+ if (flow.refusals.length > 0) return { code: 1, lines: flowRefusalLines(flow) };
267
319
  // The ship-receipt arm: the SAME normative decision review-state --check computes, over a
268
320
  // SANITIZED env — the receipts/evidence/flow-store overrides are producer test seams, and
269
321
  // honoring them HERE would let a forged store bypass the fixed-path reads above.
@@ -278,10 +330,7 @@ export const runGuard = ({ cwd = process.cwd(), env = process.env } = {}) => {
278
330
  const flowSuffix = flow.present && flow.armed
279
331
  ? ` — flow: armed${review.flowLabels?.length ? ` (${review.flowLabels.join('; ')})` : ''}`
280
332
  : '';
281
- const flowAdvisoryLines = flow.present && flow.armed
282
- ? flow.advisories.map((a) => `commit-guard: flow advisory — ${a}`)
283
- : [];
284
- return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines] };
333
+ return { code: 0, lines: [`commit-guard: PASS — a green final receipt binds this exact tree (${fingerprint.slice(0, 12)}…), the declaration and evidence hashes match, and the review obligations are satisfied${flowSuffix}`, ...flowAdvisoryLines(flow)] };
285
334
  };
286
335
 
287
336
  const HELP = `commit-guard — the read-only pre-commit guard (agent-workflow family, D10).
@@ -291,7 +340,16 @@ Usage:
291
340
 
292
341
  Re-runs NOTHING: refuses an INDEX that lags the verified working tree (FIRST — unstaged tracked
293
342
  paths, reviewable untracked paths, or a dirty tracked submodule, each named with its recovery;
294
- this deliberately blocks a partial commit), then recomputes the current tree fingerprint and binds
343
+ this deliberately blocks a partial commit), then recomputes the current tree fingerprint.
344
+
345
+ A CONTENT-FREE fingerprint (a payload with no bytes — the value every clean work tree shares)
346
+ decides WITHOUT a receipt, because one found there was minted by another clean moment: with a dirty
347
+ index it REFUSES (staged content the payload cannot see — a gitlink hidden by
348
+ \`submodule.<name>.ignore\` / \`diff.ignoreSubmodules\`; the recovery is that configuration, not
349
+ \`git add\`), and with a clean index it PASSES stating it attests NOTHING (the commit carries no
350
+ bytes: \`--allow-empty\`, a message-only \`--amend\`, an empty merge).
351
+
352
+ Otherwise it binds
295
353
  the LATEST completed run-gates --final receipt — refusing on { no receipt for this tree · a red
296
354
  latest attempt · before≠after · declaration content drift · evidence-hash drift · lcov drift ·
297
355
  a flow-store refusal (a PRESENT store's open own chain / base motion / coverage — verbatim; no
@@ -324,8 +382,7 @@ export const main = (argv, ctx = {}) => {
324
382
  }
325
383
  };
326
384
 
327
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
328
- if (isDirectRun) {
385
+ if (isDirectRun(import.meta.url)) {
329
386
  const r = main(process.argv.slice(2));
330
387
  if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
331
388
  if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
@@ -36,11 +36,11 @@
36
36
 
37
37
  import { readFileSync, lstatSync, realpathSync, readlinkSync, openSync, readSync, closeSync } from 'node:fs';
38
38
  import { join, dirname, normalize, sep, basename } from 'node:path';
39
- import { pathToFileURL } from 'node:url';
40
39
  import { spawnSync } from 'node:child_process';
41
40
  import { createHash } from 'node:crypto';
42
41
  import { writeContainedFileAtomic } from './atomic-write.mjs';
43
42
  import { parsePositiveIntKnob, probeVerdict } from './changed-surface.mjs';
43
+ import { isDirectRun } from './direct-run.mjs';
44
44
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
45
45
  import { lexicalRepoRelative } from './repo-lex.mjs';
46
46
  // The coverage vocabulary leaf: run-gates RECORDS the token this validator checks, and run-gates
@@ -160,6 +160,16 @@ export const computeTreeFingerprint = (cwd, fsx) => {
160
160
  return payload == null ? null : createHash('sha256').update(payload).digest('hex');
161
161
  };
162
162
 
163
+ // The fingerprint of a CONTENT-FREE payload — a clean work tree emits no bytes at all, so this ONE
164
+ // value is shared by every clean moment of every repository. It therefore identifies no working
165
+ // state and correlates to no base: evidence found at it was minted by some other clean moment,
166
+ // possibly at another base, and can decide nothing in either direction. Two situations reach it,
167
+ // and only the INDEX tells them apart (never the payload): an empty commit, where the index equals
168
+ // HEAD and no byte enters the repository, and staged content the payload cannot see — a gitlink
169
+ // hidden from `git diff` by an ignore configuration. Read by the consumers that correlate a
170
+ // fingerprint to a base (flow-check-rungs.mjs #65) and by commit-guard's two content-free lanes.
171
+ export const CONTENT_FREE_FINGERPRINT = createHash('sha256').update(Buffer.alloc(0)).digest('hex');
172
+
163
173
  // The index↔worktree split the fingerprint deliberately CANNOT see: the payload above concatenates
164
174
  // the staged and unstaged diffs, so against an otherwise-empty index a hunk moving into the index
165
175
  // leaves it byte-identical — while `git commit` builds the commit from the INDEX alone. This is the
@@ -1220,8 +1230,7 @@ export const main = (argv, ctx = {}) => {
1220
1230
  }
1221
1231
  };
1222
1232
 
1223
- const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
1224
- if (isDirectRun) {
1233
+ if (isDirectRun(import.meta.url)) {
1225
1234
  const r = main(process.argv.slice(2));
1226
1235
  if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
1227
1236
  if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);