@sabaiway/agent-workflow-kit 10.6.0 → 11.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +7 -6
  3. package/SKILL.md +9 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -6
  6. package/bridges/antigravity-cli-bridge/capability.json +1 -1
  7. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  8. package/bridges/codex-cli-bridge/bin/codex-exec.sh +1 -1
  9. package/bridges/codex-cli-bridge/bin/codex-review.sh +6 -6
  10. package/bridges/codex-cli-bridge/capability.json +1 -1
  11. package/capability.json +1 -1
  12. package/launchers/windsurf-workflow.md +2 -2
  13. package/package.json +1 -1
  14. package/references/contracts.md +1 -1
  15. package/references/modes/bootstrap.md +5 -5
  16. package/references/modes/commit-guard.md +2 -2
  17. package/references/modes/control-bytes.md +91 -0
  18. package/references/modes/flow-writer.md +16 -4
  19. package/references/modes/procedures.md +2 -0
  20. package/references/modes/recommendations.md +1 -0
  21. package/references/modes/review-state.md +1 -1
  22. package/references/modes/robustness-brief.md +61 -0
  23. package/references/modes/velocity.md +2 -0
  24. package/references/robustness-literals.json +107 -0
  25. package/references/scripts/archiver-structure.test.mjs +48 -3
  26. package/references/scripts/install-git-hooks.test.mjs +31 -1
  27. package/references/shared/deploy-tail.md +1 -1
  28. package/references/templates/agent_rules.md +1 -1
  29. package/references/templates/changelog.md +1 -1
  30. package/references/templates/decisions.md +2 -2
  31. package/tools/changed-surface.mjs +1 -1
  32. package/tools/commands.mjs +14 -0
  33. package/tools/commit-guard.mjs +20 -9
  34. package/tools/control-bytes.mjs +264 -0
  35. package/tools/core-evidence.mjs +33 -20
  36. package/tools/coverage-check.mjs +1 -1
  37. package/tools/dispatch-store-read.mjs +2 -2
  38. package/tools/flow-check-git-lane.mjs +2 -1
  39. package/tools/flow-delta-proof.mjs +4 -4
  40. package/tools/flow-record-shape.mjs +44 -2
  41. package/tools/flow-round-gates.mjs +151 -0
  42. package/tools/flow-store-read.mjs +1 -1
  43. package/tools/flow-writer.mjs +235 -82
  44. package/tools/fold-scope.mjs +7 -3
  45. package/tools/fs-read-nofollow.mjs +56 -4
  46. package/tools/git-env.mjs +120 -0
  47. package/tools/grounding.mjs +4 -3
  48. package/tools/plan-shape-facts.mjs +23 -10
  49. package/tools/plan-shape.mjs +8 -0
  50. package/tools/procedures.mjs +18 -4
  51. package/tools/recommendations.mjs +53 -3
  52. package/tools/repo-lex.mjs +29 -0
  53. package/tools/review-state.mjs +43 -34
  54. package/tools/robustness-brief.mjs +209 -0
  55. package/tools/robustness-literals.mjs +101 -0
  56. package/tools/sandbox-masks.mjs +2 -1
  57. package/tools/set-flow.mjs +2 -1
  58. package/tools/source-size-scope.mjs +19 -25
  59. package/tools/tracked-tree-census.mjs +3 -3
  60. package/tools/velocity-profile.mjs +5 -2
  61. package/tools/worktree-handoff-return.mjs +2 -2
  62. package/tools/worktrees.mjs +2 -2
@@ -5,7 +5,7 @@
5
5
  // no-follow read without a cycle. flow-store-read.mjs re-exports everything here, so every
6
6
  // existing consumer keeps its import site. Dependency-free, Node >= 22.
7
7
 
