@lyupro/codex-bridge 0.6.2 → 0.6.5

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 (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +3 -3
  3. package/cli/runs-inventory.mjs +5 -3
  4. package/cli/stop.mjs +2 -2
  5. package/cli/unlock.mjs +2 -2
  6. package/package.json +2 -2
  7. package/src/agents/codex-advisor.md +187 -0
  8. package/src/agents/codex-build.md +4 -0
  9. package/src/commands/env.md +5 -4
  10. package/src/home/hooks/live-runs.mjs +19 -21
  11. package/src/home/hooks/reply-guard.mjs +5 -2
  12. package/src/home/hooks/shell-write-intent.mjs +320 -154
  13. package/src/home/hooks/worktree-lock.mjs +9 -2
  14. package/src/home/hooks/worktree-witness.mjs +21 -40
  15. package/src/home/lib/agents.mjs +3 -0
  16. package/src/home/lib/budgets.mjs +64 -0
  17. package/src/home/lib/config-validate.mjs +3 -39
  18. package/src/home/lib/meta/advice-status.mjs +61 -0
  19. package/src/home/lib/meta/advice-verdict.mjs +226 -0
  20. package/src/home/lib/meta/advisor-task.mjs +69 -0
  21. package/src/home/lib/meta/events.mjs +4 -0
  22. package/src/home/lib/meta/launch-rows.mjs +45 -0
  23. package/src/home/lib/meta/reply.mjs +42 -34
  24. package/src/home/lib/meta/run-liveness.mjs +64 -0
  25. package/src/home/lib/meta/run-state.mjs +28 -34
  26. package/src/home/lib/meta/verdict.mjs +18 -0
  27. package/src/home/lib/required-inputs.mjs +25 -4
  28. package/src/home/lib/run-codex.mjs +1 -0
  29. package/src/home/lib/run-config.mjs +5 -1
  30. package/src/home/lib/runner/args.mjs +8 -4
  31. package/src/home/lib/runner/attach.mjs +5 -13
  32. package/src/home/lib/runner/codex-args.mjs +14 -5
  33. package/src/home/lib/runner/codex-cmd.mjs +105 -19
  34. package/src/home/lib/runner/git-state.mjs +6 -1
  35. package/src/home/lib/runner/launcher.mjs +36 -30
  36. package/src/home/lib/runner/preflight.mjs +109 -0
  37. package/src/home/lib/runner/prompts.mjs +39 -0
  38. package/src/home/lib/runner/sandbox-probe.mjs +109 -0
  39. package/src/home/lib/runner/schemas.mjs +137 -1
  40. package/src/home/lib/runner/scope-check.mjs +31 -3
  41. package/src/home/lib/runner/worker-order.mjs +4 -2
  42. package/src/home/lib/runner/worker.mjs +2 -2
  43. package/src/home/lib/write-meta.mjs +18 -1
@@ -1,154 +1,320 @@
1
- /**
2
- * Reports whether a shell command plainly intends to write files and the target paths it names.
3
- *
4
- * This is deliberately not a complete list, and it cannot become one. Enumerating every way a
5
- * shell can write would reproduce the original defect one level up: the 2026-08-16 incident is
6
- * why the worktree witness hook exists beside this cheap prevention check.
7
- */
8
-
9
- const COMMANDS = new Set(['cp', 'mv', 'rm', 'touch', 'truncate', 'tee', 'sed']);
10
- const INTERPRETERS = /\b(?:python(?:\d+(?:\.\d+)?)?|node|perl|ruby)(?:\.exe)?\b/i;
11
-
12
- function unquote(value) {
13
- const trimmed = value.trim().replace(/[;,]+$/, '');
14
- if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
15
- || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
16
- return trimmed.slice(1, -1);
17
- }
18
- return trimmed;
19
- }
20
-
21
- // A candidate holding a character no file name may contain is not a file name. The redirection
22
- // pattern cannot tell `>` the operator from `>` the redirect, so `node -e "i>0?'yes':'no'"` was
23
- // refused on 2026-08-23 naming the path `0?'yes` — a false refusal on a command that writes
24
- // nothing. Narrowing here costs nothing the witness does not already cover.
25
- const IMPOSSIBLE_IN_A_NAME = /[?*<>|"]/;
26
-
27
- function addPath(paths, value) {
28
- const candidate = unquote(value);
29
- if (candidate
30
- && !['&', '$', '%', '`'].includes(candidate[0])
31
- && !IMPOSSIBLE_IN_A_NAME.test(candidate)
32
- && !paths.includes(candidate)) paths.push(candidate);
33
- }
34
-
35
- function tokens(segment) {
36
- return segment.match(/"(?:\\.|[^"])*"|'[^']*'|[^\s]+/g) ?? [];
37
- }
38
-
39
- // A `>` inside a quoted argument is text, not a redirect. The 2026-08-28 worktree-lock refusal
40
- // named `#'` from `sed -E 's#(A ).*#\1<redacted>#'` because this scan previously read raw text.
41
- function quotedCharacters(command) {
42
- const quoted = new Uint8Array(command.length);
43
- let quote = null;
44
- for (let index = 0; index < command.length; index += 1) {
45
- const character = command[index];
46
- if (quote === "'") {
47
- if (character === "'") quote = null;
48
- else quoted[index] = 1;
49
- } else if (quote === '"') {
50
- if (character === '\\' && index + 1 < command.length) {
51
- quoted[index] = 1;
52
- quoted[index + 1] = 1;
53
- index += 1;
54
- } else if (character === '"') quote = null;
55
- else quoted[index] = 1;
56
- } else if (character === '\\' && index + 1 < command.length) {
57
- index += 1;
58
- } else if (character === "'" || character === '"') {
59
- quote = character;
60
- quoted[index] = 1;
61
- }
62
- }
63
- return quote ? null : quoted;
64
- }
65
-
66
- function positional(args) {
67
- return args.filter((arg) => !arg.startsWith('-'));
68
- }
69
-
70
- /**
71
- * Strip the directory and the Windows executable suffix before matching the command name.
72
- * `INTERPRETERS` has always allowed `python.exe`; the command list did not, so `sed.exe -i` —
73
- * the spelling Git Bash and PowerShell hand over on this platform — walked past the guard while
74
- * the identical `sed -i` was refused (found accepting the 2026-08-17 smoke run, which had recorded
75
- * that gap as intended behaviour).
76
- */
77
- function commandName(token) {
78
- return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
79
- }
80
-
81
- /**
82
- * Split a heredoc command into the shell around it and the document inside it.
83
- *
84
- * `shell` is everything the shell itself runs — the line opening the document AND every command
85
- * after its closing marker. Dropping that tail hid `… <<EOF … EOF; echo x >> README.md` from the
86
- * guard completely, and a live probe on 2026-08-23 wrote to a tracked file during a held run
87
- * exactly that way. `paths` holds only what the document body names, which the caller uses just
88
- * when the interpreter is itself the writer.
89
- */
90
- function heredoc(command) {
91
- const header = command.match(/^(.*?<<-?\s*(['"]?)([A-Za-z_][\w-]*)\2[^\r\n]*)[\r\n]/s);
92
- if (!header) return null;
93
- const paths = [];
94
- const marker = header[3];
95
- const body = command.slice(header[0].length).split(/\r?\n/);
96
- const end = body.findIndex((line) => line.trim() === marker);
97
- const shell = [header[1], ...(end < 0 ? [] : body.slice(end + 1))].join('\n');
98
- if (!INTERPRETERS.test(header[1])) return { shell, paths, writes: false };
99
- const source = body.slice(0, end < 0 ? body.length : end).join('\n');
100
- for (const match of source.matchAll(/(['"])(.*?)\1/g)) {
101
- const value = match[2];
102
- if (/^(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])/.test(value)
103
- || /[\\/]/.test(value)
104
- || /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, value);
105
- }
106
- return { shell, paths, writes: true };
107
- }
108
-
109
- /** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
110
- export function shellWriteIntent(command) {
111
- if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
112
- const paths = [];
113
- const document = heredoc(command);
114
- // The document body is prose until an interpreter makes it code. Reading `header` here while
115
- // heredoc() returned `shell` meant the whole command, body included, went through shell parsing
116
- // whenever the writer was plain `cat`: on 2026-09-06 the lock refused four harmless commands,
117
- // the last of them because a sentence in a task file said "Do not touch the installer" and
118
- // `touch` is in COMMANDS, so every following word became a write target under the repository.
119
- const shell = document?.shell ?? command;
120
- let writes = document?.writes ?? false;
121
- const quoted = quotedCharacters(shell);
122
-
123
- for (const match of shell.matchAll(/(?<![<>=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g)) {
124
- if (quoted && quoted[match.index]) continue;
125
- writes = true;
126
- addPath(paths, match[1]);
127
- }
128
-
129
- // A real output redirect identifies the writer's destination. Body text is only evidence when
130
- // the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
131
- if (!paths.length && document?.writes) {
132
- for (const candidate of document.paths) addPath(paths, candidate);
133
- }
134
-
135
- for (const segment of shell.split(/(?:&&|\|\||[;|\r\n])/)) {
136
- const parts = tokens(segment);
137
- const index = parts.findIndex((part) => COMMANDS.has(commandName(part)));
138
- if (index < 0) continue;
139
- const name = commandName(parts[index]);
140
- const args = parts.slice(index + 1);
141
- if (name === 'sed' && !args.some((arg) => /^-.*i/.test(arg))) continue;
142
- writes = true;
143
- const values = positional(args);
144
- if (name === 'cp' || name === 'mv') {
145
- if (values.length > 1) addPath(paths, values.at(-1));
146
- } else if (name === 'sed') {
147
- for (const value of values.slice(1)) addPath(paths, value);
148
- } else {
149
- for (const value of values) addPath(paths, value);
150
- }
151
- }
152
-
153
- return { writes, paths };
154
- }
1
+ /**
2
+ * Reports whether a shell command plainly intends to write files and the target paths it names.
3
+ *
4
+ * This is deliberately not a complete list, and it cannot become one. Enumerating every way a
5
+ * shell can write would reproduce the original defect one level up: the 2026-08-16 incident is
6
+ * why the worktree witness hook exists beside this cheap prevention check.
7
+ */
8
+
9
+ const COMMANDS = new Set(['cp', 'mv', 'rm', 'touch', 'truncate', 'tee', 'sed']);
10
+ const INTERPRETERS = /\b(?:python(?:\d+(?:\.\d+)?)?|node|perl|ruby)(?:\.exe)?\b/i;
11
+
12
+ function unquote(value) {
13
+ const trimmed = value.trim().replace(/[;,]+$/, '');
14
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
15
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
16
+ return trimmed.slice(1, -1);
17
+ }
18
+ return trimmed;
19
+ }
20
+
21
+ // A candidate holding a character no file name may contain is not a file name. The redirection
22
+ // pattern cannot tell `>` the operator from `>` the redirect, so `node -e "i>0?'yes':'no'"` was
23
+ // refused on 2026-08-23 naming the path `0?'yes` — a false refusal on a command that writes
24
+ // nothing. Narrowing here costs nothing the witness does not already cover.
25
+ const IMPOSSIBLE_IN_A_NAME = /[?*<>|"]/;
26
+
27
+ function addPath(paths, candidate) {
28
+ // D4c: word values are already unquoted; raw redirects keep their separate unquote() call.
29
+ if (candidate
30
+ && !['&', '$', '%', '`'].includes(candidate[0])
31
+ && !IMPOSSIBLE_IN_A_NAME.test(candidate)
32
+ && !paths.includes(candidate)) paths.push(candidate);
33
+ }
34
+
35
+ // One quote reader owns redirects, command boundaries and words: the 2026-09-16 awk refusal
36
+ // repeated the 2026-08-23 node, 2026-08-28 sed and 2026-09-06 heredoc text-as-shell incidents.
37
+ function lexShell(command) {
38
+ const literal = new Uint8Array(command.length);
39
+ const state = () => ({ commands: [], words: [], word: '', started: false, escaped: false, previousLiteral: false });
40
+ let parsed = state();
41
+ const plain = state();
42
+ const stack = [];
43
+ let quote = null;
44
+ let doubleEscape = false;
45
+ let comment = false;
46
+
47
+ function finish(current, endCommand) {
48
+ if (current.word || (current !== plain && current.started)) {
49
+ current.words.push(current.word);
50
+ current.word = '';
51
+ current.started = false;
52
+ }
53
+ if (endCommand && current.words.length) {
54
+ current.commands.push(current.words);
55
+ current.words = [];
56
+ }
57
+ }
58
+
59
+ function append(current, index, isLiteral) {
60
+ const character = command[index];
61
+ if (current.escaped) {
62
+ current.escaped = false;
63
+ isLiteral = true;
64
+ } else if (!isLiteral && character === '\\'
65
+ && (/[;|&<>() \t'"\\]/.test(command[index + 1] ?? '')
66
+ || (current !== plain && /[#$]/.test(command[index + 1] ?? '')))) {
67
+ // Windows paths use backslashes too: Git Bash escapes shell metacharacters here,
68
+ // but consuming every backslash would corrupt C:\tools\sed.exe and its targets.
69
+ current.escaped = true;
70
+ current.previousLiteral = true;
71
+ current.started = true;
72
+ return true;
73
+ }
74
+ const pipeAmpersand = character === '&' && !current.previousLiteral && command[index - 1] === '|';
75
+ const operatorAmpersand = character === '&'
76
+ && ((!current.previousLiteral && /[<>|]/.test(command[index - 1] ?? ''))
77
+ || command[index + 1] === '>');
78
+ current.previousLiteral = isLiteral;
79
+ if (!isLiteral && pipeAmpersand) return false;
80
+ if (!isLiteral && (';|()\r\n'.includes(character)
81
+ || (character === '&' && !operatorAmpersand))) finish(current, true);
82
+ else if (!isLiteral && /\s/.test(character)) finish(current, false);
83
+ else {
84
+ current.word += character;
85
+ current.started = true;
86
+ }
87
+ return isLiteral;
88
+ }
89
+
90
+ for (let index = 0; index < command.length; index += 1) {
91
+ const character = command[index];
92
+ // Keep the original quote-blind fallback for malformed input (D4c review).
93
+ append(plain, index, false);
94
+ if (comment) {
95
+ if (!'\r\n'.includes(character)) {
96
+ literal[index] = 1;
97
+ continue;
98
+ }
99
+ comment = false;
100
+ }
101
+ let isLiteral = quote !== null || parsed.escaped || doubleEscape;
102
+ let quoteBoundary = false;
103
+ if (doubleEscape) doubleEscape = false;
104
+ else if (!parsed.escaped) {
105
+ if (quote === '"' && character === '\\' && index + 1 < command.length) {
106
+ doubleEscape = true;
107
+ } else if (quote) {
108
+ if (character === quote) {
109
+ quote = null;
110
+ quoteBoundary = true;
111
+ }
112
+ } else if (character === "'" || character === '"') {
113
+ quote = character;
114
+ isLiteral = true;
115
+ quoteBoundary = true;
116
+ }
117
+ }
118
+ // D4c: quote fragments belong to one word; their delimiters are not its value.
119
+ if (quoteBoundary) {
120
+ parsed.started = true;
121
+ parsed.previousLiteral = true;
122
+ literal[index] = 1;
123
+ continue;
124
+ }
125
+ if (!isLiteral && character === '#' && !parsed.started) {
126
+ comment = true;
127
+ literal[index] = 1;
128
+ continue;
129
+ }
130
+ if (!isLiteral && character === '(') {
131
+ const substitution = command[index - 1] === '$' && !parsed.previousLiteral;
132
+ if (!parsed.started || substitution) {
133
+ // D4c: suspend the outer command, retaining an unresolved $() in its word.
134
+ // Flattening parentheses made a substitution suffix look like a new command.
135
+ if (substitution) parsed.word += '()';
136
+ stack.push(parsed);
137
+ parsed = { ...state(), commands: parsed.commands };
138
+ continue;
139
+ }
140
+ isLiteral = true;
141
+ }
142
+ if (!isLiteral && character === ')') {
143
+ if (stack.length) {
144
+ finish(parsed, true);
145
+ parsed = stack.pop();
146
+ parsed.previousLiteral = false;
147
+ continue;
148
+ }
149
+ isLiteral = true;
150
+ }
151
+ literal[index] = append(parsed, index, isLiteral);
152
+ }
153
+ finish(parsed, true);
154
+ while (stack.length) {
155
+ parsed = stack.pop();
156
+ finish(parsed, true);
157
+ }
158
+ finish(plain, true);
159
+ return { literal: quote ? null : literal, commands: quote ? plain.commands : parsed.commands };
160
+ }
161
+
162
+ function positional(args) {
163
+ return args.filter((arg) => !arg.startsWith('-'));
164
+ }
165
+
166
+ /**
167
+ * Strip the directory and the Windows executable suffix before matching the command name.
168
+ * `INTERPRETERS` has always allowed `python.exe`; the command list did not, so `sed.exe -i` —
169
+ * the spelling Git Bash and PowerShell hand over on this platform — walked past the guard while
170
+ * the identical `sed -i` was refused (found accepting the 2026-08-17 smoke run, which had recorded
171
+ * that gap as intended behaviour).
172
+ */
173
+ function commandName(token) {
174
+ return token.replace(/^.*[\\/]/, '').replace(/\.exe$/i, '').toLowerCase();
175
+ }
176
+
177
+ const COMMAND_PREFIXES = new Set(['if', 'then', 'else', 'elif', 'while', 'until', 'do', '!', '{', 'time']);
178
+ const LAUNCHERS = new Set(['xargs', 'env', 'nohup', 'timeout', 'nice', 'command', 'exec', 'sudo']);
179
+
180
+ // Plan_57 D11: `git log --grep rm` and `grep -rn touch` were refused because arguments became
181
+ // commands. These launchers only move command position; unknown launchers leave a lock gap
182
+ // covered after execution by worktree-witness.mjs, without falsely refusing a read.
183
+ function* writingCommands(parts) {
184
+ // D4c review: 6000 nested find actions overflowed recursive yield*. Index ranges and
185
+ // precomputed terminators keep traversal iterative without repeatedly copying/scanning tails.
186
+ const terminators = new Array(parts.length);
187
+ let next = parts.length;
188
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
189
+ if ([';', '+'].includes(parts[index])) next = index;
190
+ terminators[index] = next;
191
+ }
192
+ const pending = [{ start: 0, end: parts.length }];
193
+ while (pending.length) {
194
+ const { start, end } = pending.pop();
195
+ let launched = false;
196
+ for (let index = start; index < end; index += 1) {
197
+ const part = parts[index];
198
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(part) || COMMAND_PREFIXES.has(part)) continue;
199
+ // A case pattern (`a)`, `*)`) precedes a command position the way `then` does. The lexer keeps
200
+ // an unmatched `)` literal because `echo ) rm x` runs nothing, which alone hid
201
+ // `case x in a) rm b.txt;; esac` — a write the scanner caught before D4c.
202
+ if (part === 'case') {
203
+ while (index + 1 < end && !parts[index].endsWith(')')) index += 1;
204
+ continue;
205
+ }
206
+ if (part.endsWith(')')) continue;
207
+ if (/^(?:\d*[<>]|&>)/.test(part)) {
208
+ if (/^(?:\d*[<>]+&?|&>>?)$/.test(part)) index += 1;
209
+ continue;
210
+ }
211
+ if (launched && (part.startsWith('-') || /^\d+[smhd]?$/.test(part))) {
212
+ if (launched === 'command' && /^-[^-]*[vV]/.test(part)) break;
213
+ continue;
214
+ }
215
+ const name = commandName(part);
216
+ if (LAUNCHERS.has(name)) {
217
+ launched = name;
218
+ continue;
219
+ }
220
+ if (COMMANDS.has(name)) yield parts.slice(index, end);
221
+ else if (name === 'find') {
222
+ const actions = [];
223
+ for (index += 1; index < end; index += 1) {
224
+ if (!['-exec', '-execdir', '-ok', '-okdir'].includes(parts[index])) continue;
225
+ const actionStart = index + 1;
226
+ index = Math.min(terminators[actionStart] ?? end, end);
227
+ // D11: terminators and later predicates are not action arguments.
228
+ actions.push({ start: actionStart, end: index });
229
+ }
230
+ for (let action = actions.length - 1; action >= 0; action -= 1) pending.push(actions[action]);
231
+ }
232
+ break;
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Split a heredoc command into the shell around it and the document inside it.
239
+ *
240
+ * `shell` is everything the shell itself runs — the line opening the document AND every command
241
+ * after its closing marker. Dropping that tail hid `… <<EOF … EOF; echo x >> README.md` from the
242
+ * guard completely, and a live probe on 2026-08-23 wrote to a tracked file during a held run
243
+ * exactly that way. `paths` holds only what the document body names, which the caller uses just
244
+ * when the interpreter is itself the writer.
245
+ */
246
+ function heredoc(command) {
247
+ const header = command.match(/^(.*?<<-?\s*(['"]?)([A-Za-z_][\w-]*)\2[^\r\n]*)[\r\n]/s);
248
+ if (!header) return null;
249
+ const paths = [];
250
+ const marker = header[3];
251
+ const body = command.slice(header[0].length).split(/\r?\n/);
252
+ const end = body.findIndex((line) => line.trim() === marker);
253
+ const shell = [header[1], ...(end < 0 ? [] : body.slice(end + 1))].join('\n');
254
+ if (!INTERPRETERS.test(header[1])) return { shell, paths, writes: false };
255
+ const source = body.slice(0, end < 0 ? body.length : end).join('\n');
256
+ for (const match of source.matchAll(/(['"])(.*?)\1/g)) {
257
+ const value = match[2];
258
+ if (/^(?:[A-Za-z]:[\\/]|[\\/]|\.\.?[\\/])/.test(value)
259
+ || /[\\/]/.test(value)
260
+ || /(?:^|[\\/])[^\\/]+\.[A-Za-z0-9_-]+$/.test(value)) addPath(paths, unquote(value));
261
+ }
262
+ return { shell, paths, writes: true };
263
+ }
264
+
265
+ /** Returns `{ writes, paths }` for the deliberately obvious write forms this guard recognises. */
266
+ export function shellWriteIntent(command) {
267
+ if (typeof command !== 'string' || !command.trim()) return { writes: false, paths: [] };
268
+ const paths = [];
269
+ const document = heredoc(command);
270
+ // The document body is prose until an interpreter makes it code. Reading `header` here while
271
+ // heredoc() returned `shell` meant the whole command, body included, went through shell parsing
272
+ // whenever the writer was plain `cat`: on 2026-09-06 the lock refused four harmless commands,
273
+ // the last of them because a sentence in a task file said "Do not touch the installer" and
274
+ // `touch` is in COMMANDS, so every following word became a write target under the repository.
275
+ const shell = document?.shell ?? command;
276
+ let writes = document?.writes ?? false;
277
+ const { literal, commands } = lexShell(shell);
278
+
279
+ const redirects = /(?<![<=])(?:\d*)>>?(?![=>&])\s*("(?:\\.|[^"])*"|'[^']*'|[^\s;|&]+)/g;
280
+ let match;
281
+ while ((match = redirects.exec(shell))) {
282
+ if (literal?.[match.index]
283
+ || (shell[match.index - 1] === '>' && !literal?.[match.index - 1])) {
284
+ // D4c: a literal > must not consume the real redirect immediately after it.
285
+ redirects.lastIndex = match.index + 1;
286
+ continue;
287
+ }
288
+ writes = true;
289
+ addPath(paths, unquote(match[1]));
290
+ }
291
+
292
+ // A real output redirect identifies the writer's destination. Body text is only evidence when
293
+ // the interpreter itself is the writer, as in the 2026-08-16 python heredoc incident.
294
+ if (!paths.length && document?.writes) {
295
+ for (const candidate of document.paths) addPath(paths, unquote(candidate));
296
+ }
297
+
298
+ for (const parts of commands) {
299
+ // D11 keeps the pre-position fallback: an unfinished quote makes word positions untrusted.
300
+ const candidates = literal ? writingCommands(parts) : [parts];
301
+ for (const candidate of candidates) {
302
+ const index = literal ? 0 : candidate.findIndex((part) => COMMANDS.has(commandName(part)));
303
+ if (index < 0) continue;
304
+ const name = commandName(candidate[index]);
305
+ const args = candidate.slice(index + 1);
306
+ if (name === 'sed' && !args.some((arg) => /^-.*i/.test(arg))) continue;
307
+ writes = true;
308
+ const values = positional(args).map((value) => literal ? value : unquote(value));
309
+ if (name === 'cp' || name === 'mv') {
310
+ if (values.length > 1) addPath(paths, values.at(-1));
311
+ } else if (name === 'sed') {
312
+ for (const value of values.slice(1)) addPath(paths, value);
313
+ } else {
314
+ for (const value of values) addPath(paths, value);
315
+ }
316
+ }
317
+ }
318
+
319
+ return { writes, paths };
320
+ }
@@ -39,8 +39,15 @@ if (WRITE_TOOL_NAMES.has(input.tool_name)) {
39
39
  const rawPath = toolInput[pathField];
40
40
  if (typeof rawPath !== 'string' || !rawPath.trim()) pass();
41
41
  rawPaths = [rawPath];
42
- } else {
43
- const intent = shellWriteIntent(toolInput.command);
42
+ } else {
43
+ let intent;
44
+ try {
45
+ intent = shellWriteIntent(toolInput.command);
46
+ } catch {
47
+ // D4c review (2026-09-17_131740_plan-57-d4-review): deep find actions overflowed
48
+ // the scanner. Unrecognised input must fail open; the witness checks writes afterwards.
49
+ pass();
50
+ }
44
51
  if (!intent.writes || !intent.paths.length) pass();
45
52
  rawPaths = intent.paths;
46
53
  }
@@ -7,13 +7,17 @@
7
7
  * split-guard run and made an honest build appear out of scope. This hook compares the live
8
8
  * run's recorded git state with the repository after every shell tool and directs the
9
9
  * orchestrator to undo any change outside the run's declared scope.
10
+ * The 2026-09-19 run accused itself of editing its own artifacts: the witness must use
11
+ * the verdict's snapshot and environment split, not a second definition of changed work.
10
12
  */
11
13
  import fs from 'node:fs';
12
14
  import path from 'node:path';
13
- import { spawnSync } from 'node:child_process';
14
15
  import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
15
16
  import { parseJsonText } from '../lib/json-file.mjs';
16
- import { globToRegExp } from '../lib/meta/paths.mjs';
17
+ import { splitRunChanges } from '../lib/meta/environment.mjs';
18
+ import { changedPaths } from '../lib/meta/paths.mjs';
19
+ import { outOfScope } from '../lib/meta/verdict.mjs';
20
+ import { git, worktreeSnapshot } from '../lib/runner/git-state.mjs';
17
21
  import { runsRoot } from '../lib/runner/runs-root.mjs';
18
22
  import { allLiveRuns, normalizePath } from './live-runs.mjs';
19
23
 
@@ -28,19 +32,6 @@ function readRequired(file) {
28
32
  }
29
33
  }
30
34
 
31
- function statusMap(text) {
32
- if (typeof text !== 'string') return null;
33
- const result = new Map();
34
- for (const line of text.split(/\r?\n/).filter(Boolean)) {
35
- if (!/^.. /.test(line)) return null;
36
- const rawPath = line.slice(3);
37
- const file = rawPath.includes(' -> ') ? rawPath.slice(rawPath.lastIndexOf(' -> ') + 4) : rawPath;
38
- if (!file.trim()) return null;
39
- result.set(file, line.slice(0, 2));
40
- }
41
- return result;
42
- }
43
-
44
35
  let input;
45
36
  try {
46
37
  input = parseJsonText('stdin', fs.readFileSync(0, 'utf8'));
@@ -67,37 +58,26 @@ const owner = liveRuns.find(({ status }) => {
67
58
  if (!owner) pass();
68
59
 
69
60
  const { dir, status } = owner;
70
- const beforeText = readRequired(path.join(dir, 'git-before.txt'));
61
+ const beforeText = readRequired(path.join(dir, 'state-before.txt'));
71
62
  const scopeText = readRequired(path.join(dir, 'scope.txt'));
72
63
  if (beforeText === null || scopeText === null) pass();
73
64
 
74
- const before = statusMap(beforeText);
75
- let allowed;
65
+ let outside;
76
66
  try {
67
+ // changedPaths tolerates malformed rows; a hook must instead fail open on a damaged baseline.
68
+ if (beforeText.split(/\r?\n/).filter(Boolean).some((row) =>
69
+ !/^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/.test(row))) pass();
77
70
  const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
78
71
  if (!patterns.length) pass();
79
- allowed = patterns.map(globToRegExp);
72
+ // The shared snapshot returns empty output on git errors; do not call that a restored tree.
73
+ const repository = git(status.repo, ['rev-parse', '--is-inside-work-tree']);
74
+ if (repository.error || repository.status !== 0 || repository.stdout?.trim() !== 'true') pass();
75
+ const current = worktreeSnapshot(status.repo);
76
+ const { work } = splitRunChanges(dir, changedPaths(beforeText, current));
77
+ outside = outOfScope(work, patterns);
80
78
  } catch {
81
79
  pass();
82
80
  }
83
- if (!before) pass();
84
-
85
- let currentResult;
86
- try {
87
- currentResult = spawnSync('git', ['-C', status.repo, 'status', '--porcelain'], {
88
- encoding: 'utf8',
89
- });
90
- } catch {
91
- pass();
92
- }
93
- if (currentResult.error || currentResult.status !== 0 || typeof currentResult.stdout !== 'string') pass();
94
- const current = statusMap(currentResult.stdout);
95
- if (!current) pass();
96
-
97
- const changed = [];
98
- for (const [file, state] of current) if (before.get(file) !== state) changed.push(file);
99
- for (const file of before.keys()) if (!current.has(file)) changed.push(file);
100
- const outside = [...new Set(changed)].filter((file) => !allowed.some((pattern) => pattern.test(file)));
101
81
  if (!outside.length) pass();
102
82
 
103
83
  const folder = path.basename(dir);
@@ -105,9 +85,10 @@ const paths = outside.join(', ');
105
85
  process.stdout.write(JSON.stringify({
106
86
  hookSpecificOutput: {
107
87
  hookEventName: 'PostToolUse',
108
- additionalContext: `WORKTREE WITNESS — act now: the shell changed paths outside the scope of `
109
- + `live run folder ${dir} (agent ${status.agent}, slug ${status.slug}): ${paths}. `
110
- + `Revert those paths now, or run codex-bridge stop ${folder} to take the repository back `
88
+ additionalContext: `WORKTREE WITNESS — the run's own folder, environment paths and gitignored `
89
+ + `files are already excluded. These are the orchestrator's own edits inside the repository `
90
+ + `of live run folder ${dir} (agent ${status.agent}, slug ${status.slug}), outside its scope: ${paths}. `
91
+ + `Act now: revert those paths, or run codex-bridge stop ${folder} to take the repository back `
111
92
  + `before making further changes.`,
112
93
  },
113
94
  }));
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Defines each delegated agent's role, wall-clock budget, write access and result file.
3
+ * D2: a budget declares positive minutes or named phases; budgets.mjs alone normalizes it.
3
4
  *
4
5
  * The three agents were listed four times: twice in run-config.mjs for budgets and models,
5
6
  * once in codex-args.mjs for role lookup, and once in order-gate.mjs for guarded names.
@@ -10,6 +11,8 @@ export const AGENTS = {
10
11
  'codex-scout': { role: 'scout', budget: 15, writes: false, result: 'result.json' },
11
12
  'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
12
13
  'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
14
+ // Plan_59: design advice needs a bounded scope pass before the continued decision pass.
15
+ 'codex-advisor': { role: 'advisor', budget: { scope: 5, advise: 15 }, writes: false, result: 'result.json' },
13
16
  };
14
17
 
15
18
  export const agentRole = (agent) => AGENTS[agent]?.role;