@lyupro/codex-bridge 0.6.4 → 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.
@@ -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
 
@@ -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',
@@ -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;
@@ -27,7 +27,8 @@ import { parseArgs, die } from './args.mjs';
27
27
  import { settleTaskInput } from './task-input.mjs';
28
28
  import { parseContinuationGrant } from '../required-inputs.mjs';
29
29
  import { continuationRefusal } from './continuation.mjs';
30
- import { SCHEMAS } from './schemas.mjs';
30
+ import { parseAdvisorTask } from '../meta/advisor-task.mjs';
31
+ import { schemaFor } from './schemas.mjs';
31
32
  import { INSTRUCTIONS } from './prompts.mjs';
32
33
  import { git, headSha, branchName, worktreeSnapshot, reviewScope } from './git-state.mjs';
33
34
  import { agentRole } from '../agents.mjs';
@@ -40,7 +41,7 @@ import { cleanupRetention } from '../retention.mjs';
40
41
  import { renderConventions } from './conventions.mjs';
41
42
  import { validateScope } from './scope-check.mjs';
42
43
  import { probeSandbox, sandboxRefusal } from './sandbox-probe.mjs';
43
- import { preflightRefusal } from './preflight.mjs';
44
+ import { preflightRefusal, resolveRunPhase, scopeRunRefusal, taskPreflight } from './preflight.mjs';
44
45
 
45
46
  /**
46
47
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -93,7 +94,11 @@ export const runDirPath = (root, slug, runStamp = stamp()) => {
93
94
  export async function launcher(argv = process.argv.slice(2)) {
94
95
  loadRunEnv();
95
96
  const opts = parseArgs(argv);
97
+ opts.phase = resolveRunPhase(opts, RUN_ENV.budgets);
96
98
  const taskText = settleTaskInput(opts);
99
+ // Plan_59 D5/D6: blind choices and design authority must be checked before the paid probe.
100
+ const taskGate = taskPreflight({ agent: opts.agent, taskText });
101
+ if (taskGate.refusal) die(taskGate.refusal, 1);
97
102
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
98
103
  const isGitRepo = topLevel.status === 0;
99
104
  const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
@@ -155,6 +160,10 @@ export async function launcher(argv = process.argv.slice(2)) {
155
160
  continuationGrant,
156
161
  );
157
162
  if (continuationError) die(continuationError);
163
+ // Plan_59 D14: the grant is valid by now; an advise pass may continue only an OK scope run.
164
+ const scopeRun = continuationGrant &&
165
+ scopeRunRefusal({ agent: opts.agent, phase: opts.phase, runsRoot: projectRunsRoot, grantRun: continuationGrant.run });
166
+ if (scopeRun) die(scopeRun, 1);
158
167
 
159
168
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
160
169
  // restart the synchronous launcher. A live same-order run is now the repeat target: attach
@@ -230,6 +239,8 @@ export async function launcher(argv = process.argv.slice(2)) {
230
239
  agent: opts.agent,
231
240
  slug: opts.slug,
232
241
  order_id: opts.orderId,
242
+ phase: opts.phase,
243
+ ...(taskGate.advice === undefined ? {} : { advice: taskGate.advice }),
233
244
  // Fingerprint of the order, so a later run of the same task finds this one whatever it
234
245
  // calls itself. Written here, before Codex starts, like everything the chain reads.
235
246
  task_hash: taskHash,
@@ -263,6 +274,7 @@ export async function launcher(argv = process.argv.slice(2)) {
263
274
  if (opts.agent === 'codex-scout') {
264
275
  fs.writeFileSync(path.join(runDir, 'questions.json'), `${JSON.stringify(questions, null, 2)}\n`);
265
276
  }
277
+ if (opts.agent === 'codex-advisor') fs.writeFileSync(path.join(runDir, 'advisor-task.json'), `${JSON.stringify(parseAdvisorTask(taskText), null, 2)}\n`);
266
278
 
267
279
  if (opts.agent === 'codex-build') {
268
280
  fs.writeFileSync(path.join(runDir, 'scope.txt'), `${opts.scopePatterns.join('\n')}\n`);
@@ -308,7 +320,7 @@ export async function launcher(argv = process.argv.slice(2)) {
308
320
  fs.writeFileSync(path.join(runDir, 'task.md'), `${sections.join('\n\n')}\n`);
309
321
  fs.writeFileSync(
310
322
  path.join(runDir, 'schema.json'),
311
- `${JSON.stringify(SCHEMAS[opts.agent], null, 2)}\n`,
323
+ `${JSON.stringify(schemaFor(opts.agent, opts.phase), null, 2)}\n`,
312
324
  );
313
325
 
314
326
  if (opts.agent === 'codex-build') {
@@ -341,7 +353,8 @@ export async function launcher(argv = process.argv.slice(2)) {
341
353
  repo: repoRoot,
342
354
  isGitRepo,
343
355
  launcherPid: process.pid,
344
- budgetMinutes: RUN_ENV?.budgets?.[agentRole(opts.agent)],
356
+ phase: opts.phase,
357
+ budgetMinutes: RUN_ENV.budgets[agentRole(opts.agent)][opts.phase],
345
358
  scopeNew: opts.scopeNewPatterns,
346
359
  profile: runProfile({ ...opts, repo: repoRoot }),
347
360
  args: codexArgv,