8
- import { readFileSync, lstatSync, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
8
+ import { readFileSync, readSync, lstatSync, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
9
9
 
10
10
  // Local no-follow lstat (null ONLY on a true ENOENT).
11
11
  export const lstatNoFollowRead = (path, lstat = lstatSync) => {
@@ -24,6 +24,14 @@ export const describeNonRegular = (st) =>
24
24
  // ignoreBOM: a BOM must surface as malformed line 1, not vanish and get rewritten without it.
25
25
  const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
26
26
  const hasOpenFlag = (v) => typeof v === 'number' && v !== 0;
27
+ const errCode = (err) => (err && err.code) || (err && err.message) || null;
28
+ // The open failures with a NAME of their own (shared by both readers); null for every other.
29
+ const openFailure = (err) => {
30
+ if (err && err.code === 'ENOENT') return { outcome: 'absent' };
31
+ if (err && err.code === 'ELOOP') return { outcome: 'foreign', className: 'symlink', isDirectory: false };
32
+ if (err && err.code === 'EISDIR') return { outcome: 'foreign', className: 'directory', isDirectory: true };
33
+ return null;
34
+ };
27
35
 
28
36
  // The ONE race-free read for store/lock bytes: open O_NOFOLLOW|O_NONBLOCK, fstat the DESCRIPTOR,
29
37
  // read through it, decode fatally — a pathname swapped after the open cannot change what the fd
@@ -89,9 +97,8 @@ const readNoFollowCore = (path, io, closeBox) => {
89
97
  }
90
98
  return { outcome: 'ok', content, dev: st.dev, ino: st.ino };
91
99
  } catch (err) {
92
- if (err && err.code === 'ENOENT') return { outcome: 'absent' };
93
- if (err && err.code === 'ELOOP') return { outcome: 'foreign', className: 'symlink', isDirectory: false };
94
- if (err && err.code === 'EISDIR') return { outcome: 'foreign', className: 'directory', isDirectory: true };
100
+ const known = openFailure(err);
101
+ if (known !== null) return known;
95
102
  try {
96
103
  const st = lstatNoFollowRead(path, lstat);
97
104
  if (st && !st.isFile()) return { outcome: 'foreign', className: describeNonRegular(st), isDirectory: st.isDirectory() };
@@ -108,6 +115,51 @@ const readNoFollowCore = (path, io, closeBox) => {
108
115
  }
109
116
  };
110
117
 
118
+ // readFileBytesNoFollowCapped(path, cap, io?) → ok { bytes } | over-cap { cap } | absent | foreign |
119
+ // error — the control-byte gate's read: open O_NOFOLLOW|O_NONBLOCK, fstat the DESCRIPTOR for its
120
+ // kind, read on it in bounded chunks until EOF or cap+1 bytes; the fstat SIZE is never consulted (a
121
+ // file growing after the fstat still refuses); no decode (a lone 0xE9 is ok bytes); no lstat fallback.
122
+ // ONE skeleton: no open without both flags; fstat the DESCRIPTOR; the body runs on a regular file only.
123
+ const READ_CHUNK = 64 * 1024;
124
+ const withRegularDescriptor = (path, io, body) => {
125
+ const consts = io.constants ?? fsConstants;
126
+ const open = io.open ?? openSync;
127
+ const fstat = io.fstat ?? fstatSync;
128
+ const close = io.close ?? closeSync;
129
+ if (!hasOpenFlag(consts.O_NONBLOCK) || !hasOpenFlag(consts.O_NOFOLLOW)) {
130
+ return { outcome: 'error', code: 'this platform exposes no usable O_NOFOLLOW|O_NONBLOCK open flags — refusing to open (fail closed)' };
131
+ }
132
+ let fd = null;
133
+ let result;
134
+ try {
135
+ fd = open(path, (consts.O_RDONLY ?? 0) | consts.O_NOFOLLOW | consts.O_NONBLOCK);
136
+ const st = fstat(fd);
137
+ result = st.isFile() ? body(fd) : { outcome: 'foreign', className: describeNonRegular(st), isDirectory: st.isDirectory() };
138
+ } catch (err) {
139
+ result = openFailure(err) ?? { outcome: 'error', code: errCode(err) ?? 'read failed' };
140
+ } finally {
141
+ if (fd !== null) {
142
+ try { close(fd); } catch (err) { result = { outcome: 'error', code: `descriptor close failed (${errCode(err) ?? 'close failed'}) — fail closed` }; }
143
+ }
144
+ }
145
+ return result;
146
+ };
147
+ // probeRegularFileNoFollow(path, io?) → ok | absent | foreign | error: the kind check alone, no read.
148
+ export const probeRegularFileNoFollow = (path, io = {}) => withRegularDescriptor(path, io, () => ({ outcome: 'ok' }));
149
+ export const readFileBytesNoFollowCapped = (path, cap, io = {}) => withRegularDescriptor(path, io, (fd) => {
150
+ const read = io.read ?? readSync;
151
+ const chunks = [];
152
+ let total = 0;
153
+ while (total <= cap) {
154
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK, cap + 1 - total));
155
+ const n = read(fd, chunk, 0, chunk.length, null);
156
+ if (n === 0) break;
157
+ chunks.push(chunk.subarray(0, n));
158
+ total += n;
159
+ }
160
+ return total > cap ? { outcome: 'over-cap', cap } : { outcome: 'ok', bytes: Buffer.concat(chunks, total) };
161
+ });
162
+
111
163
  // readFileBytesNoFollow(path, io?) → { outcome: 'ok', bytes } | absent | foreign | error — the
112
164
  // BYTES twin of the reader above for consumers whose domain is byte offsets (the Phase-4
113
165
  // receipt-deadline watermark, the finding-manifest digest domain): keepFd internally so the raw
@@ -0,0 +1,120 @@
1
+ // git-env.mjs — the kit's git-location leaf: WHICH repository a git spawn will talk to, judged by
2
+ // AGREEMENT, never by a variable ban (measured 2026-08-31, git 2.43: an ambient GIT_DIR made three
3
+ // consumers judge ANOTHER repository in silence; a killed git read as "not a git work tree", a pass).
4
+ // The six-state table, the GIT_* strip, the hermetic env, the ls-files parser. A pure LEAF.
5
+
6
+ import { realpathSync } from 'node:fs';
7
+ import { resolve } from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+
10
+ export const GIT_MAX_BUFFER = 256 * 1024 * 1024;
11
+ export const GIT_LOCATION_STATES = Object.freeze(['work-tree', 'not-a-repository', 'error', 'redirected', 'no-work-tree', 'env-only']);
12
+ const LOCATION_VARIABLES = 'GIT_DIR / GIT_WORK_TREE / GIT_COMMON_DIR';
13
+
14
+ // ASCII-case-insensitive prefix (a case-insensitive host honours `git_dir` as GIT_DIR) — an ASCII
15
+ // class, never toUpperCase(): Unicode case folding would also strip a foreign `g<dotless-i>t_dir`.
16
+ export const stripGitLocationEnv = (env) => Object.fromEntries(Object.entries(env).filter(([key]) => !/^[Gg][Ii][Tt]_/.test(key)));
17
+
18
+ // The test fixtures' env, never a consumer's: the strip plus every host-reaching knob pinned.
19
+ export const hermeticGitEnv = (env, home) => ({
20
+ ...stripGitLocationEnv(env),
21
+ HOME: home, XDG_CONFIG_HOME: home, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null',
22
+ GIT_CONFIG_NOSYSTEM: '1', GIT_ATTR_NOSYSTEM: '1', LC_ALL: 'C',
23
+ });
24
+
25
+ // An env naming no PATH (a partial override, a test's `{}`) borrows the process PATH; one naming a PATH is honoured as is.
26
+ export const withGitPath = (env) => (env.PATH === undefined && process.env.PATH !== undefined ? { ...env, PATH: process.env.PATH } : env);
27
+
28
+ // `ls-files -s -z` records: mode, object, stage, TAB, then the path BYTES up to the NUL — undecoded.
29
+ export const parseIndexEntries = (buf) => {
30
+ const entries = [];
31
+ for (let start = 0; start < buf.length;) {
32
+ let end = buf.indexOf(0, start);
33
+ if (end === -1) end = buf.length;
34
+ const record = buf.subarray(start, end);
35
+ const tab = record.indexOf(0x09);
36
+ if (tab !== -1) {
37
+ const [mode, sha, stage] = record.subarray(0, tab).toString('utf8').split(' ');
38
+ entries.push({ mode, sha, stage: Number(stage), path: record.subarray(tab + 1) });
39
+ }
40
+ start = end + 1;
41
+ }
42
+ return entries;
43
+ };
44
+
45
+ const NOT_A_REPOSITORY = /not a git repository/u;
46
+ const NO_WORK_TREE = /must be run in a work tree/u;
47
+
48
+ // ONE rev-parse identity per spawn → ok { line } | not-a-repository | no-work-tree | error (a throw,
49
+ // ENOENT, a signal, any other exit, an EMPTY answer). The answer is the stdout minus exactly one
50
+ // terminating LF — never a split: a path may carry a newline. LC_ALL=C pins git's own wording.
51
+ const probe = (arg, { cwd, env, spawn }) => {
52
+ let r;
53
+ try {
54
+ r = spawn('git', ['rev-parse', arg], { cwd, env: { ...withGitPath(env), LC_ALL: 'C' }, maxBuffer: GIT_MAX_BUFFER, windowsHide: true });
55
+ } catch (err) {
56
+ return { kind: 'error', cause: `git threw synchronously (${err?.message ?? err})` };
57
+ }
58
+ if (r == null || typeof r !== 'object') return { kind: 'error', cause: 'the git runner returned no result' };
59
+ if (r.error) return { kind: 'error', cause: `git could not run (${r.error.code ?? r.error.message ?? r.error})` };
60
+ if (r.signal) return { kind: 'error', cause: `git rev-parse ${arg} was killed by ${r.signal}` };
61
+ const stderr = String(r.stderr ?? '').trim();
62
+ if (r.status === 128 && NOT_A_REPOSITORY.test(stderr)) return { kind: 'not-a-repository', cause: stderr };
63
+ if (r.status === 128 && NO_WORK_TREE.test(stderr)) return { kind: 'no-work-tree', cause: stderr };
64
+ if (r.status !== 0) return { kind: 'error', cause: `git rev-parse ${arg} exited ${r.status}${stderr ? ` (${stderr})` : ''}` };
65
+ const line = String(r.stdout ?? '').replace(/\n$/u, '');
66
+ if (line.length === 0) return { kind: 'error', cause: `git rev-parse ${arg} answered nothing` };
67
+ return { kind: 'ok', line };
68
+ };
69
+
70
+ const identity = (cwd, line, realpath) => {
71
+ try { return { ok: true, path: realpath(resolve(cwd, line)) }; } catch (err) { return { ok: false, cause: `${line} cannot be resolved (${err?.code ?? err?.message ?? err})` }; }
72
+ };
73
+
74
+ const located = (state, cause, facts = {}) => ({ state, cause, top: null, gitDir: null, commonDir: null, ...facts });
75
+
76
+ // resolveGitLocation(cwd, { spawn, env, realpath }) → { state, cause, top, gitDir, commonDir }: the
77
+ // REALPATH identity of the git dir, the common dir and the work-tree top, ambient env vs the
78
+ // GIT_-stripped discovery from cwd. Only `work-tree` proceeds.
79
+ export const resolveGitLocation = (cwd, deps = {}) => {
80
+ const spawn = deps.spawn ?? spawnSync;
81
+ const env = deps.env ?? process.env;
82
+ const realpath = deps.realpath ?? realpathSync;
83
+ const ambient = { cwd, env, spawn };
84
+ const stripped = { cwd, env: stripGitLocationEnv(env), spawn };
85
+ // Precedence (fail closed): the GIT DIR probes decide whether each discovery reaches a repository;
86
+ // the COMMON DIR and TOP probes then owe ok (or no-work-tree for the top) — anything else is error.
87
+ const a = probe('--absolute-git-dir', ambient);
88
+ const s = probe('--absolute-git-dir', stripped);
89
+ if (a.kind === 'error' || a.kind === 'no-work-tree') return located('error', a.cause);
90
+ if (s.kind === 'error' || s.kind === 'no-work-tree') return located('error', `under the GIT_-stripped discovery from ${cwd}: ${s.cause}`);
91
+ if (a.kind === 'not-a-repository' && s.kind === 'not-a-repository') return located('not-a-repository', a.cause);
92
+ if (a.kind === 'not-a-repository') return located('redirected', `the ambient git environment (${LOCATION_VARIABLES}) reaches no repository (${a.cause}) while the discovery from ${cwd} does`);
93
+ if (s.kind === 'not-a-repository') return located('env-only', `the ambient git environment (${LOCATION_VARIABLES}) reaches the git dir ${a.line} while the GIT_-stripped discovery from ${cwd} reaches no repository (${s.cause})`);
94
+ const ids = {};
95
+ const ac = probe('--git-common-dir', ambient);
96
+ const sc = probe('--git-common-dir', stripped);
97
+ if (ac.kind !== 'ok') return located('error', `the common dir under the ambient git environment: ${ac.cause}`);
98
+ if (sc.kind !== 'ok') return located('error', `the common dir under the GIT_-stripped discovery: ${sc.cause}`);
99
+ for (const [name, ax, sx] of [['git dir', a, s], ['common dir', ac, sc]]) {
100
+ const x = identity(cwd, ax.line, realpath);
101
+ const y = identity(cwd, sx.line, realpath);
102
+ if (!x.ok) return located('error', `the ${name} under the ambient git environment: ${x.cause}`);
103
+ if (!y.ok) return located('error', `the ${name} under the GIT_-stripped discovery: ${y.cause}`);
104
+ if (x.path !== y.path) return located('redirected', `the ${name} differs: the ambient git environment (${LOCATION_VARIABLES}) resolves it to ${x.path}, the discovery from ${cwd} to ${y.path}`);
105
+ ids[name] = x.path;
106
+ }
107
+ const at = probe('--show-toplevel', ambient);
108
+ const st = probe('--show-toplevel', stripped);
109
+ if (at.kind === 'error' || at.kind === 'not-a-repository') return located('error', at.cause);
110
+ if (st.kind === 'error' || st.kind === 'not-a-repository') return located('error', `under the GIT_-stripped discovery from ${cwd}: ${st.cause}`);
111
+ if (at.kind === 'no-work-tree' && st.kind === 'no-work-tree') return located('no-work-tree', `the git dir ${ids['git dir']} agrees but git names no work tree (${at.cause})`);
112
+ if (at.kind === 'no-work-tree') return located('redirected', `the ambient git environment (${LOCATION_VARIABLES}) names no work tree (${at.cause}) while the discovery from ${cwd} reaches one`);
113
+ if (st.kind === 'no-work-tree') return located('redirected', `the ambient git environment (${LOCATION_VARIABLES}) reaches a work tree at ${at.line} while the discovery from ${cwd} names none (${st.cause})`);
114
+ const x = identity(cwd, at.line, realpath);
115
+ const y = identity(cwd, st.line, realpath);
116
+ if (!x.ok) return located('error', `the work-tree top under the ambient git environment: ${x.cause}`);
117
+ if (!y.ok) return located('error', `the work-tree top under the GIT_-stripped discovery: ${y.cause}`);
118
+ if (x.path !== y.path) return located('redirected', `the work-tree top differs: the ambient git environment (${LOCATION_VARIABLES}) resolves it to ${x.path}, the discovery from ${cwd} to ${y.path}`);
119
+ return located('work-tree', null, { top: at.line, gitDir: ids['git dir'], commonDir: ids['common dir'] });
120
+ };
@@ -33,6 +33,7 @@ import { spawnSync } from 'node:child_process';
33
33
  import { isDirectRun } from './direct-run.mjs';
34
34
  import { fail } from './orchestration-config.mjs';
35
35
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
36
+ import { stripGitLocationEnv } from './git-env.mjs';
36
37
  // (f) --autonomy (AD-044 Plan 3): the effective per-project autonomy policy for the facts payload.
37
38
  // READ core only — never autonomy-write.mjs (the import-split invariant).
38
39
  import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, isSparseSeedConfig } from './autonomy-config.mjs';
