@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.
- package/CHANGELOG.md +45 -0
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/agents/codex-advisor.md +187 -0
- package/src/agents/codex-build.md +4 -0
- package/src/commands/env.md +5 -4
- package/src/home/lib/agents.mjs +3 -0
- package/src/home/lib/budgets.mjs +64 -0
- package/src/home/lib/config-validate.mjs +3 -39
- package/src/home/lib/meta/advice-status.mjs +61 -0
- package/src/home/lib/meta/advice-verdict.mjs +226 -0
- package/src/home/lib/meta/advisor-task.mjs +69 -0
- package/src/home/lib/meta/reply.mjs +41 -2
- package/src/home/lib/meta/run-state.mjs +2 -0
- package/src/home/lib/meta/verdict.mjs +7 -0
- package/src/home/lib/required-inputs.mjs +25 -4
- package/src/home/lib/run-codex.mjs +1 -0
- package/src/home/lib/run-config.mjs +5 -1
- package/src/home/lib/runner/args.mjs +1 -0
- package/src/home/lib/runner/codex-args.mjs +4 -3
- package/src/home/lib/runner/codex-cmd.mjs +1 -1
- package/src/home/lib/runner/launcher.mjs +17 -4
- package/src/home/lib/runner/preflight.mjs +75 -1
- package/src/home/lib/runner/prompts.mjs +39 -0
- package/src/home/lib/runner/schemas.mjs +137 -1
- package/src/home/lib/runner/scope-check.mjs +9 -1
- package/src/home/lib/runner/worker-order.mjs +4 -2
- package/src/home/lib/runner/worker.mjs +2 -2
- package/src/home/lib/write-meta.mjs +13 -0
|
@@ -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
|
|
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 && !
|
|
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 &&
|
|
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 (
|
|
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 ||
|
|
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) =>
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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,
|