@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.
- package/CHANGELOG.md +65 -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 +57 -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/advise-carry.mjs +50 -0
- package/src/home/lib/runner/args.mjs +1 -0
- package/src/home/lib/runner/attach.mjs +11 -7
- 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/continuation.mjs +3 -1
- package/src/home/lib/runner/launcher.mjs +29 -15
- 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
|
|
|
@@ -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
|
+
}
|
|
@@ -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`
|
|
97
|
-
* the
|
|
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 =
|
|
124
|
-
const entry =
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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;
|