@@ -163,9 +164,9 @@ const systemTempRoots = () => [...new Set([tmpdir(), process.env.TMPDIR, '/tmp']
163
164
 
164
165
  const gitLine = (args, cwd) => {
165
166
  // Ambient GIT_* location vars (GIT_DIR / GIT_WORK_TREE / …) would let rev-parse prove a FOREIGN
166
- // tree — every location answer must come from cwd alone, for every gitLine consumer.
167
- const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k)));
168
- const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', windowsHide: true });
167
+ // tree — every location answer must come from cwd alone, for every gitLine consumer. The strip
168
+ // is the git-env leaf's (GIT_* only — never the test fixtures' hermetic pins).
169
+ const r = spawnSync('git', args, { cwd, env: stripGitLocationEnv(process.env), encoding: 'utf8', windowsHide: true });
169
170
  return r.error || r.status == null ? null : { status: r.status, stdout: r.stdout ?? '' };
170
171
  };
171
172
 
@@ -1,6 +1,7 @@
1
1
  import { lstatSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
4
+ import { classIds, readShippedRobustnessLiterals } from './robustness-literals.mjs';
4
5
  import { segmentPrefixOf, validateSourceSizeConfig } from './source-size-config.mjs';
5
6
  import { getLineCount, isSweep, resolveAnchorCandidates, unique } from './plan-shape.mjs';
6
7
 
@@ -83,6 +84,14 @@ const loadPractice = (root) => {
83
84
  }
84
85
  };
