@sabaiway/agent-workflow-kit 1.44.0 → 1.45.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 (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +6 -3
  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 +87 -16
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
  8. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  9. package/bridges/codex-cli-bridge/SKILL.md +1 -1
  10. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
  11. package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
  12. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
  13. package/bridges/codex-cli-bridge/capability.json +3 -2
  14. package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
  15. package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
  16. package/capability.json +1 -1
  17. package/package.json +1 -1
  18. package/references/modes/bootstrap.md +1 -1
  19. package/references/modes/grounding.md +8 -7
  20. package/references/modes/recommendations.md +14 -0
  21. package/references/modes/review-state.md +1 -1
  22. package/references/modes/sandbox-masks.md +15 -0
  23. package/references/modes/upgrade.md +10 -7
  24. package/references/modes/velocity.md +13 -2
  25. package/references/shared/composition-handoff.md +21 -16
  26. package/references/shared/report-footer.md +5 -1
  27. package/references/templates/AGENTS.md +2 -1
  28. package/references/templates/autonomy.json +3 -0
  29. package/tools/autonomy-config.mjs +13 -3
  30. package/tools/commands.mjs +15 -1
  31. package/tools/delegation.mjs +9 -5
  32. package/tools/detect-backends.mjs +2 -2
  33. package/tools/doc-parity.mjs +8 -0
  34. package/tools/engine-source.mjs +6 -0
  35. package/tools/family-registry.mjs +21 -7
  36. package/tools/fold-completeness-run.mjs +5 -0
  37. package/tools/grounding.mjs +139 -22
  38. package/tools/inject-methodology.mjs +117 -28
  39. package/tools/manifest/schema.md +20 -0
  40. package/tools/manifest/validate.mjs +26 -0
  41. package/tools/procedures.mjs +61 -8
  42. package/tools/recipes.mjs +94 -15
  43. package/tools/recommendations.mjs +469 -0
  44. package/tools/review-ledger-write.mjs +14 -0
  45. package/tools/review-ledger.mjs +3 -2
  46. package/tools/review-state.mjs +101 -24
  47. package/tools/run-gates.mjs +3 -0
  48. package/tools/sandbox-masks.mjs +370 -0
  49. package/tools/set-recipe.mjs +13 -1
  50. package/tools/velocity-profile.mjs +228 -22
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ // sandbox-masks.mjs — the GUARDED cosmetic exclude lane behind `/agent-workflow-kit sandbox-masks`
3
+ // (AD-044 Plan 4, Phase 1.5; design consult codex+agy CONVERGED on B+D — probe-DERIVED, never a
4
+ // frozen list). An OS sandbox (Claude Code) injects character-device masks into the work tree;
5
+ // the review domain already ignores them BY CONSTRUCTION (review-state.mjs + both bridge wrappers,
6
+ // Decision 1), so this lane is COSMETIC ONLY: it hides the masks from `git status` (and from every
7
+ // other --exclude-standard untracked walk) via ONE managed fenced block in the repo's
8
+ // `git rev-parse --git-path info/exclude` file.
9
+ //
10
+ // Contract:
11
+ // flagless READ-ONLY probe/preview. Derives the CURRENT mask set from the UNFILTERED
12
+ // untracked walk (`git ls-files --others -z`, WITHOUT standard excludes — an
13
+ // already-excluded mask must stay visible to a rerun) + lstat classification.
14
+ // ONLY the never-committable classes (char/block devices, FIFOs, sockets — the
15
+ // SAME class set as the review-domain filter) are ever candidates; tracked /
16
+ // regular / directory / symlink / gitlink / missing paths can never enter the
17
+ // block by construction. The probe also REVALIDATES the existing fenced entries
18
+ // and loudly flags any that became a REAL path (delete that line first — an
19
+ // excluded real file is silently skipped by bulk staging, `git add -A`/`git add .`,
20
+ // and `.git/info/exclude` never warns). No standing detector: the flags ride probe runs.
21
+ // --apply consent-gated FULL-BLOCK REPLACE from a fresh derivation (stale masks drop by
22
+ // construction — never append-only). An apply whose derivation is EMPTY while a
23
+ // non-empty block exists (e.g. accidentally run outside the sandbox) REFUSES
24
+ // unless --clear is also given; an empty derivation with no existing block
25
+ // reports "0 masks visible" and no-ops.
26
+ // --clear (only with --apply) remove the managed block entirely. Takes PRECEDENCE over
27
+ // the derivation — with masks still visible the block is removed, never re-written.
28
+ //
29
+ // Writes ONLY its own fenced block in the file `git rev-parse --git-path info/exclude` names —
30
+ // never .gitignore, never a global excludesFile, never outside the fence. The fence is SEPARATE
31
+ // from the hidden-mode reconcile's block (hide-footprint.mjs — that lane noops on visible
32
+ // deployments; this one serves them too). A malformed fence (start without end / duplicated
33
+ // markers) fails CLOSED: loud report, file untouched. Patterns are root-anchored (`/rel`),
34
+ // glob-metacharacters escaped, NUL-safe walk. Dependency-free, Node >= 18; no side effects on
35
+ // import (the isDirectRun idiom).
36
+
37
+ import { lstatSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
38
+ import { join, resolve, dirname } from 'node:path';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
+ import { spawnSync } from 'node:child_process';
41
+ import { fail } from './orchestration-config.mjs';
42
+ import { isNeverCommittableStat, shellQuoteArg } from './review-state.mjs';
43
+ import { assertContainedRealPath } from './fs-safe.mjs';
44
+
45
+ export const MASKS_FENCE_START = '# >>> agent-workflow sandbox-masks — managed block, fully REPLACED by the kit sandbox-masks lane; do not hand-edit inside >>>';
46
+ export const MASKS_FENCE_END = '# <<< agent-workflow sandbox-masks <<<';
47
+
48
+ const HERE = dirname(fileURLToPath(import.meta.url));
49
+
50
+ // ── git plumbing (read-only queries; injectable for tests) ─────────────────────────
51
+
52
+ const gitLine = (args, cwd) => {
53
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
54
+ if (r.error || r.status !== 0) return null;
55
+ return r.stdout.replace(/\r?\n$/, '');
56
+ };
57
+
58
+ // The UNFILTERED untracked walk: --others WITHOUT --exclude-standard, so a mask already hidden by
59
+ // an earlier apply stays visible to a rerun (the full-block replace re-derives it, codex).
60
+ const listUntrackedUnfilteredZ = (root) => {
61
+ const r = spawnSync('git', ['ls-files', '--others', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, windowsHide: true });
62
+ if (r.error || r.status !== 0) return null;
63
+ return r.stdout.split('\0').filter(Boolean);
64
+ };
65
+
66
+ // ── derivation (the D5 guard IS the classifier: only never-committable classes survive) ────────
67
+
68
+ // A root-anchored gitignore pattern for one relative path: leading `/` (root-anchored — a mask
69
+ // name can never shadow a deeper real path), glob metacharacters + leading-! / leading-# handled
70
+ // by the anchor, trailing whitespace escaped (gitignore trims it otherwise).
71
+ // Trailing SPACES are escapable in gitignore (`\ `); a trailing TAB is not expressible — such a
72
+ // name is classified unrenderable by the derivation (codex R12), so it never reaches this renderer.
73
+ export const toExcludePattern = (rel) => {
74
+ const escaped = rel.replace(/([\\*?[\]])/g, '\\$1').replace(/ +$/, (m) => '\\ '.repeat(m.length));
75
+ return `/${escaped}`;
76
+ };
77
+
78
+ // deriveMasks({ root, lstat, listUntracked }) → { masks, unrenderable }: sorted rel paths whose
79
+ // lstat class is never-committable (char/block/FIFO/socket). Everything else is refused by
80
+ // construction — tracked paths never appear in an --others walk; regular/dir/symlink/missing fail
81
+ // the predicate. A CR/LF-carrying mask NAME cannot be expressed as ONE gitignore rule — rendering
82
+ // it would split into several rules and could hide unrelated committable paths (codex R3) — so it
83
+ // is a LOUD unrenderable skip, never written (the review domain already ignores the mask itself).
84
+ export const deriveMasks = ({ root, lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ } = {}) => {
85
+ const entries = listUntracked(root);
86
+ if (entries == null) return null;
87
+ const masks = [];
88
+ const unrenderable = [];
89
+ for (const rel of entries) {
90
+ let stat = null;
91
+ try {
92
+ stat = lstat(join(root, rel));
93
+ } catch {
94
+ stat = null;
95
+ }
96
+ if (!isNeverCommittableStat(stat)) continue;
97
+ // CR/LF anywhere, or a trailing TAB (inexpressible in gitignore — only trailing SPACES escape
98
+ // as `\ `; codex R12), make the name unrenderable as ONE exclude rule.
99
+ if (/[\r\n]/.test(rel) || /\t$/.test(rel)) unrenderable.push(rel);
100
+ else masks.push(rel);
101
+ }
102
+ return { masks: masks.sort(), unrenderable: unrenderable.sort() };
103
+ };
104
+
105
+ // ── the fence (own block; malformed → fail closed) ─────────────────────────────────
106
+
107
+ export const findMasksFence = (lines) => {
108
+ const starts = lines.map((l, i) => [l.trim(), i]).filter(([t]) => t === MASKS_FENCE_START).map(([, i]) => i);
109
+ const ends = lines.map((l, i) => [l.trim(), i]).filter(([t]) => t === MASKS_FENCE_END).map(([, i]) => i);
110
+ if (starts.length === 0 && ends.length === 0) return { state: 'absent' };
111
+ if (starts.length !== 1 || ends.length !== 1) return { state: 'malformed', reason: 'duplicated fence marker(s)' };
112
+ if (ends[0] < starts[0]) return { state: 'malformed', reason: 'end marker precedes start marker' };
113
+ return { state: 'ok', startIdx: starts[0], endIdx: ends[0], body: lines.slice(starts[0] + 1, ends[0]) };
114
+ };
115
+
116
+ // A fenced pattern back to its relative path (the derivation writes only `/rel` root-anchored
117
+ // escaped forms; unescape is its exact inverse — the escaped-space form `\ ` included, so a
118
+ // trailing-space mask round-trips; codex R1). Non-pattern lines (blank/comment) are skipped;
119
+ // only UNESCAPED trailing whitespace is insignificant (gitignore semantics).
120
+ const patternToRel = (line) => {
121
+ // A trailing CR (a CRLF-saved exclude file) strips FIRST — it would otherwise ride into the rel
122
+ // name and false-ENOENT the revalidation lstat (agy R5); then only UNESCAPED trailing
123
+ // whitespace is insignificant (gitignore semantics — an escaped `\ ` survives).
124
+ const t = line.replace(/\r$/, '').replace(/^[ \t]+/, '').replace(/(?<!\\)[ \t]+$/, '');
125
+ if (t === '' || t.startsWith('#') || !t.startsWith('/')) return null;
126
+ return t.slice(1).replace(/\\([\\*?[\] ])/g, '$1');
127
+ };
128
+
129
+ // Revalidate the CURRENT fence body against the disk: an entry whose path now exists as anything
130
+ // OTHER than a never-committable class is a STALE-REAL flag — the D5 watch (a real file at an
131
+ // excluded path is silently skipped by bulk staging; delete the line before trusting git with it).
132
+ export const revalidateFence = (bodyLines, { root, lstat = lstatSync } = {}) => {
133
+ const staleReal = [];
134
+ for (const line of bodyLines) {
135
+ const rel = patternToRel(line);
136
+ if (rel == null) continue;
137
+ let stat = null;
138
+ try {
139
+ stat = lstat(join(root, rel));
140
+ } catch {
141
+ continue; // vanished mask — the next --apply drops it by construction, nothing real is hidden
142
+ }
143
+ if (!isNeverCommittableStat(stat)) staleReal.push(rel);
144
+ }
145
+ return staleReal;
146
+ };
147
+
148
+ // ── the probe (read-only; exported — the Recommendations advisor consumes it) ──────
149
+
150
+ // probeSandboxMasks({ cwd, ... }) → everything the render/apply/advisor needs, or null when not a
151
+ // git work tree. Read-only always.
152
+ export const probeSandboxMasks = ({ cwd = process.cwd(), lstat = lstatSync, listUntracked = listUntrackedUnfilteredZ, readFile = readFileSync } = {}) => {
153
+ const root = gitLine(['rev-parse', '--show-toplevel'], cwd);
154
+ if (root == null) return null;
155
+ const gitPathRaw = gitLine(['rev-parse', '--git-path', 'info/exclude'], cwd);
156
+ const commonDirRaw = gitLine(['rev-parse', '--git-common-dir'], cwd);
157
+ if (gitPathRaw == null || commonDirRaw == null) return null;
158
+ const excludeFile = resolve(cwd, gitPathRaw);
159
+ // The write-containment root (codex R2): info/exclude lives in the COMMON git dir (worktree-safe),
160
+ // and the apply must never write through a symlinked component or a non-regular leaf.
161
+ const gitCommonDir = resolve(cwd, commonDirRaw);
162
+ const derived = deriveMasks({ root, lstat, listUntracked });
163
+ if (derived == null) return null;
164
+ const { masks, unrenderable } = derived;
165
+ // The WHOLE chain is guarded BEFORE any read (codex R7+R8): a symlinked exclude leaf OR a
166
+ // symlinked parent (.git/info) must never be read through, and a FIFO/socket leaf would HANG
167
+ // the read-only probe — parent-chain containment + leaf lstat first, fail closed on everything
168
+ // but a plain absent file, then read the existing REGULAR content. The apply re-checks
169
+ // (defense in depth) before writing.
170
+ try {
171
+ assertContainedRealPath(gitCommonDir, excludeFile, { lstat });
172
+ } catch (err) {
173
+ throw fail(1, `refusing to touch ${excludeFile}: ${err.message}`);
174
+ }
175
+ // The chain guard above already refused symlinked components (the leaf included) and PROPAGATED
176
+ // any non-ENOENT lstat failure — after it, the leaf can only be absent or exist non-symlinked.
177
+ const leafStat = (() => {
178
+ try {
179
+ return lstat(excludeFile);
180
+ } catch {
181
+ return null; // absent (ENOENT) — the only reachable arm after the chain guard
182
+ }
183
+ })();
184
+ if (leafStat != null && !leafStat.isFile()) {
185
+ throw fail(1, `refusing to open ${excludeFile}: the exclude path exists but is not a regular file (a FIFO/device read would hang)`);
186
+ }
187
+ let content = '';
188
+ if (leafStat != null) {
189
+ try {
190
+ content = readFile(excludeFile, 'utf8');
191
+ } catch (err) {
192
+ // The file existed a moment ago — ANY read failure now fails CLOSED (an apply over a misread
193
+ // "empty" file would overwrite hand content outside the managed block; codex R4).
194
+ throw fail(1, `cannot read ${excludeFile} (${err?.code ?? err?.message ?? err}) — refusing to treat an unreadable exclude file as empty`);
195
+ }
196
+ }
197
+ const lines = content.split('\n');
198
+ const fence = findMasksFence(lines);
199
+ const staleReal = fence.state === 'ok' ? revalidateFence(fence.body, { root, lstat }) : [];
200
+ const applyCmd = `node ${shellQuoteArg(join(HERE, 'sandbox-masks.mjs'))} --cwd ${shellQuoteArg(root)} --apply`;
201
+ return { root, excludeFile, gitCommonDir, content, lines, fence, masks, unrenderable, staleReal, applyCmd };
202
+ };
203
+
204
+ // needsMasksApply(probe) → whether the CURRENT derivation diverges from the fenced block — the
205
+ // Recommendations advisor's fire condition (Phase 3, AD-044 Plan 4). True when any fenced entry
206
+ // became a real path (stale-real), or when visible masks exist and the derived set ≠ the fenced
207
+ // set. A malformed fence is NOT an apply item (the apply would fail closed — it needs a hand fix,
208
+ // which the probe render itself flags loudly). Pure over the probe result.
209
+ export const needsMasksApply = (probe) => {
210
+ if (probe == null) return false;
211
+ if (probe.staleReal.length > 0) return true;
212
+ if (probe.fence.state === 'malformed') return false;
213
+ const fenced = probe.fence.state === 'ok' ? probe.fence.body.map(patternToRel).filter((r) => r != null) : [];
214
+ return probe.masks.length > 0 && [...probe.masks].sort().join('\0') !== [...fenced].sort().join('\0');
215
+ };
216
+
217
+ // ── the apply plan (pure: probe → new file content or a refusal) ───────────────────
218
+
219
+ export const planApply = (probe, { clear = false } = {}) => {
220
+ if (probe.fence.state === 'malformed') {
221
+ return { action: 'refuse', reason: `malformed managed block in ${probe.excludeFile} (${probe.fence.reason}) — fix it by hand; the file was left unchanged` };
222
+ }
223
+ // --clear takes PRECEDENCE over the derivation (codex, Segment-A receipt): it means "remove the
224
+ // managed block", even while masks are visible — re-writing the block instead of the requested
225
+ // clear would be a silent divergence. An EMPTY fence (markers, no entries) clears too (codex R8);
226
+ // no fence at all is a stated no-op.
227
+ if (clear && probe.fence.state !== 'ok') {
228
+ return { action: 'noop', reason: 'no managed block present — nothing to clear' };
229
+ }
230
+ const hasBlock = probe.fence.state === 'ok' && probe.fence.body.some((l) => patternToRel(l) != null);
231
+ if (!clear && probe.masks.length === 0 && hasBlock) {
232
+ return { action: 'refuse', reason: '0 masks visible but a non-empty managed block exists — you are probably OUTSIDE the sandbox (the masks are in-sandbox only). Re-run inside the sandbox, or pass --apply --clear to intentionally remove the block' };
233
+ }
234
+ if (!clear && probe.masks.length === 0 && !hasBlock) {
235
+ return { action: 'noop', reason: '0 masks visible (outside the sandbox or clean) — nothing to write' };
236
+ }
237
+ const masks = clear ? [] : probe.masks;
238
+ const block = masks.length === 0 ? [] : [MASKS_FENCE_START, ...masks.map(toExcludePattern), MASKS_FENCE_END];
239
+ // EVERY hand byte outside the managed fence is preserved EXACTLY (codex R5+R6): the first-apply
240
+ // append adds at most ONE separator newline, and the existing-block replace splices via RAW
241
+ // string offsets around the fence lines — a split/join('\n') rebuild would silently normalize
242
+ // CRLF hand content outside the block. Only the fenced block itself is ours (always LF).
243
+ const rendered = block.length ? `${block.join('\n')}\n` : '';
244
+ let content;
245
+ if (probe.fence.state === 'ok') {
246
+ const offsetOf = (lineIdx) => probe.lines.slice(0, lineIdx).reduce((n, l) => n + l.length + 1, 0);
247
+ const rawStart = offsetOf(probe.fence.startIdx);
248
+ const endLineStop = offsetOf(probe.fence.endIdx) + probe.lines[probe.fence.endIdx].length;
249
+ const rawEnd = endLineStop < probe.content.length ? endLineStop + 1 : endLineStop; // consume the end-marker's own newline when present
250
+ content = `${probe.content.slice(0, rawStart)}${rendered}${probe.content.slice(rawEnd)}`;
251
+ } else {
252
+ const head = probe.content === '' ? '' : probe.content.endsWith('\n') ? probe.content : `${probe.content}\n`;
253
+ content = `${head}${rendered}`;
254
+ }
255
+ return { action: masks.length === 0 ? 'cleared' : 'replaced', content, count: masks.length };
256
+ };
257
+
258
+ // ── rendering ───────────────────────────────────────────────────────────────────────
259
+
260
+ const formatProbe = (probe) => {
261
+ const lines = [
262
+ `sandbox-masks — never-committable untracked masks (device/FIFO/socket) in ${probe.root}`,
263
+ ` exclude file: ${probe.excludeFile}`,
264
+ ` managed block: ${probe.fence.state === 'ok' ? `present (${probe.fence.body.filter((l) => patternToRel(l) != null).length} entr(ies))` : probe.fence.state}`,
265
+ ];
266
+ if (probe.fence.state === 'malformed') lines.push(` ⚠ MALFORMED managed block (${probe.fence.reason}) — this lane fails closed and will not write until it is fixed by hand`);
267
+ lines.push(` masks visible now: ${probe.masks.length === 0 ? '0 (outside the sandbox or clean)' : ''}`);
268
+ for (const m of probe.masks) lines.push(` · ${m}`);
269
+ for (const rel of probe.staleReal) {
270
+ lines.push(` ⚠ fenced entry became a REAL path: ${rel} — delete its line from the managed block BEFORE relying on git for this path: an excluded real file is silently skipped by bulk staging (git add -A / git add .), so it stays OUT of your commits, and an explicit git add refuses it without -f; .git/info/exclude never warns`);
271
+ }
272
+ for (const rel of probe.unrenderable) {
273
+ lines.push(` ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`);
274
+ }
275
+ // A stale-real-only fence (empty derivation over a non-empty block) makes the plain --apply
276
+ // REFUSE — the rendered one-liner must be the form planApply actually accepts (--clear), the
277
+ // same rule the Recommendations item applies (codex terminal).
278
+ const applyLine = probe.masks.length === 0 && probe.staleReal.length > 0 ? `${probe.applyCmd} --clear` : probe.applyCmd;
279
+ lines.push('', probe.masks.length > 0 || probe.fence.state === 'ok'
280
+ ? ` apply (consent-gated, full-block replace): ${applyLine}`
281
+ : ' nothing to apply.');
282
+ return lines.join('\n');
283
+ };
284
+
285
+ const HELP = `sandbox-masks — cosmetic exclude lane for sandbox-injected device masks (agent-workflow kit, AD-044).
286
+
287
+ Usage:
288
+ node sandbox-masks.mjs [--cwd <dir>] [--json] # READ-ONLY probe/preview
289
+ node sandbox-masks.mjs [--cwd <dir>] --apply [--clear] # consent-gated FULL-BLOCK replace
290
+
291
+ The review domain already ignores never-committable untracked paths (char/block devices, FIFOs,
292
+ sockets) BY CONSTRUCTION — this lane is cosmetic: it hides the CURRENT, probe-derived mask set
293
+ from \`git status\` via one managed fenced block in \`git rev-parse --git-path info/exclude\`.
294
+ Only that block is ever written — never .gitignore, never a global excludesFile. --apply REPLACES
295
+ the whole block from a fresh derivation (stale masks drop by construction); an EMPTY derivation
296
+ over a non-empty block refuses unless --clear is given (you are probably outside the sandbox).
297
+ --clear always means REMOVE the managed block — it takes precedence over the derivation.
298
+ Watch note: an exclude rule hides a path from git status AND from bulk staging — a real file at
299
+ an excluded path is silently skipped by \`git add -A\`/\`git add .\` (explicit \`git add\` refuses
300
+ without -f), so it would stay out of your commits. The probe flags any fenced entry that became a
301
+ real path; delete that line first.
302
+
303
+ Exit codes: 0 ok (probe / applied / no-op); 1 refusal or error (loud); 2 usage.`;
304
+
305
+ const KNOWN_ARGS = new Set(['--help', '-h', '--apply', '--clear', '--json', '--cwd']);
306
+
307
+ export const main = (argv, ctx = {}) => {
308
+ try {
309
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
310
+ for (let i = 0; i < argv.length; i += 1) {
311
+ if (argv[i] === '--cwd') { i += 1; continue; }
312
+ if (!KNOWN_ARGS.has(argv[i])) throw fail(2, `unknown argument: ${argv[i]} (see --help)`);
313
+ }
314
+ const cwdIdx = argv.indexOf('--cwd');
315
+ const cwd = cwdIdx !== -1 ? argv[cwdIdx + 1] : (ctx.cwd ?? process.cwd());
316
+ if (cwdIdx !== -1 && (!cwd || cwd.startsWith('--'))) throw fail(2, '--cwd requires a directory argument');
317
+ if (argv.includes('--clear') && !argv.includes('--apply')) throw fail(2, '--clear is only valid together with --apply');
318
+ const probe = probeSandboxMasks({ cwd, ...(ctx.deps ?? {}) });
319
+ if (probe == null) throw fail(1, `not a git work tree: ${cwd}`);
320
+ if (!argv.includes('--apply')) {
321
+ if (argv.includes('--json')) {
322
+ const { lines: _lines, content: _content, ...rest } = probe;
323
+ return { code: 0, stdout: JSON.stringify(rest, null, 2), stderr: '' };
324
+ }
325
+ return { code: 0, stdout: formatProbe(probe), stderr: '' };
326
+ }
327
+ const plan = planApply(probe, { clear: argv.includes('--clear') });
328
+ // The unrenderable skips stay LOUD on EVERY apply path (codex R4) — the same warning the
329
+ // probe prints; the renderable subset still applies (a refusal would block the useful masks).
330
+ const skipWarnings = probe.unrenderable
331
+ .map((rel) => `sandbox-masks: ⚠ mask name carries a newline or ends with a tab and cannot be expressed as ONE exclude rule — NOT written (the review domain already ignores the mask itself): ${JSON.stringify(rel)}`)
332
+ .join('\n');
333
+ // A refusal carries the unrenderable warnings too (codex R5) — they are never discarded.
334
+ if (plan.action === 'refuse') throw fail(1, skipWarnings ? `${plan.reason}\n${skipWarnings}` : plan.reason);
335
+ if (plan.action === 'noop') return { code: 0, stdout: `sandbox-masks: ${plan.reason}`, stderr: skipWarnings };
336
+ const writeFile = ctx.deps?.writeFile ?? writeFileSync;
337
+ const mkdir = ctx.deps?.mkdir ?? mkdirSync;
338
+ const lstat = ctx.deps?.lstat ?? lstatSync;
339
+ // The write never follows a symlink or clobbers a non-regular leaf (codex R2): every component
340
+ // from the common git dir down is guarded, and an existing exclude leaf must be a regular file.
341
+ try {
342
+ assertContainedRealPath(probe.gitCommonDir, probe.excludeFile, { lstat });
343
+ } catch (err) {
344
+ throw fail(1, `refusing to write ${probe.excludeFile}: ${err.message}`);
345
+ }
346
+ const leaf = (() => {
347
+ try {
348
+ return lstat(probe.excludeFile);
349
+ } catch {
350
+ return null;
351
+ }
352
+ })();
353
+ if (leaf != null && !leaf.isFile()) throw fail(1, `refusing to write ${probe.excludeFile}: the exclude path exists but is not a regular file`);
354
+ mkdir(dirname(probe.excludeFile), { recursive: true });
355
+ writeFile(probe.excludeFile, plan.content);
356
+ const verb = plan.action === 'cleared' ? 'managed block removed (0 masks)' : `managed block replaced — ${plan.count} mask(s) hidden from git status`;
357
+ return { code: 0, stdout: `sandbox-masks: ${verb} in ${probe.excludeFile}`, stderr: skipWarnings };
358
+ } catch (err) {
359
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `sandbox-masks: ${err.message}` };
360
+ }
361
+ };
362
+
363
+ const emitResult = (r) => {
364
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
365
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
366
+ process.exitCode = r.code;
367
+ };
368
+
369
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
370
+ if (isDirectRun) emitResult(main(process.argv.slice(2)));
@@ -26,6 +26,7 @@ import { homedir } from 'node:os';
26
26
  import { pathToFileURL } from 'node:url';
27
27
  import { detectBackends } from './detect-backends.mjs';
28
28
  import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
29
+ import { loadAutonomy, resolveAutonomy } from './autonomy-config.mjs';
29
30
  import {
30
31
  CONFIG_REL,
31
32
  fail,
@@ -208,7 +209,18 @@ export const main = (argv, ctx = {}) => {
208
209
  validateConfig(after); // defensive re-validate immediately before the write
209
210
  const { writtenPath } = writeConfig(cwd, after, ctx);
210
211
  const fileBody = serializeConfig(after);
211
- const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection);
212
+ // The echoed handover line carries the SAME autonomy levels recipes --active-line renders —
213
+ // sync facts (no render-check needed for the cells; codex R1, Segment B). A malformed policy
214
+ // surfaces loudly through the line's own MALFORMED segment.
215
+ const autonomyFacts = (() => {
216
+ try {
217
+ const { config: autonomyConfig, source } = loadAutonomy(cwd, ctx.readFileSync ?? readFileSync, ctx.lstatSync ?? lstatSync);
218
+ return { source, ...resolveAutonomy(autonomyConfig) };
219
+ } catch (err) {
220
+ return { error: (err && err.message) || String(err) };
221
+ }
222
+ })();
223
+ const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection, autonomyFacts);
212
224
  const stdout = json
213
225
  ? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
214
226
  : formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });