@lyupro/codex-bridge 0.6.4 → 0.6.6

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.
@@ -0,0 +1,226 @@
1
+ /** Judges advisor evidence and independent decisions so Plan_59 D3/D4/D5/D10 failures cannot pass as advice. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const BACKSTOPS = {
6
+ English: ['both options are good', 'it depends on preference', 'you know better', 'either works'],
7
+ Russian: ['оба варианта хороши', 'зависит от предпочтений', 'вам виднее', 'подойдёт любой'],
8
+ };
9
+ const ADDRESS_TOKEN = /(?<![\p{L}\p{N}_./\\:~-])(?:[a-zA-Z]:)?[\p{L}\p{N}_@!$~.%+/\\-]+:\d+(?:-\d+)?(?![\p{L}\p{N}_:\d-])/gu;
10
+ const QUOTED_ADDRESS = /`([^`\r\n]+:\d+(?:-\d+)?)`|"([^"\r\n]+:\d+(?:-\d+)?)"|'([^'\r\n]+:\d+(?:-\d+)?)'/u;
11
+ const ADDRESS = new RegExp(`${QUOTED_ADDRESS.source}|(${ADDRESS_TOKEN.source})`, 'gu');
12
+
13
+ function* strings(value, field = '') {
14
+ if (typeof value === 'string') yield { field, value };
15
+ else if (Array.isArray(value)) {
16
+ for (const [index, item] of value.entries()) yield* strings(item, `${field}[${index}]`);
17
+ } else if (value && typeof value === 'object') {
18
+ for (const [key, item] of Object.entries(value)) {
19
+ yield* strings(item, field ? `${field}.${key}` : key);
20
+ }
21
+ }
22
+ }
23
+
24
+ function backstopPattern(phrase) {
25
+ // Plan_59 D5 ignores punctuation within words as well as between words, without matching word prefixes.
26
+ const words = phrase.split(' ').map((word) => [...word].join('[\\p{P}\\p{S}]*'));
27
+ return new RegExp(`(?<![\\p{L}\\p{N}])${words.join('[\\s\\p{P}\\p{S}]+')}(?![\\p{L}\\p{N}])`, 'iu');
28
+ }
29
+
30
+ function isInside(root, absolute) {
31
+ const relative = path.relative(root, absolute);
32
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
33
+ }
34
+
35
+ function entryInRepo(root, raw) {
36
+ const normalized = raw.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
37
+ if (path.posix.isAbsolute(normalized) || /^[a-z]:/i.test(normalized)) return null;
38
+ const absolute = path.resolve(root, normalized);
39
+ if (!isInside(root, absolute)) return null;
40
+ try {
41
+ // Plan_59 D3 requires real containment: a repository symlink cannot authorize outside evidence.
42
+ if (!isInside(fs.realpathSync(root), fs.realpathSync(absolute))) return null;
43
+ const stat = fs.statSync(absolute);
44
+ return {
45
+ absolute,
46
+ relative: path.relative(root, absolute).replace(/\\/g, '/'),
47
+ isFile: stat.isFile(),
48
+ isDirectory: stat.isDirectory(),
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function lineCount(absolute) {
56
+ const text = fs.readFileSync(absolute, 'utf8');
57
+ if (!text) return 0;
58
+ const lines = text.split(/\r\n|\n|\r/);
59
+ return lines.length - (lines.at(-1) === '' ? 1 : 0);
60
+ }
61
+
62
+ function* addressesIn(value, root) {
63
+ for (const match of value.matchAll(ADDRESS)) {
64
+ const address = match.slice(1).find(Boolean);
65
+ if (!match[4]) {
66
+ // Plan_59 D3 must distinguish a quoted path with spaces from prose quoting several citations.
67
+ const embedded = [...address.matchAll(ADDRESS_TOKEN)].map((token) => token[0]);
68
+ const file = address.match(/^(.+):\d+(?:-\d+)?$/)[1];
69
+ if (embedded.length && !entryInRepo(root, file)?.isFile &&
70
+ (embedded.length > 1 || !/^[^\s]*[/\\]/.test(address))) {
71
+ yield* embedded;
72
+ continue;
73
+ }
74
+ }
75
+ yield address;
76
+ }
77
+ }
78
+
79
+ function citationProblem(raw, root, allowed) {
80
+ const match = raw.match(/^(.+):(\d+)(?:-(\d+))?$/);
81
+ if (!match) return 'use a repository-relative path:line or path:line-line address';
82
+ const entry = entryInRepo(root, match[1]);
83
+ if (!entry?.isFile) return 'cite an existing file under repoRoot';
84
+ let count;
85
+ try {
86
+ count = lineCount(entry.absolute);
87
+ } catch {
88
+ return 'cite a readable file under repoRoot';
89
+ }
90
+ const start = Number(match[2]);
91
+ const end = match[3] === undefined ? start : Number(match[3]);
92
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end > count) {
93
+ return `use an ascending line range within this file's ${count} lines`;
94
+ }
95
+ if (!allowed.some((scope) => entry.relative === scope.relative ||
96
+ (scope.isDirectory && (!scope.relative || entry.relative.startsWith(`${scope.relative}/`))))) {
97
+ return 'cite a task path or a scope missing_paths entry';
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function checkCitations({ phase, result, task, repoRoot, scopeResult }, reasons) {
103
+ const root = path.resolve(repoRoot);
104
+ const allowed = [...task.paths, ...(phase === 'scope' ? result.missing_paths : scopeResult?.missing_paths ?? [])]
105
+ .map((raw) => entryInRepo(root, raw)).filter(Boolean);
106
+ const check = (field, address) => {
107
+ const problem = citationProblem(address, root, allowed);
108
+ if (problem) reasons.push(`D3 ${field} ${JSON.stringify(address)}: ${problem}.`);
109
+ };
110
+ const checkAddress = (field, raw) => {
111
+ const trimmed = raw.trim();
112
+ check(field, /^`.*`$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed);
113
+ };
114
+ // D3 covers every address anywhere in the answer. A list of prose fields had to be edited by
115
+ // hand when `falsifier` became `pre_mortem`, and each new field would have joined it unchecked.
116
+ // Fields that ARE an address are checked whole below, so they are skipped here.
117
+ for (const { field, value } of strings(result)) {
118
+ if (/\.address$|\.early_check\.target$/.test(field)) continue;
119
+ for (const address of addressesIn(value, root)) check(field, address);
120
+ }
121
+ if (result.independent_checks) {
122
+ for (const [index, item] of result.independent_checks.entries()) {
123
+ checkAddress(`independent_checks[${index}].address`, item.address);
124
+ }
125
+ }
126
+ if (phase === 'advise') {
127
+ for (const [index, item] of result.assumptions.entries()) {
128
+ if (item.rating === 'VERIFIED' || item.address !== '') {
129
+ checkAddress(`assumptions[${index}].address`, item.address);
130
+ }
131
+ }
132
+ for (const [index, item] of result.pre_mortem.entries()) {
133
+ if (item.early_check.kind === 'inspect') {
134
+ checkAddress(`pre_mortem[${index}].early_check.target`, item.early_check.target);
135
+ }
136
+ }
137
+ // An outcome's address is evidence whether or not the phase-1 answer is at hand.
138
+ for (const [index, item] of result.risk_outcomes.entries()) {
139
+ checkAddress(`risk_outcomes[${index}].address`, item.address);
140
+ }
141
+ }
142
+ }
143
+
144
+ export function judgeAdvice({ phase, result, task, repoRoot, commandsRun, language, scopeResult }) {
145
+ if (phase !== 'scope' && phase !== 'advise') throw new RangeError('phase must be "scope" or "advise".');
146
+ if (!Number.isInteger(commandsRun) || commandsRun < 0) {
147
+ throw new TypeError('commandsRun must be a non-negative integer.');
148
+ }
149
+ if (typeof language !== 'string') throw new TypeError('language must be a string.');
150
+ if (typeof repoRoot !== 'string' || !repoRoot) throw new TypeError('repoRoot must be a non-empty path.');
151
+ if (!result || !Array.isArray(task?.options) || !Array.isArray(task?.paths)) {
152
+ throw new TypeError('result and a parsed advisor task are required.');
153
+ }
154
+ const reasons = [];
155
+ if (commandsRun === 0) reasons.push('D4 commandsRun 0: inspect repository evidence before answering.');
156
+ if (phase === 'scope') {
157
+ if (!result.taken_on_trust.length) {
158
+ reasons.push('D4 taken_on_trust []: name at least one assumption taken on trust.');
159
+ }
160
+ if (result.sufficient === false && !result.missing_paths.length) {
161
+ reasons.push('D4 missing_paths [] with sufficient false: name the paths needed for advice.');
162
+ }
163
+ if (result.sufficient === true && result.missing_paths.length) {
164
+ reasons.push(`D4 missing_paths ${JSON.stringify(result.missing_paths)} with sufficient true: resolve the scope contradiction.`);
165
+ }
166
+ const riskIds = new Set();
167
+ for (const [index, item] of result.predicted_risks.entries()) {
168
+ if (riskIds.has(item.id)) {
169
+ reasons.push(`D10 predicted_risks[${index}].id ${JSON.stringify(item.id)}: use a unique predicted risk id.`);
170
+ }
171
+ riskIds.add(item.id);
172
+ }
173
+ } else {
174
+ const ids = new Set(task.options.map(({ id }) => id));
175
+ const chosen = result.recommendation.option_id;
176
+ if (!ids.has(chosen) && chosen !== 'none-of-these') {
177
+ reasons.push(`D5 recommendation.option_id ${JSON.stringify(chosen)}: choose a task option id.`);
178
+ }
179
+ if (chosen === 'none-of-these') {
180
+ if (result.unlisted_option === 'none' || [...result.unlisted_option.trim()].length < 40) {
181
+ reasons.push(`D10 unlisted_option ${JSON.stringify(result.unlisted_option)}: describe an unlisted option in at least 40 characters.`);
182
+ }
183
+ const rejected = new Set(result.rejected.map(({ option_id }) => option_id));
184
+ const missing = [...ids].filter((id) => !rejected.has(id));
185
+ if (missing.length) {
186
+ reasons.push(`D10 rejected ${JSON.stringify(missing)}: reject every task option when recommending "none-of-these".`);
187
+ }
188
+ } else if (ids.has(chosen) && result.unlisted_option !== 'none') {
189
+ reasons.push(`D10 unlisted_option ${JSON.stringify(result.unlisted_option)}: use "none" when recommending a task option.`);
190
+ }
191
+ for (const [index, item] of result.rejected.entries()) {
192
+ if (!ids.has(item.option_id)) {
193
+ reasons.push(`D5 rejected[${index}].option_id ${JSON.stringify(item.option_id)}: use a task option id.`);
194
+ }
195
+ if (item.option_id === chosen) {
196
+ reasons.push(`D5 rejected[${index}].option_id ${JSON.stringify(chosen)}: do not reject the recommended option.`);
197
+ }
198
+ }
199
+ if (scopeResult !== undefined) {
200
+ const riskIds = new Set(scopeResult.predicted_risks.map(({ id }) => id));
201
+ for (const [index, item] of result.risk_outcomes.entries()) {
202
+ if (!riskIds.has(item.risk_id)) {
203
+ reasons.push(`D10 risk_outcomes[${index}].risk_id ${JSON.stringify(item.risk_id)}: use a scope predicted risk id.`);
204
+ }
205
+ }
206
+ for (const id of riskIds) {
207
+ const count = result.risk_outcomes.filter(({ risk_id }) => risk_id === id).length;
208
+ if (count !== 1) {
209
+ reasons.push(`D10 risk_outcomes ${JSON.stringify(id)} count ${count}: provide exactly one outcome per predicted risk.`);
210
+ }
211
+ }
212
+ }
213
+ }
214
+ checkCitations({ phase, result, task, repoRoot, scopeResult }, reasons);
215
+ const phrases = Object.hasOwn(BACKSTOPS, language) ? BACKSTOPS[language] : Object.values(BACKSTOPS).flat();
216
+ const patterns = phrases.map((phrase) => [phrase, backstopPattern(phrase)]);
217
+ for (const { field, value } of strings(result)) {
218
+ for (const [phrase, pattern] of patterns) {
219
+ const match = value.normalize('NFKC').match(pattern);
220
+ if (match) {
221
+ reasons.push(`D5 ${field} ${JSON.stringify(match[0])}: replace backstop phrase ${JSON.stringify(phrase)} with a decision.`);
222
+ }
223
+ }
224
+ }
225
+ return { ok: reasons.length === 0, reasons };
226
+ }
@@ -0,0 +1,69 @@
1
+ /** Parses blind advisor task choices so Plan_59 D5 rejects biased or ambiguous inputs before review. */
2
+ const OPTION = /^\s*-\s+([a-z0-9][a-z0-9-]*):\s*(.*)$/;
3
+ const PREFERENCE = /recommend|prefer|рекоменд|предпочт|\(✓\)|★/iu;
4
+
5
+ function sections(text) {
6
+ if (typeof text !== 'string') throw new TypeError('Advisor task text must be a string.');
7
+ const found = { options: { header: null, lines: [] }, paths: { header: null, lines: [] } };
8
+ let current = null;
9
+ for (const [index, value] of text.split(/\r\n|\n|\r/).entries()) {
10
+ const line = { number: index + 1, value };
11
+ const heading = value.match(/^\s{0,3}(#{1,2})\s+(.+?)\s*$/);
12
+ if (heading) {
13
+ const name = heading[2].replace(/\s+#+$/, '').toLowerCase();
14
+ current = heading[1] === '##' && Object.hasOwn(found, name) ? found[name] : null;
15
+ if (current && !current.header) current.header = line;
16
+ } else if (current) {
17
+ current.lines.push(line);
18
+ }
19
+ }
20
+ return found;
21
+ }
22
+
23
+ function optionsFrom(lines) {
24
+ return lines.flatMap(({ value }) => {
25
+ const match = value.match(OPTION);
26
+ return match ? [{ id: match[1], description: match[2].trim() }] : [];
27
+ });
28
+ }
29
+
30
+ function pathsFrom(lines) {
31
+ return lines.flatMap(({ value }) => {
32
+ const match = value.match(/^\s*-\s+(.+?)\s*$/);
33
+ return match && match[1].trim() ? [match[1].trim()] : [];
34
+ });
35
+ }
36
+
37
+ export function parseAdvisorTask(text) {
38
+ const { options, paths } = sections(text);
39
+ return { options: optionsFrom(options.lines), paths: pathsFrom(paths.lines) };
40
+ }
41
+
42
+ function at(line) {
43
+ return `line ${line.number} ${JSON.stringify(line.value)}`;
44
+ }
45
+
46
+ export function advisorTaskRefusal(text) {
47
+ const { options, paths } = sections(text);
48
+ if (!options.header) return 'Options: add a "## Options" section with at least two options.';
49
+ const ids = new Set();
50
+ for (const line of options.lines) {
51
+ const preference = line.value.match(PREFERENCE);
52
+ if (preference) {
53
+ return `Options ${at(line)}: remove preference marker ${JSON.stringify(preference[0])} (D5).`;
54
+ }
55
+ if (!/^\s*[-*+](?:\s|$)/.test(line.value)) continue;
56
+ const match = line.value.match(OPTION);
57
+ if (!match) {
58
+ return `Options ${at(line)}: use "- option-id: description" with id matching [a-z0-9][a-z0-9-]*.`;
59
+ }
60
+ if (ids.has(match[1])) return `Options ${at(line)}: replace duplicate option id "${match[1]}".`;
61
+ ids.add(match[1]);
62
+ }
63
+ if (ids.size < 2) return `Options ${at(options.header)}: provide at least two distinct options.`;
64
+ if (!paths.header) return 'Paths: add a "## Paths" section with repository-relative paths.';
65
+ if (!pathsFrom(paths.lines).length) {
66
+ return `Paths ${at(paths.header)}: add at least one repository-relative path as a list item.`;
67
+ }
68
+ return null;
69
+ }
@@ -2,8 +2,7 @@
2
2
  * Renders the reply lines a dispatcher is allowed to return, one format per agent.
3
3
  *
4
4
  * AGENTS adds reply strategies to the shared agent registry: for each one it describes how
5
- * to tell the result file is filled in and which of the three reply strategies below
6
- * renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
5
+ * to tell the result file is filled in and which reply strategy below renders it. FAIL and LIMIT bypass the per-agent strategy — a run that
7
6
  * produced nothing has nothing agent-specific left to say.
8
7
  *
9
8
  * The lines built here ARE the reply. Agents forward this text verbatim instead of
@@ -41,6 +40,12 @@ export const AGENTS = {
41
40
  filled: (r) => Boolean(String(r?.verdict || '').trim()),
42
41
  reply: reviewReply,
43
42
  },
43
+ // Plan_59 D4: one result file, two contracts. Either phase's decisive field proves it is filled.
44
+ 'codex-advisor': {
45
+ ...AGENT_DEFINITIONS['codex-advisor'],
46
+ filled: (r) => typeof r?.sufficient === 'boolean' || Boolean(String(r?.recommendation?.option_id || '').trim()),
47
+ reply: advisorReply,
48
+ },
44
49
  };
45
50
 
46
51
  function scoutReply(ctx) {
@@ -113,6 +118,40 @@ function reviewReply(ctx) {
113
118
  ];
114
119
  }
115
120
 
121
+ /**
122
+ * Plan_59 D4: an insufficient scope is an OK run with a named outcome, not a LIMIT — the first
123
+ * word stays the status, and the outcome that decides the orchestrator's next step is the first
124
+ * thing after it, derived from the same fields meta.json records.
125
+ */
126
+ function advisorReply(ctx) {
127
+ const r = ctx.result;
128
+ const log = `Report: ${ctx.file(path.basename(ctx.resultPath))} · Log: ${readCommand(ctx.runDir)}`;
129
+ // The result's own shape picks the lines, as it does in `filled`: a run folder missing its phase
130
+ // record must not send a scope answer into the advise branch and crash the verdict.
131
+ if (typeof r.sufficient === 'boolean') {
132
+ const missing = r.missing_paths || [];
133
+ const risks = r.predicted_risks || [];
134
+ return [
135
+ r.sufficient
136
+ ? 'OK — scope: sufficient; continue this run with --phase advise'
137
+ : `OK — scope: insufficient; ${missing.length} paths named`,
138
+ ...(missing.length ? [`Missing: ${line(missing.join(', '), 160)}`] : []),
139
+ `Predicted risks: ${risks.length}${risks[0] ? ` — ${risks[0].id} ${line(risks[0].risk, 120)}` : ''}`,
140
+ log,
141
+ ];
142
+ }
143
+ const outcomes = r.risk_outcomes || [];
144
+ const confirmed = outcomes.filter((o) => o.outcome === 'confirmed').length;
145
+ const rejected = (r.rejected || []).map((o) => `${o.option_id} (${line(o.cost, 60)})`);
146
+ return [
147
+ `OK — recommend ${r.recommendation.option_id}: ${line(r.recommendation.text, 200)}`,
148
+ `Rejected: ${rejected.length ? line(rejected.join('; '), 180) : 'none'}`,
149
+ `Counter: ${line(r.strongest_counterargument, 180)}`,
150
+ `Risks: ${confirmed} confirmed · ${outcomes.length - confirmed} refuted · open questions ${(r.open_questions || []).length} · confidence ${r.confidence}`,
151
+ log,
152
+ ];
153
+ }
154
+
116
155
  /**
117
156
  * Which worker actually ran, printed next to the log link.
118
157
  *
@@ -197,6 +197,8 @@ export function abandonedBranchDrift(runsRoot, repo, currentBranch) {
197
197
  export function writeFailure(runDir, agent, reason, extraLines = [], preStart = false) {
198
198
  const meta = {
199
199
  agent,
200
+ phase: readJson(path.join(runDir, 'worker.json'))?.phase
201
+ ?? readJson(path.join(runDir, 'status.json'))?.phase ?? null,
200
202
  project: path.basename(path.dirname(runDir)),
201
203
  run: path.basename(runDir),
202
204
  finished_at: new Date().toISOString(),
@@ -28,6 +28,7 @@ import { deadlineReason } from './deadline.mjs';
28
28
  import { startupGap } from './startup.mjs';
29
29
  import { transportGap } from './transport.mjs';
30
30
  import { reasonFrom } from './reason.mjs';
31
+ import { adviceGap } from './advice-status.mjs';
31
32
 
32
33
  // How much prose an answer must carry once coordinates and paths are subtracted. Measured
33
34
  // on the scout run of 2026-07-30 that replied with a table of `file.ts:60-79` rows: every
@@ -329,6 +330,12 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
329
330
  const gap = scoutCoverageGap(runDir, result);
330
331
  if (gap) return { status: 'FAIL', reason: gap };
331
332
  }
333
+ // Plan_59 D3/D4/D5/D10: a schema-valid agreement still needs evidence and decision checks.
334
+ // Place the advisor judge before the build-only scope check, alongside the other agent contracts.
335
+ if (agent === 'codex-advisor') {
336
+ const gap = adviceGap(runDir, result, eventData);
337
+ if (gap) return { status: 'FAIL', reason: gap };
338
+ }
332
339
  // Files the task never authorised. Checked before the mismatch below, because a run that
333
340
  // did the job AND edited three unrelated files passes reportVersusWork() — one matching
334
341
  // path is all it needs — and the unrelated files would go unmentioned. No automatic
@@ -80,6 +80,23 @@ export const REQUIRED_INPUTS = Object.freeze({
80
80
  TASK_FILE_INPUT,
81
81
  CONTINUATION_INPUT,
82
82
  ]),
83
+ // Plan_59 D7: the caller must name the phase and authorize the continued decision pass.
84
+ 'codex-advisor': freezeEntries([
85
+ {
86
+ label: 'order id',
87
+ source: 'the orchestrator',
88
+ explanation: 'The label this design order is known by. Keep the same order id for scope and advise so phase 2 can settle the risks predicted in phase 1.',
89
+ example: 'plan-59-advisor-20260922',
90
+ },
91
+ TASK_FILE_INPUT,
92
+ CONTINUATION_INPUT,
93
+ {
94
+ label: 'phase',
95
+ source: 'the orchestrator',
96
+ explanation: 'Pass scope first to predict risks and check the reading boundary, then advise with --continue and a continuation grant naming the scope run of the same order.',
97
+ example: 'scope',
98
+ },
99
+ ]),
83
100
  });
84
101
 
85
102
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -101,6 +118,10 @@ export function isPlaceholder(value) {
101
118
  return PLACEHOLDER_VALUES.has(cleaned.replace(/[.,;:]+$/, '').trim().toLowerCase());
102
119
  }
103
120
 
121
+ // Plan_59: scope is a real phase value, while it remains a placeholder for existing inputs.
122
+ const isInputPlaceholder = (value, label) =>
123
+ !(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
124
+
104
125
  export function extractValue(promptText, label) {
105
126
  const labelPattern = escapeRegExp(label).replaceAll('\\ ', '\\s+');
106
127
  const flag = `--${label.replaceAll(/\s+/g, '-')}`;
@@ -141,7 +162,7 @@ export function diagnoseInput(promptText, label) {
141
162
  if (candidateLine === undefined) return null;
142
163
 
143
164
  const value = extractValue(candidateLine, label);
144
- if (value !== null && !isPlaceholder(value)) {
165
+ if (value !== null && !isInputPlaceholder(value, label)) {
145
166
  if (label === TASK_FILE_INPUT.label && !isAbsoluteTaskFilePath(value)) {
146
167
  return { line: readableDiagnosisLine(candidateLine), reason: `value \`${value}\` is not an absolute path` };
147
168
  }
@@ -149,7 +170,7 @@ export function diagnoseInput(promptText, label) {
149
170
  }
150
171
 
151
172
  const line = readableDiagnosisLine(candidateLine);
152
- if (value !== null && isPlaceholder(value)) {
173
+ if (value !== null && isInputPlaceholder(value, label)) {
153
174
  const displayedValue = value.trim();
154
175
  if (!displayedValue) return { line, reason: 'value is empty; replace it with a concrete value' };
155
176
  return {
@@ -195,7 +216,7 @@ export function missingInputs(agentType, promptText) {
195
216
  return requiredInputsFor(agentType).filter((entry) => {
196
217
  if (entry.conditional) return false;
197
218
  const value = extractValue(prompt, entry.label);
198
- if (isPlaceholder(value)) return true;
219
+ if (isInputPlaceholder(value, entry.label)) return true;
199
220
  return entry === TASK_FILE_INPUT && !isAbsoluteTaskFilePath(value);
200
221
  });
201
222
  }
@@ -206,7 +227,7 @@ export function shellUnsafeInputs(agentType, promptText) {
206
227
  return requiredInputsFor(agentType).flatMap((entry) => {
207
228
  if (entry.conditional) return [];
208
229
  const value = extractValue(prompt, entry.label);
209
- if (value === null || isPlaceholder(value)) return [];
230
+ if (value === null || isInputPlaceholder(value, entry.label)) return [];
210
231
  const sequence = firstShellUnsafeSequence(value);
211
232
  return sequence === null ? [] : [{ entry, sequence }];
212
233
  });
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Options:
8
8
  * --agent codex-scout | codex-build | codex-review (required)
9
+ * --phase declared phase name (optional only for a role with just the default phase)
9
10
  * --repo repository path (default: current directory)
10
11
  * --slug short run name used in the run folder (default: agent suffix)
11
12
  * --effort reasoning effort, passed to Codex as given (default: medium)
@@ -79,7 +79,11 @@ const state = (config) => [
79
79
  return `${key}: ${model}${profile.effort ? ` at ${profile.effort} effort` : ''}`
80
80
  + (profile.speed ? ` on ${profile.speed} tier` : '');
81
81
  }).join('; ')}`,
82
- `budgets: ${ROLES.map((role) => `${role}: ${config.budgets?.[role] ?? DEFAULTS.budgets[role]} minutes`).join('; ')}`,
82
+ `budgets: ${ROLES.map((role) => {
83
+ const phases = Object.entries(config.budgets?.[role] ?? DEFAULTS.budgets[role]);
84
+ return `${role}: ` + (phases.length === 1 ? `${phases[0][1]} minutes`
85
+ : phases.map(([phase, minutes]) => `${phase}: ${minutes} minutes`).join(', '));
86
+ }).join('; ')}`,
83
87
  `retention: ${config.retention?.enabled ? `on — transport older than ${config.retention.days} days` : 'off — automatic cleanup disabled'}`,
84
88
  ];
85
89
 
@@ -0,0 +1,50 @@
1
+ /** Owns the scope snapshot shared by advise readers because run 2026-09-23_141341_plan59-a1-stdin-hang left them with different inputs. */
2
+ import path from 'node:path';
3
+ import { readJsonFileSync } from '../json-file.mjs';
4
+ import { parseAdvisorTask } from '../meta/advisor-task.mjs';
5
+ import { die } from './args.mjs';
6
+
7
+ export function advisorTaskArtifact({ taskText, phase, runsRoot, grantRun }) {
8
+ const task = parseAdvisorTask(taskText);
9
+ if (phase !== 'advise') return task;
10
+ if (typeof runsRoot !== 'string' || !runsRoot || typeof grantRun !== 'string' || !grantRun) {
11
+ throw new Error('Cannot carry advise scope result: runsRoot and grantRun are required.');
12
+ }
13
+ const scopeFile = path.join(runsRoot, grantRun, 'result.json');
14
+ let result;
15
+ try {
16
+ result = readJsonFileSync(scopeFile);
17
+ } catch (error) {
18
+ throw new Error(`Cannot read advise scope result at ${scopeFile}: ${error.message}`);
19
+ }
20
+ if (!result || typeof result !== 'object' || Array.isArray(result) ||
21
+ !Array.isArray(result.predicted_risks) || !Array.isArray(result.missing_paths) ||
22
+ result.predicted_risks.some((risk) => !risk || typeof risk.id !== 'string' || typeof risk.risk !== 'string') ||
23
+ result.missing_paths.some((entry) => typeof entry !== 'string')) {
24
+ throw new Error(`Malformed advise scope result at ${scopeFile}: expected predicted_risks and missing_paths arrays.`);
25
+ }
26
+ return { ...task, scope: { run: grantRun, predicted_risks: result.predicted_risks, missing_paths: result.missing_paths } };
27
+ }
28
+
29
+ // Called before makeRunDir: a scope result that cannot be carried is known before any quota is
30
+ // spent, so it refuses like every other preflight gate instead of leaving a registered run behind.
31
+ export function advisorTaskOrRefuse(input) {
32
+ try {
33
+ return advisorTaskArtifact(input);
34
+ } catch (error) {
35
+ return die(`${error.message} The run folder was not created; quota was not spent.`, 1);
36
+ }
37
+ }
38
+
39
+ export function adviseSection(artifact) {
40
+ if (!artifact?.scope) return '';
41
+ return [
42
+ '## Scope phase results',
43
+ '',
44
+ 'Predicted risks:',
45
+ ...artifact.scope.predicted_risks.map(({ id, risk }) => `- ${id}: ${risk}`),
46
+ '',
47
+ 'Paths you may additionally read and cite:',
48
+ ...artifact.scope.missing_paths.map((entry) => `- ${entry}`),
49
+ ].join('\n');
50
+ }
@@ -89,6 +89,7 @@ const SHELL_CHECKED_FLAGS = Object.freeze([
89
89
  'scope-new',
90
90
  'repo',
91
91
  'agent',
92
+ 'phase',
92
93
  'effort',
93
94
  'changeset',
94
95
  'task-file',
@@ -5,6 +5,7 @@ import { readJsonFileSync } from '../json-file.mjs';
5
5
  import { workerMayBeAlive } from '../meta/run-liveness.mjs';
6
6
  import { processAlive } from '../process-identity.mjs';
7
7
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
8
+ import { sameRun } from './continuation.mjs';
8
9
  import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
9
10
 
10
11
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
@@ -93,18 +94,21 @@ export async function waitForReply(runDir, workerPid, status) {
93
94
  * path only covers a live run that has not recorded a verdict yet.
94
95
  *
95
96
  * A repeat that arrives after the verdict is answered from disk rather than refused, for the same
96
- * reason. `--continue` is the one case that must not attach: it is the orchestrator saying it read
97
- * the previous reply and wants another pass.
97
+ * reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
98
+ * incident showed that repeating the identical continuation command must return to that run.
98
99
  */
99
- export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
100
- if (isContinue) return null;
100
+ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, grantRun, isContinue, noWait } = {}) {
101
101
  const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
102
102
  const runRecords = runs
103
103
  .map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
104
104
  .filter(({ status }) => status);
105
105
  const sameOrder = runsForOrder(runRecords, orderId);
106
+ const attachRuns = isContinue
107
+ ? sameOrder.filter(({ status }) => grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
108
+ : sameOrder;
109
+ if (isContinue && !attachRuns.length) return null;
106
110
 
107
- const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
111
+ const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
108
112
  if (owner) {
109
113
  const ownerDir = path.join(runsRoot, owner.run);
110
114
  console.log(
@@ -120,8 +124,8 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
120
124
  // order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
121
125
  // a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
122
126
  let candidate = null;
123
- for (let i = sameOrder.length - 1; i >= 0; i -= 1) {
124
- const entry = sameOrder[i];
127
+ for (let i = attachRuns.length - 1; i >= 0; i -= 1) {
128
+ const entry = attachRuns[i];
125
129
  const dir = path.join(runsRoot, entry.run);
126
130
  if (fs.existsSync(path.join(dir, 'reply.txt'))) {
127
131
  announceSavedReply(dir, orderId, entry.status.started_at);
@@ -63,7 +63,7 @@ export function runProfile(opts) {
63
63
  // Plan_57: the preflight must probe the same permissions that the role will request.
64
64
  export function sandboxModeFor(agent) {
65
65
  if (agent === 'codex-build') return 'workspace-write';
66
- if (agent === 'codex-scout' || agent === 'codex-review') return 'read-only';
66
+ if (agent === 'codex-scout' || agent === 'codex-review' || agent === 'codex-advisor') return 'read-only';
67
67
  throw new Error(`unknown agent for sandbox mode: ${agent}`);
68
68
  }
69
69
 
@@ -75,7 +75,8 @@ export function codexArgs(opts, runDir, isGitRepo) {
75
75
  const modelArgs = profile.model ? ['-m', profile.model] : [];
76
76
  // Plan_56 D35: there is no identifier for ordinary speed; an unpinned tier adds no override.
77
77
  const speedArgs = profile.speed ? ['-c', `service_tier=${profile.speed}`] : [];
78
- if (opts.agent === 'codex-scout') {
78
+ // Plan_59: advisor uses schema-backed plain exec with the same read-only isolation as scout.
79
+ if (opts.agent === 'codex-scout' || opts.agent === 'codex-advisor') {
79
80
  return [
80
81
  'exec',
81
82
  '--json',
@@ -89,7 +90,7 @@ export function codexArgs(opts, runDir, isGitRepo) {
89
90
  ...modelArgs,
90
91
  '--sandbox',
91
92
  sandboxMode,
92
- '--skip-git-repo-check',
93
+ ...(opts.agent === 'codex-scout' || !isGitRepo ? ['--skip-git-repo-check'] : []),
93
94
  '-C',
94
95
  opts.repo,
95
96
  '--output-schema',
@@ -158,7 +158,7 @@ export function spawnCaptured(command, args, options) {
158
158
  export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = STDIO_DRAIN_GRACE_MS) {
159
159
  const onWindows = process.platform === 'win32';
160
160
  const deadlineMs =
161
- typeof budgetMinutes === 'number' && Number.isFinite(budgetMinutes) && budgetMinutes > 0
161
+ Number.isFinite(budgetMinutes) && budgetMinutes > 0
162
162
  ? budgetMinutes * 60 * 1000
163
163
  : null;
164
164
  let exited = false;
@@ -9,7 +9,9 @@ import path from 'node:path';
9
9
  import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
10
10
  import { readJson } from '../write-meta.mjs';
11
11
 
12
- const sameRun = (runsRootPath, left, right) => {
12
+ // One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
13
+ // disagree about whether two spellings name the same folder.
14
+ export const sameRun = (runsRootPath, left, right) => {
13
15
  const a = path.resolve(runsRootPath, left);
14
16
  const b = path.resolve(runsRootPath, right);
15
17
  return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;