@lyupro/codex-bridge 0.6.2 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +146 -0
- package/README.md +3 -3
- package/cli/runs-inventory.mjs +5 -3
- package/cli/stop.mjs +2 -2
- package/cli/unlock.mjs +2 -2
- 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/hooks/live-runs.mjs +19 -21
- package/src/home/hooks/reply-guard.mjs +5 -2
- package/src/home/hooks/shell-write-intent.mjs +320 -154
- package/src/home/hooks/worktree-lock.mjs +9 -2
- package/src/home/hooks/worktree-witness.mjs +21 -40
- 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/events.mjs +4 -0
- package/src/home/lib/meta/launch-rows.mjs +45 -0
- package/src/home/lib/meta/reply.mjs +42 -34
- package/src/home/lib/meta/run-liveness.mjs +64 -0
- package/src/home/lib/meta/run-state.mjs +28 -34
- package/src/home/lib/meta/verdict.mjs +18 -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 +8 -4
- package/src/home/lib/runner/attach.mjs +5 -13
- package/src/home/lib/runner/codex-args.mjs +14 -5
- package/src/home/lib/runner/codex-cmd.mjs +105 -19
- package/src/home/lib/runner/git-state.mjs +6 -1
- package/src/home/lib/runner/launcher.mjs +36 -30
- package/src/home/lib/runner/preflight.mjs +109 -0
- package/src/home/lib/runner/prompts.mjs +39 -0
- package/src/home/lib/runner/sandbox-probe.mjs +109 -0
- package/src/home/lib/runner/schemas.mjs +137 -1
- package/src/home/lib/runner/scope-check.mjs +31 -3
- 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 +18 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves every role's wall-clock budget into a map of phase names to minutes.
|
|
3
|
+
*
|
|
4
|
+
* A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
|
|
5
|
+
* on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
|
|
6
|
+
* killed callers left their Codex processes and token spend unrecorded. Plan_59 D2 made the
|
|
7
|
+
* phase a runner concept: a number is shorthand for one `default` phase, and it is expanded
|
|
8
|
+
* here and nowhere else, so no consumer ever has to ask which of the two shapes it holds.
|
|
9
|
+
*/
|
|
10
|
+
import { AGENTS } from './agents.mjs';
|
|
11
|
+
|
|
12
|
+
export function resolveBudgets(file, overrides = {}, registry = AGENTS) {
|
|
13
|
+
const roles = Object.values(registry).map(({ role }) => role);
|
|
14
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
15
|
+
throw new Error(`${file}: key “budgets” must be an object keyed by ${roles.join(', ')}, ` +
|
|
16
|
+
`each holding a positive number of minutes or a phase map, not ${JSON.stringify(overrides)}`);
|
|
17
|
+
}
|
|
18
|
+
const normalize = (budget, key, defaults) => {
|
|
19
|
+
if (typeof budget === 'string' && !budget.trim()) {
|
|
20
|
+
throw new Error(`${file}: key “${key}” is empty; remove the field to use the default, ` +
|
|
21
|
+
'or give it a positive number of minutes or a phase map');
|
|
22
|
+
}
|
|
23
|
+
const scalar = typeof budget === 'number';
|
|
24
|
+
// A number for a role with named phases would replace its whole map with `default`: the
|
|
25
|
+
// config would read cleanly and every run of that role would then be refused.
|
|
26
|
+
if (scalar && defaults && Object.keys(defaults).some((phase) => phase !== 'default')) {
|
|
27
|
+
throw new Error(`${file}: key “${key}” must be a phase map for this role, ` +
|
|
28
|
+
`e.g. {${Object.keys(defaults).map((phase) => `"${phase}": ${defaults[phase]}`).join(', ')}}, ` +
|
|
29
|
+
`not ${JSON.stringify(budget)}`);
|
|
30
|
+
}
|
|
31
|
+
if (scalar) budget = { default: budget };
|
|
32
|
+
if (!budget || typeof budget !== 'object' || Array.isArray(budget)) {
|
|
33
|
+
throw new Error(`${file}: key “${key}” must be a positive number of minutes or a phase map, ` +
|
|
34
|
+
`not ${JSON.stringify(budget)}`);
|
|
35
|
+
}
|
|
36
|
+
for (const [phase, minutes] of Object.entries(budget)) {
|
|
37
|
+
if (!phase.trim()) throw new Error(`${file}: key “${key}” has an empty phase name`);
|
|
38
|
+
if (defaults && !scalar && !Object.hasOwn(defaults, phase)) {
|
|
39
|
+
throw new Error(`${file}: key “${key}” has unknown phase “${phase}”. ` +
|
|
40
|
+
`Only ${Object.keys(defaults).join(', ')} are allowed`);
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
43
|
+
throw new Error(`${file}: key “${key}.${phase}” must be a positive number of minutes, ` +
|
|
44
|
+
`not ${JSON.stringify(minutes)}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// D2: a scalar is a default-only budget; only an object requests a partial phase override.
|
|
48
|
+
return { ...(scalar ? {} : defaults), ...budget };
|
|
49
|
+
};
|
|
50
|
+
const budgets = Object.fromEntries(Object.values(registry).map(({ role, budget }) => {
|
|
51
|
+
const phases = normalize(budget, `budgets.${role}`);
|
|
52
|
+
if (!Object.keys(phases).length) {
|
|
53
|
+
throw new Error(`${file}: key “budgets.${role}” must declare at least one phase`);
|
|
54
|
+
}
|
|
55
|
+
return [role, phases];
|
|
56
|
+
}));
|
|
57
|
+
for (const [role, budget] of Object.entries(overrides)) {
|
|
58
|
+
if (!roles.includes(role)) {
|
|
59
|
+
throw new Error(`${file}: key “budgets” has unknown role “${role}”. Only ${roles.join(', ')} are allowed`);
|
|
60
|
+
}
|
|
61
|
+
budgets[role] = normalize(budget, `budgets.${role}`, budgets[role]);
|
|
62
|
+
}
|
|
63
|
+
return budgets;
|
|
64
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Shared by reading and editing so Plan_56 D27 cannot persist an unreadable config. */
|
|
2
2
|
import { AGENTS } from './agents.mjs';
|
|
3
|
+
import { resolveBudgets } from './budgets.mjs';
|
|
3
4
|
|
|
4
5
|
const BUDGET_KEY = 'budgets';
|
|
5
6
|
export const ROLES = Object.values(AGENTS).map(({ role }) => role);
|
|
@@ -31,7 +32,7 @@ export const DEFAULTS = {
|
|
|
31
32
|
hooks: false,
|
|
32
33
|
plugins: false,
|
|
33
34
|
models: {},
|
|
34
|
-
budgets:
|
|
35
|
+
budgets: resolveBudgets('agent registry'),
|
|
35
36
|
retention: DEFAULT_RETENTION,
|
|
36
37
|
environmentPaths: DEFAULT_ENVIRONMENT_PATHS,
|
|
37
38
|
answerLanguage: 'English',
|
|
@@ -88,43 +89,6 @@ function readRetention(file, value) {
|
|
|
88
89
|
return { enabled: true, days: value.days };
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
/**
|
|
92
|
-
* A run gets a hard wall-clock budget, because the caller's timeout is not a run contract:
|
|
93
|
-
* on 2026-08-03 one order restarted six times and spent 170,293 accounted tokens while four
|
|
94
|
-
* killed callers left their Codex processes and token spend unrecorded.
|
|
95
|
-
*/
|
|
96
|
-
function readBudgets(file, value) {
|
|
97
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`${file}: key “${BUDGET_KEY}” must be an object keyed by ${ROLES.join(', ')}, ` +
|
|
100
|
-
`each holding a positive number of minutes, not ${JSON.stringify(value)}`,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
const budgets = { ...DEFAULTS.budgets };
|
|
104
|
-
for (const [role, minutes] of Object.entries(value)) {
|
|
105
|
-
if (!ROLES.includes(role)) {
|
|
106
|
-
throw new Error(
|
|
107
|
-
`${file}: key “${BUDGET_KEY}” has unknown role “${role}”. ` +
|
|
108
|
-
`Only ${ROLES.join(', ')} are allowed`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
if (typeof minutes === 'string' && !minutes.trim()) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`${file}: key “${BUDGET_KEY}.${role}” is empty; remove the field to use the default ` +
|
|
114
|
-
`(${DEFAULTS.budgets[role]} minutes), or give it a positive number of minutes`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
|
|
118
|
-
throw new Error(
|
|
119
|
-
`${file}: key “${BUDGET_KEY}.${role}” must be a positive number of minutes, ` +
|
|
120
|
-
`not ${JSON.stringify(minutes)}`,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
budgets[role] = minutes;
|
|
124
|
-
}
|
|
125
|
-
return budgets;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
92
|
export function validateRunConfig(file, parsed) {
|
|
129
93
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
130
94
|
throw new Error(`${file} must contain an object like {"hooks": false, "plugins": false}`);
|
|
@@ -145,7 +109,7 @@ export function validateRunConfig(file, parsed) {
|
|
|
145
109
|
continue;
|
|
146
110
|
}
|
|
147
111
|
if (key === BUDGET_KEY) {
|
|
148
|
-
config[key] =
|
|
112
|
+
config[key] = resolveBudgets(file, value);
|
|
149
113
|
continue;
|
|
150
114
|
}
|
|
151
115
|
if (key === RETENTION_KEY) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Converts a finished advisor run into judge inputs because schemas accept well-formed agreement; Plan_59 D3/D4/D5/D10 require the judge to reject it. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DEFAULTS } from '../run-config.mjs';
|
|
5
|
+
import { judgeAdvice } from './advice-verdict.mjs';
|
|
6
|
+
import { readJson } from './paths.mjs';
|
|
7
|
+
|
|
8
|
+
function contractReason(reasons) {
|
|
9
|
+
const message = `advice contract broken (${reasons.length}): ${reasons.slice(0, 2).join('; ')}`;
|
|
10
|
+
return message.length <= 200 ? message : `${message.slice(0, 197)}...`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function adviceGap(runDir, result, eventData) {
|
|
14
|
+
const worker = readJson(path.join(runDir, 'worker.json')) || {};
|
|
15
|
+
const status = readJson(path.join(runDir, 'status.json')) || {};
|
|
16
|
+
const env = readJson(path.join(runDir, 'env.json')) || {};
|
|
17
|
+
const phase = worker.phase ?? status.phase;
|
|
18
|
+
const taskFile = path.join(runDir, 'advisor-task.json');
|
|
19
|
+
const task = readJson(taskFile);
|
|
20
|
+
const reasons = [];
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(taskFile)) reasons.push('advisor-task.json is missing');
|
|
23
|
+
else if (!Array.isArray(task?.options) || !Array.isArray(task?.paths)) {
|
|
24
|
+
reasons.push('advisor-task.json is missing parsed options or paths');
|
|
25
|
+
}
|
|
26
|
+
if (phase !== 'scope' && phase !== 'advise') reasons.push('worker.json/status.json is missing a valid advisor phase');
|
|
27
|
+
if (typeof status.repo !== 'string' || !status.repo) reasons.push('status.json#repo is missing');
|
|
28
|
+
if (!Number.isInteger(eventData?.commands_executed) || eventData.commands_executed < 0) {
|
|
29
|
+
reasons.push('eventData.commands_executed is missing or invalid');
|
|
30
|
+
}
|
|
31
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) reasons.push('result.json is missing or is not an object');
|
|
32
|
+
|
|
33
|
+
let scopeResult;
|
|
34
|
+
if (phase === 'advise') {
|
|
35
|
+
if (typeof status.continued_from !== 'string' || !status.continued_from) {
|
|
36
|
+
reasons.push('status.json#continued_from for the phase-1 result is missing');
|
|
37
|
+
} else {
|
|
38
|
+
const scopeFile = path.join(path.dirname(runDir), status.continued_from, 'result.json');
|
|
39
|
+
scopeResult = readJson(scopeFile);
|
|
40
|
+
if (!scopeResult || typeof scopeResult !== 'object' || !Array.isArray(scopeResult.predicted_risks)) {
|
|
41
|
+
reasons.push('phase-1 result.json or its predicted_risks is missing');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (reasons.length) return contractReason(reasons);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const verdict = judgeAdvice({
|
|
49
|
+
phase,
|
|
50
|
+
result,
|
|
51
|
+
task,
|
|
52
|
+
repoRoot: status.repo,
|
|
53
|
+
commandsRun: eventData.commands_executed,
|
|
54
|
+
language: env.answerLanguage ?? DEFAULTS.answerLanguage,
|
|
55
|
+
scopeResult,
|
|
56
|
+
});
|
|
57
|
+
return verdict.ok ? null : contractReason(verdict.reasons);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return contractReason([`result.json is missing required fields or has an unexpected shape (${error.message})`]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -157,6 +157,10 @@ export function readEvents(runDir) {
|
|
|
157
157
|
// not an archived run.
|
|
158
158
|
hasStream: exists,
|
|
159
159
|
hasEvents: events.length > 0,
|
|
160
|
+
// The 2026-09-16 dead sandbox rejected every command; refused calls never enter the
|
|
161
|
+
// stream. Only completed items prove execution, including commands with nonzero exits.
|
|
162
|
+
commands_executed: events.filter((event) =>
|
|
163
|
+
event.type === 'item.completed' && event.item?.type === 'command_execution').length,
|
|
160
164
|
tokens: accounting.tokens,
|
|
161
165
|
usage: accounting.usage,
|
|
162
166
|
session_id: started ? String(started.thread_id) : null,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Reply rows about facts the launcher recorded in status.json before the run began. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { line, readJson } from './paths.mjs';
|
|
4
|
+
|
|
5
|
+
function formatBytes(bytes) {
|
|
6
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
7
|
+
let value = bytes;
|
|
8
|
+
let index = 0;
|
|
9
|
+
while (value >= 1024 && index < units.length - 1) {
|
|
10
|
+
value /= 1024;
|
|
11
|
+
index += 1;
|
|
12
|
+
}
|
|
13
|
+
return `${value.toFixed(1)} ${units[index]}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function retentionReply(runDir) {
|
|
17
|
+
const retention = readJson(path.join(runDir, 'status.json'))?.retention;
|
|
18
|
+
const bytes = Number(retention?.bytes_freed);
|
|
19
|
+
const runs = Number(retention?.runs);
|
|
20
|
+
const days = Number(retention?.days);
|
|
21
|
+
if (!Number.isFinite(bytes) || bytes <= 0 || !Number.isFinite(runs) || runs <= 0
|
|
22
|
+
|| !Number.isFinite(days) || days <= 0) return null;
|
|
23
|
+
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Five copies of Retention missed writeFailure(), so both reply assemblers share this point.
|
|
27
|
+
// The 2026-09-16 dead Codex sandbox incident is why the launcher probes the sandbox at all.
|
|
28
|
+
export function launchRows(runDir) {
|
|
29
|
+
const retention = retentionReply(runDir);
|
|
30
|
+
const rows = retention ? [retention] : [];
|
|
31
|
+
const probe = readJson(path.join(runDir, 'status.json'))?.sandbox_probe;
|
|
32
|
+
if (probe?.outcome === 'inconclusive') {
|
|
33
|
+
const reason = line(probe.reason, 160) || 'no reason recorded';
|
|
34
|
+
rows.push(`Sandbox probe: inconclusive — ${reason} The run started without a sandbox check.`);
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function withLaunchRows(rows, runDir) {
|
|
40
|
+
const launch = launchRows(runDir);
|
|
41
|
+
if (!launch.length) return rows;
|
|
42
|
+
let at = rows.findIndex((row) => row.includes('Log: '));
|
|
43
|
+
if (at === -1) at = rows.findIndex((row) => row.startsWith('Run: '));
|
|
44
|
+
return at === -1 ? [...rows, ...launch] : [...rows.slice(0, at), ...launch, ...rows.slice(at)];
|
|
45
|
+
}
|