@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.
- package/CHANGELOG.md +52 -0
- package/README.md +6 -3
- package/SKILL.md +9 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +87 -16
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +3 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +4 -2
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +3 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +90 -19
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +4 -0
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/references/driving-codex.md +3 -2
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +3 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +1 -1
- package/references/modes/grounding.md +8 -7
- package/references/modes/recommendations.md +14 -0
- package/references/modes/review-state.md +1 -1
- package/references/modes/sandbox-masks.md +15 -0
- package/references/modes/upgrade.md +10 -7
- package/references/modes/velocity.md +13 -2
- package/references/shared/composition-handoff.md +21 -16
- package/references/shared/report-footer.md +5 -1
- package/references/templates/AGENTS.md +2 -1
- package/references/templates/autonomy.json +3 -0
- package/tools/autonomy-config.mjs +13 -3
- package/tools/commands.mjs +15 -1
- package/tools/delegation.mjs +9 -5
- package/tools/detect-backends.mjs +2 -2
- package/tools/doc-parity.mjs +8 -0
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +21 -7
- package/tools/fold-completeness-run.mjs +5 -0
- package/tools/grounding.mjs +139 -22
- package/tools/inject-methodology.mjs +117 -28
- package/tools/manifest/schema.md +20 -0
- package/tools/manifest/validate.mjs +26 -0
- package/tools/procedures.mjs +61 -8
- package/tools/recipes.mjs +94 -15
- package/tools/recommendations.mjs +469 -0
- package/tools/review-ledger-write.mjs +14 -0
- package/tools/review-ledger.mjs +3 -2
- package/tools/review-state.mjs +101 -24
- package/tools/run-gates.mjs +3 -0
- package/tools/sandbox-masks.mjs +370 -0
- package/tools/set-recipe.mjs +13 -1
- 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)));
|
package/tools/set-recipe.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|