85
86
 
87
+ const loadRobustClasses = (deps) => {
88
+ try {
89
+ return classIds(readShippedRobustnessLiterals(deps));
90
+ } catch (error) {
91
+ throw usageError(`robustness-literals list is unreadable (${error.message})`);
92
+ }
93
+ };
94
+
86
95
  const isInScope = (path, config) => Boolean(config &&
87
96
  config.roots.some((root) => segmentPrefixOf(root, path)) &&
88
97
  !config.exclude.some((excluded) => segmentPrefixOf(excluded, path)) &&
@@ -111,6 +120,12 @@ const compileGlob = (pattern) => {
111
120
  }
112
121
  };
113
122
 
123
+ const expandPatterns = (repoFiles, patterns) => Object.fromEntries(patterns.map((pattern) => {
124
+ const compiled = compileGlob(pattern);
125
+ if (!compiled) throw planError(`unsupported glob in plan path: ${pattern}`);
126
+ return [pattern, repoFiles.filter((path) => compiled.test(path))];
127
+ }));
128
+
114
129
  const matchFilesEntry = (entry, ownedPath) => {
115
130
  const normalized = entry.replace(/^\.\//, '').replace(/\/$/, '');
116
131
  if (normalized === '' || normalized === '.') return { known: true, matches: true };
@@ -185,20 +200,18 @@ const describePath = (root, path, practice, repoFiles, packageCache) => {
185
200
  };
186
201
  };
187
202
 
188
- export const openRepo = (root) => {
203
+ export const openRepo = (root, { robustnessDeps } = {}) => {
189
204
  const repoRoot = realpathSync(root);
190
- return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), packageCache: new Map() };
205
+ return { repoRoot, repoFiles: walkRegularFiles(repoRoot), practice: loadPractice(repoRoot), robustClasses: loadRobustClasses(robustnessDeps), packageCache: new Map() };
191
206
  };
192
207
 
193
- export const buildFacts = (root, { paths = [], repo = openRepo(root) } = {}) => {
194
- const { repoRoot, repoFiles, practice, packageCache } = repo;
195
- const expansions = Object.fromEntries(paths.filter(isSweep).map((pattern) => {
196
- const compiled = compileGlob(pattern);
197
- if (!compiled) throw planError(`unsupported glob in plan path: ${pattern}`);
198
- return [pattern, repoFiles.filter((path) => compiled.test(path))];
199
- }));
208
+ export const expandSweepPaths = (root, patterns) => expandPatterns(walkRegularFiles(realpathSync(root)), patterns);
209
+
210
+ export const buildFacts = (root, { paths = [], robustnessDeps, repo = openRepo(root, { robustnessDeps }) } = {}) => {
211
+ const { repoRoot, repoFiles, practice, robustClasses, packageCache } = repo;
212
+ const expansions = expandPatterns(repoFiles, paths.filter(isSweep));
200
213
  const allPaths = unique([...paths, ...Object.values(expansions).flat()]);
201
214
  const pathFacts = Object.fromEntries(allPaths.map((path) => [path, describePath(repoRoot, path, practice, repoFiles, packageCache)]));
202
215
  const candidates = (suffix, precedingPaths = []) => resolveAnchorCandidates(repoFiles, suffix, precedingPaths);
203
- return { capDeclared: practice.capDeclared, cap: practice.cap, pathFacts, expansions, repoFiles, candidates };
216
+ return { capDeclared: practice.capDeclared, cap: practice.cap, robustClasses, pathFacts, expansions, repoFiles, candidates };
204
217
  };
@@ -1,4 +1,5 @@
1
1
  import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
2
+ import { parseRobustTag } from './robustness-literals.mjs';
2
3
 
3
4
  export const PLAN_TITLE_PREFIX = '# Plan: ';
4
5
  export const PLAN_HEADINGS = Object.freeze([
@@ -243,8 +244,15 @@ const getCurrentLines = (rows, facts, verbs) => rows
243
244
  const checkAuthoring = (parsed, facts) => {
244
245
  const findings = [];
245
246
  const bullets = getVerificationBullets(parsed.document);
247
+ const robustClasses = new Set(facts.robustClasses ?? []);
246
248
  for (const [index, row] of parsed.rows.entries()) {
247
249
  if (!row.valid) continue;
250
+ const robust = parseRobustTag(row.responsibility);
251
+ const unknownRobustClass = robust.classes?.find((classId) => !robustClasses.has(classId));
252
+ if (robust.refusal || unknownRobustClass !== undefined) {
253
+ const reason = robust.refusal ? `tag grammar refused ${robust.refusal}` : `class ${unknownRobustClass} is not in the shipped list`;
254
+ findings.push(makeFinding(row.line, 'robust-class', `${row.id} robust tag: ${reason}`, row.id));
255
+ }
248
256
  const paths = getConcretePaths(row, facts);
249
257
  const expectedKind = row.verb === 'create' ? 'absent' : 'regular';
250
258
  if (paths.length === 0 && isSweep(row.path)) findings.push(makeFinding(row.line, 'kind', `${row.id} sweep resolves to no regular path`, row.id));
@@ -424,6 +424,7 @@ const foldScopeAdvice = (activity, config, plans) => {
424
424
  // only what no canon can: the POPULATED checker commands, and the session register they read. The
425
425
  // register is NEVER defaulted by the tool — naming it here is the whole point of the block.
426
426
  export const SPEC_CHECK_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'spec-check-cli.mjs');
427
+ export const ROBUSTNESS_BRIEF_TOOL = join(dirname(fileURLToPath(import.meta.url)), 'robustness-brief.mjs');
427
428
  const SPEC_OPS_REGISTER = `${PLANS_REL}/spec-ops.list`;
428
429
  const specCheckAdvice = (activity) => {
429
430
  if (activity !== 'plan-execution') return [];
@@ -435,6 +436,16 @@ const specCheckAdvice = (activity) => {
435
436
  ];
436
437
  };
437
438
 
439
+ const robustnessBriefAdvice = (activity, plans) => {
440
+ if (activity !== 'plan-execution') return [];
441
+ const operand = populatedPlan(plans);
442
+ return [
443
+ 'Robustness brief (every tagged ledger row) — generate the concrete literals before dispatch:',
444
+ ` • node ${renderToolPath(ROBUSTNESS_BRIEF_TOOL)} --plan ${operand ?? '<plan-file>'}`,
445
+ ...planDiscoveryCaveat(plans, '--plan', 'populate --plan with the plan in flight.', operand === null ? ['--plan'] : []),
446
+ ];
447
+ };
448
+
438
449
  // The cost-lane advisory block (cost-tiered execution — orchestration.md §5 canon, paraphrased
439
450
  // at the point of use like reviewLoopAdvice paraphrases procedures.md Fold + loop / orchestration §4). Rendered UNCONDITIONALLY for
440
451
  // every activity — the lanes route EVERY step, review-backed or not (unlike reviewLoopAdvice,
@@ -596,7 +607,7 @@ const foldLaneAdvice = ({
596
607
  return foldLaneLines(judgeLedger(ledger, { backend: HELD_RECEIPT_BACKEND, degrades: [] }));
597
608
  };
598
609
 
599
- const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, foldLane }) => {
610
+ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, robustnessBrief, foldLane }) => {
600
611
  const lines = [
601
612
  section,
602
613
  '',
@@ -628,6 +639,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
628
639
  if (advice.length) lines.push('', ...advice);
629
640
  if (foldScope.length) lines.push('', ...foldScope);
630
641
  if (specCheck.length) lines.push('', ...specCheck);
642
+ if (robustnessBrief.length) lines.push('', ...robustnessBrief);
631
643
  lines.push('', ...costLanesAdvice());
632
644
  if (declaredPractice.length) lines.push('', ...declaredPractice);
633
645
  if (warnings.length) {
@@ -637,7 +649,7 @@ const formatHuman = ({ activity, section, slots, warnings, plans, autonomy, flow
637
649
  return lines.join('\n');
638
650
  };
639
651
 
640
- const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, foldLane }) => ({
652
+ const buildJson = ({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed, readersSweep, declaredPractice, foldScope, specCheck, robustnessBrief, foldLane }) => ({
641
653
  activity,
642
654
  section,
643
655
  slots: Object.fromEntries(
@@ -658,6 +670,7 @@ const buildJson = ({ activity, section, slots, configSource, warnings, plans, au
658
670
  foldScope,
659
671
  // ADDITIVE (spec layer 2b): the spec-store block, structured (empty outside plan-execution).
660
672
  specCheck,
673
+ robustnessBrief,
661
674
  foldLane,
662
675
  // ADDITIVE (AD-044 Plan 4): the per-activity autonomy block, structured (empty when unresolvable).
663
676
  autonomy: autonomyAdvice(activity, autonomy),
@@ -752,6 +765,7 @@ export const main = (argv, ctx = {}) => {
752
765
  const declaredPractice = declaredPracticeAdvice(cwd, readFile, lstat);
753
766
  const foldScope = foldScopeAdvice(activity, config, plans);
754
767
  const specCheck = specCheckAdvice(activity);
768
+ const robustnessBrief = robustnessBriefAdvice(activity, plans);
755
769
  const foldLane = foldLaneAdvice({
756
770
  activity,
757
771
  slots,
@@ -763,8 +777,8 @@ export const main = (argv, ctx = {}) => {
763
777
  readHead: ctx.readHeadInstant,
764
778
  });
765
779
  const stdout = json
766
- ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, foldLane }), null, 2)
767
- : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, foldLane });
780
+ ? JSON.stringify(buildJson({ activity, section, slots, configSource, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, robustnessBrief, foldLane }), null, 2)
781
+ : formatHuman({ activity, section, slots, warnings, plans, autonomy, flowHalves, flowArmed: flowState?.armed === true, readersSweep, declaredPractice, foldScope, specCheck, robustnessBrief, foldLane });
768
782
  if (autonomy?.error) {
769
783
  return { code: 1, stdout, stderr: `procedures: malformed ${AUTONOMY_REL} — ${autonomy.error}` };
770
784
  }
@@ -83,6 +83,7 @@ import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
83
83
  import { assertContainedRealPath } from './fs-safe.mjs';
84
84
  import { loadWorktreesConfig, resolveProbeDir } from './worktrees.mjs';
85
85
  import { preflightCheapAgents, EXECUTOR_VEHICLE } from './cheap-agents.mjs';
86
+ import { NODE_EVIDENCE, NODE_EVIDENCE_SCRIPTS, probeNodeEvidence } from './node-evidence.mjs';
86
87
  // The vehicle READINESS comes from the read-only half — the same survey `recipes` and `status` read.
87
88
  import { surveyExecutorVehicle, readStamp, readFsDeps, WORKFLOW_STAMP, EXPECTED_WORKFLOW_VERSION } from './cheap-agents-read.mjs';
88
89
  // The ack store's path, keys, lane registry, fingerprint and guarded reader live in their own leaf
@@ -108,7 +109,12 @@ export { ACKS_FILE, ACKS_LANE_KEY, ACKS_WORKTREES_DIR_KEY, ACKS_COVERAGE_DOMAIN_
108
109
  const HERE = dirname(fileURLToPath(import.meta.url));
109
110
  const toolPath = (rel) => join(HERE, rel);
110
111
  const q = shellQuoteArg;
111
-
112
+ const ENFORCEMENT_ABSENT_CODE = 'ENOENT';
113
+ const ENFORCEMENT_KIT_ROOT = resolve(HERE, '..');
114
+ const ENFORCEMENT_BUNDLE_ROOT = join(ENFORCEMENT_KIT_ROOT, 'references', 'scripts');
115
+ const ENFORCEMENT_WHAT = 'the enforcement scripts are not deployed beside docs/ai (no Node evidence) \u2014 the offer copies them and installs the pre-commit hook';
116
+ const ENFORCEMENT_APPLY_TEMPLATE = 'HAND-APPLY: mkdir -p {root}/scripts && cp {kit}/references/scripts/*.mjs {root}/scripts/ && node {root}/scripts/install-git-hooks.mjs';
117
+ const COMMIT_GUARD_INSTALLER_REMEDY = 'the hook installer (scripts/install-git-hooks.mjs) is not deployed \u2014 deploy the enforcement scripts (bootstrap steps 8\u20139) and install the hook, then re-run';
112
118
  // ── the section contract tokens (doc-parity-bound in upgrade.md + the mode doc) ────────────────
113
119
  export const RECOMMENDATIONS_SECTION_HEADER = '## Recommendations (agent-workflow)';
114
120
  export const RECOMMENDATIONS_EMPTY_LINE = 'no recommendations — flow optimal.';
@@ -166,6 +172,7 @@ export const SEVERITIES = Object.freeze({
166
172
  // offer to enable something.
167
173
  'gate-hook.marker-stale': SEVERITY_ATTENTION,
168
174
  'commit-guard': SEVERITY_OPTIONAL,
175
+ enforcement: SEVERITY_OPTIONAL,
169
176
  'read-lane': SEVERITY_OPTIONAL,
170
177
  'read-lane.stale': SEVERITY_ATTENTION,
171
178
  'read-lane.missing': SEVERITY_ATTENTION,
@@ -249,6 +256,7 @@ export const WHATS = Object.freeze({
249
256
  'gate-hook': '{n} declared gate(s) prompt per run — the gate-approval hook is not wired',
250
257
  'gate-hook.marker-stale': 'the declaration carries the lcovProducer key but the placed hook predates it — the hook goes dark and every gate prompts',
251
258
  'commit-guard': 'the gate matrix is final-run-capable but no commit-guard arms the pre-commit hook — a commit needs no green receipt yet',
259
+ enforcement: ENFORCEMENT_WHAT,
252
260
  'read-lane': 'the gate hook is wired but the read-only compound lane is off — pipes/chains of seeded reads still prompt one by one',
253
261
  'read-lane.stale': 'the read-lane is ON but the placed gate hook is stale — an old hook never reads lanes.json, so the lane is silently dark; reseed it',
254
262
  'read-lane.missing': 'the gate hook is wired but its placed file is missing — every Bash call errors and the read-lane is dark; re-place it',
@@ -313,6 +321,7 @@ export const BENEFITS = Object.freeze({
313
321
  'source-size': 'maintainability — a module you can hold whole stays reviewable, and size drift becomes recorded, reasoned debt',
314
322
  'gate-hook': 'velocity — your own declared gate commands auto-approve byte-exactly (opt-in PreToolUse hook)',
315
323
  'commit-guard': 'integrity — commits require the ONE green --final receipt at the exact staged fingerprint (consented pre-commit arm)',
324
+ enforcement: 'enforcement — bundled checks and the pre-commit hook keep caps, archives, and the generated index current',
316
325
  'read-lane': 'velocity — pipes/chains of your seeded read-only commands auto-approve instead of prompting (opt-in, conservatively classified)',
317
326
  'state-block': 'no silent stalls — a turn ending on «you are not needed», or on work it never started, warns at once instead of waiting to be spotted',
318
327
  'mcp-channel': 'velocity — path facts and literal searches arrive as typed tool calls whose arguments are JSON fields, never a shell string',
@@ -361,6 +370,7 @@ export const OPT_IN_CAPABILITIES = Object.freeze([
361
370
  { id: 'gate-hook', mode: 'hook', advisorKey: 'gate-hook' },
362
371
  { id: 'read-lane', mode: 'hook', advisorKey: 'read-lane' },
363
372
  { id: 'commit-guard', mode: 'commit-guard', advisorKey: 'commit-guard' },
373
+ { id: 'enforcement', mode: 'bootstrap', advisorKey: 'enforcement' },
364
374
  { id: 'state-block', mode: 'state-block-guard', advisorKey: 'state-block' },
365
375
  { id: 'sandbox-masks', mode: 'sandbox-masks', advisorKey: 'sandbox-masks' },
366
376
  { id: 'mcp-channel', mode: 'mcp', advisorKey: 'mcp-channel' },
@@ -843,7 +853,7 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
843
853
  if (!isFinalCapableDeclaration(gates, root)) return; // no receipt to guard yet — no offer
844
854
  const installer = join(root, 'scripts', 'install-git-hooks.mjs');
845
855
  if (!(deps.exists ?? existsSync)(installer)) {
846
- skip('commit-guard', new Error('the hook installer (scripts/install-git-hooks.mjs) is not deployed — run upgrade first'));
856
+ skip('commit-guard', new Error(COMMIT_GUARD_INSTALLER_REMEDY));
847
857
  return;
848
858
  }
849
859
  const hooksPath = (deps.gitHooksPath ?? resolveGitHooksPath)(root);
@@ -866,6 +876,45 @@ const probeCommitGuard = ({ root, deps, add, skip }) => {
866
876
  }
867
877
  };
868
878
 
879
+ const readEnforcementNode = (path, lstat) => {
880
+ try {
881
+ return lstat(path);
882
+ } catch (err) {
883
+ if (err?.code === ENFORCEMENT_ABSENT_CODE) return null;
884
+ throw new Error(`${path}: ${err?.code ?? err?.message ?? err}`);
885
+ }
886
+ };
887
+
888
+ export const probeEnforcement = ({ root, deps, add, skip }) => {
889
+ try {
890
+ const lstat = deps.lstat ?? lstatSync;
891
+ const docsAi = join(root, 'docs', 'ai');
892
+ const docsAiNode = readEnforcementNode(docsAi, lstat);
893
+ if (docsAiNode === null) return;
894
+ if (!docsAiNode.isDirectory()) throw new Error(`${docsAi} is not a plain directory`);
895
+ const evidence = probeNodeEvidence(root, lstat);
896
+ if (evidence.state === NODE_EVIDENCE.PACKAGE_JSON || evidence.state === NODE_EVIDENCE.DEPLOYED_SCRIPTS) return;
897
+ if (evidence.state === NODE_EVIDENCE.UNREADABLE) throw new Error(`${root}: ${evidence.error}`);
898
+ if (evidence.state !== NODE_EVIDENCE.NONE) throw new Error(`unknown Node-evidence state: ${evidence.state}`);
899
+ if (evidence.wrongKind.length > 0) throw new Error(join(root, evidence.wrongKind[0]));
900
+ const readDir = deps.readdir ?? readdirSync;
901
+ const bundleRoot = deps.scriptsBundleRoot ?? ENFORCEMENT_BUNDLE_ROOT;
902
+ const bundleBasenames = readDir(bundleRoot).filter((name) => name.endsWith('.mjs')).sort();
903
+ if (NODE_EVIDENCE_SCRIPTS.some((name) => !bundleBasenames.includes(name))) throw new Error(`${bundleRoot}: a runnable enforcement script is missing`);
904
+ const scripts = join(root, 'scripts');
905
+ const scriptsNode = readEnforcementNode(scripts, lstat);
906
+ if (scriptsNode !== null && !scriptsNode.isDirectory()) throw new Error(`${scripts} is not a plain directory`);
907
+ for (const basename of scriptsNode === null ? [] : bundleBasenames) {
908
+ const destination = join(scripts, basename);
909
+ if (readEnforcementNode(destination, lstat) === null) continue;
910
+ throw new Error(`${destination} already exists; the enforcement copy would clobber it`);
911
+ }
912
+ add('enforcement', ENFORCEMENT_WHAT, fillTemplate(ENFORCEMENT_APPLY_TEMPLATE, { root: q(root), kit: q(ENFORCEMENT_KIT_ROOT) }));
913
+ } catch (err) {
914
+ skip('enforcement', err);
915
+ }
916
+ };
917
+
869
918
  // The read-lane offer (AD-055 Part II, Help-through-Recommendations): once the gate hook is PLACED
870
919
  // and WIRED, offer to enable the opt-in read-only compound lane. Mutually exclusive with the
871
920
  // gate-hook item BY CONSTRUCTION — that item keys on `!wired`, this one on `wired` — so an unplaced
@@ -1242,7 +1291,7 @@ const readReadLaneToggle = (root, deps) => {
1242
1291
  // D3: the risk-marked keys — every key here has a per-item posture note in the mode doc, surfaced
1243
1292
  // at the consent moment; the static contract test asserts EXACT bidirectional coverage
1244
1293
  // (risk-marked keys == mode-doc note keys — a dropped note goes red, not silent).
1245
- export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption']);
1294
+ export const RISK_NOTED_KEYS = Object.freeze(['sandbox-lane', 'read-lane', 'worktrees-dir', 'adr-store-migration', 'gates-inert', 'source-size', 'gate-hook', 'mcp-channel', 'spec-adoption', 'enforcement']);
1246
1295
 
1247
1296
  // The feature-spec layer's adoption state (contract: kit/spec-adoption). The canon lets a plan cite
1248
1297
  // zero governing specs while a project adopts the layer, and nothing ever said whether adoption had
@@ -1547,6 +1596,7 @@ const PROBES = Object.freeze([
1547
1596
  probeGatesInert,
1548
1597
  probeSourceSize,
1549
1598
  probeCommitGuard,
1599
+ probeEnforcement,
1550
1600
  probeReadLane,
1551
1601
  probeStateBlockHook,
1552
1602
  probeCheapAgents,
@@ -39,6 +39,35 @@ const LINE_BREAKING_ALL = new RegExp(LINE_BREAKING_SOURCE, 'gu');
39
39
  export const isRenderableLine = (value) => typeof value === 'string' && !LINE_BREAKING.test(value);
40
40
  export const escapeForDisplay = (value) => String(value).replace(LINE_BREAKING_ALL, (ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, '0')}`);
41
41
 
42
+ // The control-byte gate's predicate over ONE byte (an integer 0..255, a Buffer element): C0 minus
43
+ // TAB, LF and CR, plus DEL. Bytes at or above 0x80 are never judged here (UTF-8 validity and C1
44
+ // controls are the gate's stated non-goals). ONE home, beside the artifact-path rule below, which
45
+ // keeps refusing TAB, LF and CR as its bash twins do.
46
+ export const isRefusedControlByte = (byte) => byte <= 0x08 || byte === 0x0b || byte === 0x0c || (byte >= 0x0e && byte <= 0x1f) || byte === 0x7f;
47
+
48
+ // The injective renderer of a path's raw BYTES for a report line: bytes decode as UTF-8 where a
49
+ // sequence is valid, every LINE_BREAKING code point and a valid U+FFFD render as \u{XXXX}, an invalid
50
+ // byte as \xNN and a backslash as \\ — so two names differing only in an unrenderable byte render differently,
51
+ // a name carrying a raw byte differs from a name spelling its escape, and no output line carries a
52
+ // raw control byte or U+FFFD. Separate from escapeForDisplay, whose string-rendering consumers keep their contract.
53
+ const utf8SequenceLength = (lead) => (lead < 0x80 ? 1 : (lead & 0xe0) === 0xc0 ? 2 : (lead & 0xf0) === 0xe0 ? 3 : (lead & 0xf8) === 0xf0 ? 4 : 0);
54
+ export const renderPathForDisplay = (bytes) => {
55
+ let out = '';
56
+ for (let i = 0; i < bytes.length;) {
57
+ const length = utf8SequenceLength(bytes[i]);
58
+ const slice = length > 0 && i + length <= bytes.length ? bytes.subarray(i, i + length) : null;
59
+ const decoded = slice === null ? null : slice.toString('utf8');
60
+ if (decoded === null || !Buffer.from(decoded, 'utf8').equals(slice)) {
61
+ out += `\\x${bytes[i].toString(16).padStart(2, '0')}`;
62
+ i += 1;
63
+ continue;
64
+ }
65
+ out += decoded === '\\' ? '\\\\' : LINE_BREAKING.test(decoded) || decoded.codePointAt(0) === 0xfffd ? `\\u{${decoded.codePointAt(0).toString(16)}}` : decoded;
66
+ i += length;
67
+ }
68
+ return out;
69
+ };
70
+
42
71
  // The receipt encoder's carriability rule for an artifact path (S21), the JS twin of the wrappers'
43
72
  // refuse_uncarriable_artifact_byte: a quote, a backslash, a C0 control or DEL — deliberately NOT
44
73
  // \p{Cc} (C1 is the declared residual, and the two normalizations are parity-pinned on this set).