@lyupro/codex-bridge 0.6.9 → 0.6.11
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 +132 -0
- package/README.md +15 -4
- package/cli/dispatcher-model-check.mjs +59 -0
- package/cli/doctor-host-side.mjs +2 -1
- package/cli/doctor-installation.mjs +111 -111
- package/cli/doctor.mjs +13 -5
- package/cli/handback-witness-check.mjs +24 -18
- package/cli/hosts.mjs +2 -10
- package/cli/install-owners.mjs +1 -4
- package/cli/install-record.mjs +2 -1
- package/cli/inventory-transition.mjs +1 -1
- package/cli/lifecycle-lock-inspect.mjs +3 -2
- package/cli/lifecycle-lock.mjs +19 -132
- package/cli/manifest.mjs +6 -6
- package/cli/read.mjs +20 -0
- package/cli/settings-merge.mjs +3 -29
- package/package.json +2 -2
- package/scripts/home-write-inventory.mjs +12 -2
- package/src/agents/codex-advisor.md +37 -121
- package/src/agents/codex-build.md +27 -135
- package/src/agents/codex-review.md +19 -111
- package/src/agents/codex-scout.md +21 -130
- package/src/home/hooks/dispatcher-gate.mjs +14 -9
- package/src/home/hooks/live-runs.mjs +0 -41
- package/src/home/hooks/order-gate.mjs +80 -47
- package/src/home/hooks/reply-guard.mjs +52 -65
- package/src/home/hooks/reply-identity.mjs +64 -0
- package/src/home/hooks/reply-parser.mjs +1 -1
- package/src/home/hooks/reply-verdicts.mjs +31 -27
- package/src/home/hooks/reply-witness.mjs +48 -0
- package/src/home/lib/claude-layout.mjs +14 -0
- package/src/home/lib/command-path.mjs +32 -0
- package/src/home/lib/dispatcher-call.mjs +171 -0
- package/src/home/lib/dispatcher-command.mjs +18 -54
- package/src/home/lib/dispatcher-gate.mjs +4 -0
- package/src/home/lib/dispatcher-model.mjs +112 -0
- package/src/home/lib/dispatcher-order.mjs +21 -22
- package/src/home/lib/dispatcher-pin.mjs +60 -0
- package/src/home/lib/dispatcher-protocol.mjs +91 -0
- package/src/home/lib/dispatcher-stop.mjs +11 -2
- package/src/home/lib/handback-witness.mjs +137 -48
- package/src/home/lib/home-registry.mjs +7 -0
- package/src/home/lib/install-owner-roots.mjs +53 -0
- package/src/home/lib/kernel-lock.mjs +171 -0
- package/src/home/lib/meta/chain.mjs +19 -8
- package/src/home/lib/meta/context-only.mjs +43 -0
- package/src/home/lib/meta/events.mjs +14 -2
- package/src/home/lib/meta/launch-rows.mjs +23 -0
- package/src/home/lib/meta/paths.mjs +1 -1
- package/src/home/lib/meta/reply.mjs +39 -4
- package/src/home/lib/meta/run-state.mjs +3 -0
- package/src/home/lib/meta/verdict.mjs +16 -8
- package/src/home/lib/model-family.mjs +38 -0
- package/src/home/lib/observation-ledger.mjs +145 -0
- package/src/home/lib/order-schema.mjs +204 -0
- package/src/home/lib/order-values.mjs +46 -0
- package/src/home/lib/run-codex.mjs +6 -3
- package/src/home/lib/runner/advise-carry.mjs +2 -1
- package/src/home/lib/runner/args.mjs +140 -220
- package/src/home/lib/runner/attach.mjs +24 -17
- package/src/home/lib/runner/build-evidence.mjs +29 -0
- package/src/home/lib/runner/codex-availability.mjs +50 -0
- package/src/home/lib/runner/codex-cmd.mjs +2 -19
- package/src/home/lib/runner/continuation.mjs +174 -140
- package/src/home/lib/runner/exit-codes.mjs +13 -0
- package/src/home/lib/runner/flag-baseline.mjs +105 -0
- package/src/home/lib/runner/flag-scan.mjs +165 -0
- package/src/home/lib/runner/git-state.mjs +0 -33
- package/src/home/lib/runner/launcher.mjs +237 -277
- package/src/home/lib/runner/order-claim.mjs +84 -0
- package/src/home/lib/runner/order-options.mjs +30 -0
- package/src/home/lib/runner/order-owner.mjs +19 -0
- package/src/home/lib/runner/pass-gate.mjs +111 -0
- package/src/home/lib/runner/preflight.mjs +26 -28
- package/src/home/lib/runner/prompts.mjs +7 -2
- package/src/home/lib/runner/question-kind.mjs +41 -0
- package/src/home/lib/runner/run-env.mjs +2 -1
- package/src/home/lib/runner/sandbox-probe.mjs +1 -1
- package/src/home/lib/runner/scope-check.mjs +31 -12
- package/src/home/lib/runner/task-input.mjs +34 -12
- package/src/home/lib/runner/worker-admission.mjs +43 -0
- package/src/home/lib/runner/worker.mjs +19 -18
- package/src/home/lib/subagent-model-env.mjs +44 -0
- package/src/home/lib/task-header.mjs +158 -0
- package/src/home/lib/untyped-stop.mjs +30 -0
- package/src/home/lib/write-meta.mjs +17 -5
- package/src/home/lib/required-inputs.mjs +0 -261
- /package/{cli → src/home/lib}/frontmatter.mjs +0 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Judges startup-context references and unread repository coordinates in scout responses.
|
|
3
|
+
* Plan_60 D1: honest answers about handed context need no command, unlike the 2026-09-16
|
|
4
|
+
* dead sandbox; a marker must never excuse invented addresses or an unsourced answer.
|
|
5
|
+
*/
|
|
6
|
+
import { QUESTION_KIND } from '../runner/question-kind.mjs';
|
|
7
|
+
|
|
8
|
+
export function allStartupContext(questions) {
|
|
9
|
+
return Array.isArray(questions) && questions.length > 0
|
|
10
|
+
&& questions.every((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function contextOnlyGap({ questions, result, commandsExecuted }) {
|
|
14
|
+
const answers = Array.isArray(result?.answers) ? result.answers : [];
|
|
15
|
+
const byId = new Map();
|
|
16
|
+
for (const answer of answers) {
|
|
17
|
+
const id = String(answer?.question_id || '').trim().toUpperCase();
|
|
18
|
+
if (id && !byId.has(id)) byId.set(id, answer);
|
|
19
|
+
}
|
|
20
|
+
const unsourced = (Array.isArray(questions) ? questions : [])
|
|
21
|
+
.filter((question) => question?.kind === QUESTION_KIND.STARTUP_CONTEXT)
|
|
22
|
+
.filter((question) => {
|
|
23
|
+
const answer = byId.get(String(question.id || '').trim().toUpperCase());
|
|
24
|
+
return !Array.isArray(answer?.evidence)
|
|
25
|
+
|| !answer.evidence.some((item) => /^startup:\s*\S/.test(String(item ?? '')));
|
|
26
|
+
})
|
|
27
|
+
.map((question) => question.id);
|
|
28
|
+
if (unsourced.length) {
|
|
29
|
+
return `responses to startup-context questions without a startup:<source> reference: ${unsourced.join(', ')}`;
|
|
30
|
+
}
|
|
31
|
+
if (commandsExecuted !== 0) return null;
|
|
32
|
+
const references = answers.flatMap((answer) => Array.isArray(answer?.evidence) ? answer.evidence : []);
|
|
33
|
+
references.push(...(Array.isArray(result?.findings) ? result.findings : []).map((finding) => finding?.where));
|
|
34
|
+
for (const reference of references) {
|
|
35
|
+
const text = String(reference ?? '');
|
|
36
|
+
if (text.startsWith('startup:')) continue;
|
|
37
|
+
const coordinate = text.match(/\S*\.[A-Za-z0-9]+:\d+/)?.[0];
|
|
38
|
+
if (coordinate) {
|
|
39
|
+
return `scout cited ${coordinate.slice(0, 60)} without executing a command: an address nobody read`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -14,6 +14,15 @@ import { safeSlice } from './paths.mjs';
|
|
|
14
14
|
*/
|
|
15
15
|
const LIMIT_RE = /rate[\s_-]*limit|usage[\s_-]*limit|quota|too many requests|\b429\b/i;
|
|
16
16
|
|
|
17
|
+
// Plan_60 D2: add a new measured sample only together with its verbatim fixture.
|
|
18
|
+
const UNAVAILABLE_SAMPLES = [
|
|
19
|
+
{
|
|
20
|
+
cli: 'codex-cli 0.159.0',
|
|
21
|
+
measured: '2026-10-03',
|
|
22
|
+
pattern: /^unexpected status 401 Unauthorized: Missing bearer or basic authentication in header\b/,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
17
26
|
const record = (value) => value && typeof value === 'object' && !Array.isArray(value);
|
|
18
27
|
|
|
19
28
|
const compact = (value, max = 300) =>
|
|
@@ -129,6 +138,8 @@ function transportErrorOf(event) {
|
|
|
129
138
|
const status = payload.status ?? nested.status ?? null;
|
|
130
139
|
const marker = [errorType, message].join(' ');
|
|
131
140
|
const quota = Number(status) === 429 || LIMIT_RE.test(marker);
|
|
141
|
+
const unavailable = !quota && event.type === 'turn.failed' &&
|
|
142
|
+
UNAVAILABLE_SAMPLES.some((sample) => sample.pattern.test(message));
|
|
132
143
|
const reason = compact(
|
|
133
144
|
[errorType, message, status === null || status === undefined ? '' : `status ${status}`]
|
|
134
145
|
.filter(Boolean)
|
|
@@ -137,7 +148,7 @@ function transportErrorOf(event) {
|
|
|
137
148
|
(typeof event.error?.message === 'string' ? event.error.message : '') ||
|
|
138
149
|
event.type,
|
|
139
150
|
);
|
|
140
|
-
return { event, payload, status, error_type: errorType, message, quota, reason };
|
|
151
|
+
return { event, payload, status, error_type: errorType, message, quota, unavailable, reason };
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
/**
|
|
@@ -150,7 +161,8 @@ export function readEvents(runDir) {
|
|
|
150
161
|
const accounting = usageOf(events);
|
|
151
162
|
const started = events.find((event) => event.type === 'thread.started' && event.thread_id);
|
|
152
163
|
const errors = events.map(transportErrorOf).filter(Boolean);
|
|
153
|
-
const transport_error = errors.find((error) => error.quota) ||
|
|
164
|
+
const transport_error = errors.find((error) => error.quota) ||
|
|
165
|
+
errors.find((error) => error.unavailable) || errors[0] || null;
|
|
154
166
|
return {
|
|
155
167
|
events,
|
|
156
168
|
// The file was written, whatever it holds. Pass 2 judges a run by this: a stream the
|
|
@@ -23,6 +23,27 @@ function retentionReply(runDir) {
|
|
|
23
23
|
return `Retention: freed ${formatBytes(bytes)} from ${runs} runs older than ${days} days`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function attemptReply(runDir) {
|
|
27
|
+
const retryOf = readJson(path.join(runDir, 'status.json'))?.retry_of;
|
|
28
|
+
if (typeof retryOf !== 'string' || !retryOf.trim()) return null;
|
|
29
|
+
const runsRoot = path.dirname(runDir);
|
|
30
|
+
const key = (name) => process.platform === 'win32' ? name.toLowerCase() : name;
|
|
31
|
+
const seen = new Set([key(path.basename(runDir))]);
|
|
32
|
+
let previous = retryOf;
|
|
33
|
+
let attempt = 1;
|
|
34
|
+
// Plan_75 D1, 2026-10-03 TradeForge: show retries without trusting broken provenance links.
|
|
35
|
+
for (let hops = 0; hops < 50; hops += 1) {
|
|
36
|
+
if (typeof previous !== 'string' || !previous || /[/\\]/.test(previous)
|
|
37
|
+
|| previous === '.' || previous === '..' || seen.has(key(previous))) break;
|
|
38
|
+
const status = readJson(path.join(runsRoot, previous, 'status.json'));
|
|
39
|
+
if (!status) break;
|
|
40
|
+
seen.add(key(previous));
|
|
41
|
+
attempt += 1;
|
|
42
|
+
previous = status.retry_of;
|
|
43
|
+
}
|
|
44
|
+
return `Attempt: ${attempt} of this pass — retry of ${retryOf}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
26
47
|
// Five copies of Retention missed writeFailure(), so both reply assemblers share this point.
|
|
27
48
|
// The 2026-09-16 dead Codex sandbox incident is why the launcher probes the sandbox at all.
|
|
28
49
|
export function launchRows(runDir) {
|
|
@@ -33,6 +54,8 @@ export function launchRows(runDir) {
|
|
|
33
54
|
const reason = line(probe.reason, 160) || 'no reason recorded';
|
|
34
55
|
rows.push(`Sandbox probe: inconclusive — ${reason} The run started without a sandbox check.`);
|
|
35
56
|
}
|
|
57
|
+
const attempt = attemptReply(runDir);
|
|
58
|
+
if (attempt) rows.push(attempt);
|
|
36
59
|
return rows;
|
|
37
60
|
}
|
|
38
61
|
|
|
@@ -114,7 +114,7 @@ export const samePath = (declared, touched) => {
|
|
|
114
114
|
};
|
|
115
115
|
|
|
116
116
|
/**
|
|
117
|
-
* One
|
|
117
|
+
* One scope pattern as a regular expression. Deliberately a small glob dialect and not a
|
|
118
118
|
* dependency: `**` crosses directory boundaries, `*` and `?` stop at `/`, matching is
|
|
119
119
|
* case-insensitive because Windows paths arrive in whatever case Codex felt like using.
|
|
120
120
|
* A slash right after `**` is optional, so `src/**` covers `src/a.ts` too — otherwise the
|
|
@@ -2,7 +2,8 @@
|
|
|
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 reply strategy below renders it.
|
|
5
|
+
* to tell the result file is filled in and which reply strategy below renders it.
|
|
6
|
+
* FAIL, LIMIT and UNAVAILABLE bypass the per-agent strategy — a run that
|
|
6
7
|
* produced nothing has nothing agent-specific left to say.
|
|
7
8
|
*
|
|
8
9
|
* The lines built here ARE the reply. Agents forward this text verbatim instead of
|
|
@@ -14,6 +15,7 @@ import { AGENTS as AGENT_DEFINITIONS } from '../agents.mjs';
|
|
|
14
15
|
import { changedPaths, line, readText } from './paths.mjs';
|
|
15
16
|
import { splitRunChanges } from './environment.mjs';
|
|
16
17
|
import { scoutCoverage } from './verdict.mjs';
|
|
18
|
+
import { orderInputName } from '../order-schema.mjs';
|
|
17
19
|
|
|
18
20
|
/** What the run itself changed, and what the tooling around it changed while it worked. */
|
|
19
21
|
const runChanges = (runDir) =>
|
|
@@ -24,6 +26,11 @@ const runChanges = (runDir) =>
|
|
|
24
26
|
|
|
25
27
|
const readCommand = (runDir) => `codex-bridge read ${runDir}`;
|
|
26
28
|
|
|
29
|
+
function stripVerdict(value) {
|
|
30
|
+
// Run 2026-10-01_114244_plan72-r3q-live-probe-20261001 repeated OK because the model prefixed its summary.
|
|
31
|
+
return String(value ?? '').replace(/^(?:OK|FAIL|LIMIT|UNAVAILABLE)(?: — | - |: )/, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
27
34
|
export const AGENTS = {
|
|
28
35
|
'codex-scout': {
|
|
29
36
|
...AGENT_DEFINITIONS['codex-scout'],
|
|
@@ -54,7 +61,7 @@ function scoutReply(ctx) {
|
|
|
54
61
|
const unknowns = (r.unknowns || []).filter(Boolean);
|
|
55
62
|
const coverage = scoutCoverage(ctx.runDir, r);
|
|
56
63
|
return [
|
|
57
|
-
`OK — ${line(r.answer, 160)}`,
|
|
64
|
+
`OK — ${line(stripVerdict(r.answer), 160)}`,
|
|
58
65
|
// Any explicit question gets a coverage line, including a valid one-question order.
|
|
59
66
|
...(coverage ? [`Coverage: ${coverage}`] : []),
|
|
60
67
|
`Key finding: ${top ? `${line(top.fact, 130)} (${line(top.where, 60)})` : 'no findings listed'}`,
|
|
@@ -68,6 +75,7 @@ function buildReply(ctx) {
|
|
|
68
75
|
const { work: touchedPaths, environment } = runChanges(ctx.runDir);
|
|
69
76
|
const paths = touchedPaths.slice(0, 3).join(', ');
|
|
70
77
|
const flags = readText(path.join(ctx.runDir, 'flags.txt')).split(/\r?\n/).filter(Boolean);
|
|
78
|
+
const gaps = readText(path.join(ctx.runDir, 'flags-coverage.txt')).split(/\r?\n/).filter((gap) => gap.trim());
|
|
71
79
|
// A multi-line verification is several commands, and collapsing them into one 60-character
|
|
72
80
|
// line cut the last one mid-word: the reply named a command nobody could run.
|
|
73
81
|
const commands = String(r.verify_command ?? '')
|
|
@@ -90,7 +98,7 @@ function buildReply(ctx) {
|
|
|
90
98
|
? `${paths ? `${paths} · ` : ''}changes were made by an earlier run of this task`
|
|
91
99
|
: paths || 'worktree untouched';
|
|
92
100
|
return [
|
|
93
|
-
`OK — ${line(r.summary, 300)}`,
|
|
101
|
+
`OK — ${line(stripVerdict(r.summary), 300)}`,
|
|
94
102
|
`Files: ${touchedPaths.length} changed · ${files}`,
|
|
95
103
|
// Only when something outside the run wrote to the tree. Subtracting those paths from the
|
|
96
104
|
// verdict without naming them would hide a real edit behind a pattern.
|
|
@@ -99,6 +107,9 @@ function buildReply(ctx) {
|
|
|
99
107
|
: []),
|
|
100
108
|
`Verification: ${verify} — ${verdict}`,
|
|
101
109
|
`Flags: ${flags.length ? `${flags.length} TODO/skip — ${line(flags.slice(0, 3).join(' | '), 140)}` : 'none'}`,
|
|
110
|
+
...(gaps.length
|
|
111
|
+
? [`Flags coverage: incomplete — ${line(gaps[0], 120)}${gaps.length > 1 ? ` (+${gaps.length - 1} more)` : ''}`]
|
|
112
|
+
: []),
|
|
102
113
|
`Report: ${ctx.file('report.md')} · Log: ${readCommand(ctx.runDir)}`,
|
|
103
114
|
];
|
|
104
115
|
}
|
|
@@ -133,7 +144,7 @@ function advisorReply(ctx) {
|
|
|
133
144
|
const risks = r.predicted_risks || [];
|
|
134
145
|
return [
|
|
135
146
|
r.sufficient
|
|
136
|
-
?
|
|
147
|
+
? `OK — scope: sufficient; continue this run with ${orderInputName('phase')} advise`
|
|
137
148
|
: `OK — scope: insufficient; ${missing.length} paths named`,
|
|
138
149
|
...(missing.length ? [`Missing: ${line(missing.join(', '), 160)}`] : []),
|
|
139
150
|
`Predicted risks: ${risks.length}${risks[0] ? ` — ${risks[0].id} ${line(risks[0].risk, 120)}` : ''}`,
|
|
@@ -223,3 +234,27 @@ export function limitReply(ctx, meta) {
|
|
|
223
234
|
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
224
235
|
return rows;
|
|
225
236
|
}
|
|
237
|
+
|
|
238
|
+
/** Plan_60 D2: pre-start and post-start refusals share the same handoff contract. */
|
|
239
|
+
export function unavailableRows(signal) {
|
|
240
|
+
return [
|
|
241
|
+
'UNAVAILABLE — Codex is not available (missing or signed out); ' +
|
|
242
|
+
'hand the task to the next executor, do not retry',
|
|
243
|
+
`Signal: ${line(signal, 170)}`,
|
|
244
|
+
'Operator check: codex --version; codex login status (sign in with codex login)',
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function unavailableReply(ctx, meta) {
|
|
249
|
+
const rows = unavailableRows(meta.reason);
|
|
250
|
+
if (ctx.agent === 'codex-build') {
|
|
251
|
+
const { work: touched } = runChanges(ctx.runDir);
|
|
252
|
+
rows.push(
|
|
253
|
+
`Worktree: ${
|
|
254
|
+
touched.length ? `has unfinished changes (${touched.length}), see git-after.txt` : 'no new changes'
|
|
255
|
+
}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
rows.push(`Log: ${readCommand(ctx.runDir)}`);
|
|
259
|
+
return rows;
|
|
260
|
+
}
|
|
@@ -12,6 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import { changedPaths, line, normalizePath, readJson, readText, size } from './paths.mjs';
|
|
13
13
|
import { withLaunchRows } from './launch-rows.mjs';
|
|
14
14
|
import { runLiveness } from './run-liveness.mjs';
|
|
15
|
+
import { removeFlagBaselineCopies } from '../runner/flag-baseline.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Run state on disk, merged over whatever is already there. A killed runner leaves no
|
|
@@ -115,6 +116,8 @@ export function markAbandoned(runsRoot, currentTree) {
|
|
|
115
116
|
}
|
|
116
117
|
try {
|
|
117
118
|
writeStatus(runDir, patch);
|
|
119
|
+
// Plan_60 D3a: a dead worker cannot remove its start copies; keep the manifest for inspection.
|
|
120
|
+
if (patch.state === 'abandoned') removeFlagBaselineCopies(runDir);
|
|
118
121
|
changed.push({ run: entry.name, state: patch.state });
|
|
119
122
|
} catch {
|
|
120
123
|
// A folder that cannot be written to is not worth failing a fresh run over.
|
|
@@ -28,6 +28,7 @@ import { startupGap } from './startup.mjs';
|
|
|
28
28
|
import { transportGap } from './transport.mjs';
|
|
29
29
|
import { reasonFrom } from './reason.mjs';
|
|
30
30
|
import { adviceGap } from './advice-status.mjs';
|
|
31
|
+
import { allStartupContext, contextOnlyGap } from './context-only.mjs';
|
|
31
32
|
import { compareSnapshots } from './snapshot-format.mjs';
|
|
32
33
|
import { readSnapshot, runSnapshotChanges, snapshotRefusal } from './run-snapshots.mjs';
|
|
33
34
|
|
|
@@ -156,7 +157,7 @@ function substanceLength(text) {
|
|
|
156
157
|
|
|
157
158
|
const questionsOf = (runDir) => {
|
|
158
159
|
const parsed = readJson(path.join(runDir, 'questions.json'));
|
|
159
|
-
return Array.isArray(parsed) ? parsed
|
|
160
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
160
161
|
};
|
|
161
162
|
|
|
162
163
|
/** Answers indexed by question id, first one wins; ids come back in whatever case Codex used. */
|
|
@@ -175,8 +176,8 @@ const answersById = (result) => {
|
|
|
175
176
|
* single-answer check. Every branch here exists because a schema alone accepted the run that
|
|
176
177
|
* answered in coordinates.
|
|
177
178
|
*/
|
|
178
|
-
function scoutCoverageGap(
|
|
179
|
-
const questions =
|
|
179
|
+
function scoutCoverageGap(orderedQuestions, result) {
|
|
180
|
+
const questions = orderedQuestions.filter((q) => q && q.id);
|
|
180
181
|
const show = (list) => list.slice(0, 8).join(', ');
|
|
181
182
|
|
|
182
183
|
if (!questions.length) {
|
|
@@ -211,7 +212,7 @@ function scoutCoverageGap(runDir, result) {
|
|
|
211
212
|
|
|
212
213
|
/** `1/1 sub-questions`, or null for legacy runs with no questions.json artifact. */
|
|
213
214
|
export function scoutCoverage(runDir, result) {
|
|
214
|
-
const questions = questionsOf(runDir);
|
|
215
|
+
const questions = questionsOf(runDir).filter((q) => q && q.id);
|
|
215
216
|
if (!questions.length) return null;
|
|
216
217
|
const byId = answersById(result);
|
|
217
218
|
const answered = questions.filter((q) => String(byId.get(q.id)?.answer || '').trim()).length;
|
|
@@ -288,9 +289,10 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
288
289
|
// This branch already knows why the run died: the CLI said so itself, with a status. Letting
|
|
289
290
|
// the ordered reason speak here would answer a transport question with the model's older
|
|
290
291
|
// complaint about the task — a LIMIT that never mentions quota is the same "verdict says the
|
|
291
|
-
// wrong thing" defect this module exists to remove.
|
|
292
|
+
// wrong thing" defect this module exists to remove. Plan_60 D2: UNAVAILABLE only on a measured
|
|
293
|
+
// signed-out sample (events.mjs); an unfamiliar failure stays FAIL with its raw CLI text.
|
|
292
294
|
return {
|
|
293
|
-
status: transportError.quota ? 'LIMIT' : 'FAIL',
|
|
295
|
+
status: transportError.quota ? 'LIMIT' : transportError.unavailable ? 'UNAVAILABLE' : 'FAIL',
|
|
294
296
|
reason: transportError.reason,
|
|
295
297
|
};
|
|
296
298
|
}
|
|
@@ -318,19 +320,25 @@ export function resolveStatus({ resultOk, exit, agent, result, runDir, events })
|
|
|
318
320
|
// Answers that never arrived, or arrived as coordinates. Scout-only, and per-question
|
|
319
321
|
// only when questions.json is absent — see scoutCoverageGap().
|
|
320
322
|
if (agent === 'codex-scout') {
|
|
323
|
+
const questions = questionsOf(runDir);
|
|
321
324
|
// The 2026-09-16 dead sandbox produced nonempty evidence without reading code. Require an
|
|
322
325
|
// execution fact first: checking whether evidence paths existed falsely rejected 64/151 runs.
|
|
323
326
|
// Only a run that left a stream can prove it: an archived run from before `--json` has none and
|
|
324
327
|
// is judged by the contract of its day; a `--json` run missing its stream already failed above.
|
|
325
|
-
|
|
328
|
+
// Plan_60 D1 exempts only orders whose every question asks about handed startup context.
|
|
329
|
+
if (eventData.hasStream && eventData.commands_executed === 0 && !allStartupContext(questions)) {
|
|
326
330
|
return {
|
|
327
331
|
status: 'FAIL',
|
|
328
332
|
reason: 'scout executed no command, so no answer rests on reading the code; '
|
|
329
333
|
+ 'check stderr.log for sandbox refusals before changing the order',
|
|
330
334
|
};
|
|
331
335
|
}
|
|
332
|
-
const gap = scoutCoverageGap(
|
|
336
|
+
const gap = scoutCoverageGap(questions, result);
|
|
333
337
|
if (gap) return { status: 'FAIL', reason: gap };
|
|
338
|
+
const contextGap = contextOnlyGap({
|
|
339
|
+
questions, result, commandsExecuted: eventData.hasStream ? eventData.commands_executed : null,
|
|
340
|
+
});
|
|
341
|
+
if (contextGap) return { status: 'FAIL', reason: contextGap };
|
|
334
342
|
}
|
|
335
343
|
// Plan_59 D3/D4/D5/D10: a schema-valid agreement still needs evidence and decision checks.
|
|
336
344
|
// Place the advisor judge before the build-only scope check, alongside the other agent contracts.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses model families by id structure, Plan_67 D5, without a model id literal in code:
|
|
3
|
+
* an id table would become stale with every model release and create false mismatch warnings.
|
|
4
|
+
* D5 (b) treats unparsed ids as no evidence, rather than as matches or violations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// These are host aliases, not model ids or family pins (Plan_67 D5).
|
|
8
|
+
export const NON_FAMILY_ALIASES = Object.freeze(['inherit', 'default', 'opusplan']);
|
|
9
|
+
|
|
10
|
+
const isAsciiLetters = (value) => value.length > 0 && !/[^A-Za-z]/.test(value);
|
|
11
|
+
|
|
12
|
+
export function modelFamily(value) {
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
const tokens = value.replace(/\[[^\[\]]*\]$/, '').split(/[-.@]/);
|
|
15
|
+
const claudeIndex = tokens.findIndex((token) => token.toLowerCase() === 'claude');
|
|
16
|
+
if (claudeIndex !== -1) {
|
|
17
|
+
const family = tokens.slice(claudeIndex + 1).find(isAsciiLetters);
|
|
18
|
+
return family ? family.toLowerCase() : null;
|
|
19
|
+
}
|
|
20
|
+
if (tokens.length !== 1 || !isAsciiLetters(tokens[0])) return null;
|
|
21
|
+
const family = tokens[0].toLowerCase();
|
|
22
|
+
return NON_FAMILY_ALIASES.includes(family) ? null : family;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function compareModelFamilies({ pinFamily, modelIds }) {
|
|
26
|
+
const families = new Set();
|
|
27
|
+
let unparsed = 0;
|
|
28
|
+
for (const id of modelIds) {
|
|
29
|
+
const family = modelFamily(id);
|
|
30
|
+
if (family === null) unparsed += 1;
|
|
31
|
+
else families.add(family);
|
|
32
|
+
}
|
|
33
|
+
const parsed = [...families];
|
|
34
|
+
const verdict = pinFamily === null || parsed.length === 0
|
|
35
|
+
? 'undetermined'
|
|
36
|
+
: parsed.some((family) => family !== pinFamily) ? 'violation' : 'match';
|
|
37
|
+
return { verdict, parsed, unparsed };
|
|
38
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/** Keeps, per key, the last observation apart from the last confirmed violation and the last confirmed match.
|
|
2
|
+
* Plan_67 D8 separates these facts to cure the eternal witness warning: an undetermined observation
|
|
3
|
+
* cannot clear a violation, while a later confirmed match can. Adapters own causes and severity.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
7
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
8
|
+
import { stateDirWriter } from './home-write.mjs';
|
|
9
|
+
import { parseJsonText } from './json-file.mjs';
|
|
10
|
+
|
|
11
|
+
export const HISTORY_LIMIT = 20;
|
|
12
|
+
const VERDICTS = new Set(['violation', 'match', 'undetermined']);
|
|
13
|
+
const has = (object, key) => Object.hasOwn(object, key);
|
|
14
|
+
const plainObject = (value) => value !== null && typeof value === 'object'
|
|
15
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
16
|
+
|
|
17
|
+
function fields(value, required, optional = []) {
|
|
18
|
+
return plainObject(value) && required.every((key) => has(value, key))
|
|
19
|
+
&& Reflect.ownKeys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jsonValue(value, ancestors = new Set()) {
|
|
23
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
|
|
24
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
25
|
+
if ((!Array.isArray(value) && !plainObject(value)) || ancestors.has(value)) return false;
|
|
26
|
+
ancestors.add(value);
|
|
27
|
+
const valid = Array.isArray(value)
|
|
28
|
+
? Array.from(value).every((item) => jsonValue(item, ancestors))
|
|
29
|
+
: Reflect.ownKeys(value).every((key) => typeof key === 'string' && jsonValue(value[key], ancestors));
|
|
30
|
+
ancestors.delete(value);
|
|
31
|
+
return valid;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isoTimestamp(value) {
|
|
35
|
+
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) return false;
|
|
36
|
+
const parts = /^(\d{4}|[+-]\d{6})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
37
|
+
if (!parts) return false;
|
|
38
|
+
const [, year, month, day, hour, minute, second] = parts.map(Number);
|
|
39
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
40
|
+
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
41
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1]
|
|
42
|
+
&& hour < 24 && minute < 60 && second < 60;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validObservation(value, maxSeq) {
|
|
46
|
+
return fields(value, ['seq', 'verdict', 'at', 'detail'], ['data'])
|
|
47
|
+
&& Number.isInteger(value.seq) && value.seq > 0 && value.seq <= maxSeq
|
|
48
|
+
&& VERDICTS.has(value.verdict) && isoTimestamp(value.at) && typeof value.detail === 'string'
|
|
49
|
+
&& (!has(value, 'data') || (plainObject(value.data) && jsonValue(value.data)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validEntry(entry, maxSeq) {
|
|
53
|
+
if (!fields(entry, ['lastObservation', 'lastViolation', 'lastMatch', 'history'])
|
|
54
|
+
|| !validObservation(entry.lastObservation, maxSeq)
|
|
55
|
+
|| !Array.isArray(entry.history) || entry.history.length > HISTORY_LIMIT) return false;
|
|
56
|
+
const lastSeq = entry.lastObservation.seq;
|
|
57
|
+
if (entry.lastViolation !== null && (!validObservation(entry.lastViolation, lastSeq)
|
|
58
|
+
|| entry.lastViolation.verdict !== 'violation')) return false;
|
|
59
|
+
if (entry.lastMatch !== null && (!validObservation(entry.lastMatch, lastSeq)
|
|
60
|
+
|| entry.lastMatch.verdict !== 'match')) return false;
|
|
61
|
+
let previous = 0;
|
|
62
|
+
for (const observation of entry.history) {
|
|
63
|
+
if (!validObservation(observation, lastSeq) || observation.verdict !== 'violation'
|
|
64
|
+
|| observation.seq <= previous) return false;
|
|
65
|
+
previous = observation.seq;
|
|
66
|
+
}
|
|
67
|
+
if (entry.lastViolation === null ? entry.history.length !== 0 : previous !== entry.lastViolation.seq) return false;
|
|
68
|
+
const slot = entry.lastObservation.verdict === 'violation' ? entry.lastViolation
|
|
69
|
+
: entry.lastObservation.verdict === 'match' ? entry.lastMatch : null;
|
|
70
|
+
return entry.lastObservation.verdict === 'undetermined' || slot?.seq === lastSeq;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function emptyLedger() {
|
|
74
|
+
return { format: 1, seq: 0, entries: {} };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeLedger(parsed) {
|
|
78
|
+
if (!fields(parsed, ['format', 'seq', 'entries']) || parsed.format !== 1
|
|
79
|
+
|| !Number.isInteger(parsed.seq) || parsed.seq < 0 || !plainObject(parsed.entries)) return null;
|
|
80
|
+
return Reflect.ownKeys(parsed.entries).every((key) => typeof key === 'string' && key.length > 0
|
|
81
|
+
&& validEntry(parsed.entries[key], parsed.seq)) ? parsed : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function reduceObservation(ledger, { key, verdict, at, detail, data }) {
|
|
85
|
+
if (!VERDICTS.has(verdict)) throw new TypeError('verdict must be violation, match or undetermined');
|
|
86
|
+
if (typeof key !== 'string' || key.length === 0) throw new TypeError('key must be a non-empty string');
|
|
87
|
+
if (!isoTimestamp(at)) throw new TypeError('at must be an ISO timestamp');
|
|
88
|
+
if (typeof detail !== 'string') throw new TypeError('detail must be a string');
|
|
89
|
+
if (data !== undefined && (!plainObject(data) || !jsonValue(data))) {
|
|
90
|
+
throw new TypeError('data must be a plain JSON object');
|
|
91
|
+
}
|
|
92
|
+
if (!normalizeLedger(ledger)) throw new TypeError('ledger must be a valid observation ledger');
|
|
93
|
+
const seq = ledger.seq + 1;
|
|
94
|
+
if (seq === ledger.seq) throw new TypeError('ledger sequence cannot increment');
|
|
95
|
+
const observation = { seq, verdict, at, detail };
|
|
96
|
+
if (data !== undefined) observation.data = structuredClone(data);
|
|
97
|
+
const previous = has(ledger.entries, key) ? ledger.entries[key]
|
|
98
|
+
: { lastObservation: null, lastViolation: null, lastMatch: null, history: [] };
|
|
99
|
+
const entry = { ...previous, lastObservation: observation };
|
|
100
|
+
if (verdict === 'violation') {
|
|
101
|
+
entry.lastViolation = observation;
|
|
102
|
+
entry.history = [...previous.history, observation].slice(-HISTORY_LIMIT);
|
|
103
|
+
} else if (verdict === 'match') {
|
|
104
|
+
entry.lastMatch = observation;
|
|
105
|
+
}
|
|
106
|
+
return { format: 1, seq, entries: { ...ledger.entries, [key]: entry } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function entryState(entry) {
|
|
110
|
+
if (!entry) return 'unobserved';
|
|
111
|
+
if (entry.lastViolation) {
|
|
112
|
+
return entry.lastMatch?.seq > entry.lastViolation.seq ? 'recovered' : 'violation';
|
|
113
|
+
}
|
|
114
|
+
return entry.lastMatch ? 'clean' : 'undetermined';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readLedgerFile(file, { normalize = normalizeLedger } = {}) {
|
|
118
|
+
let source;
|
|
119
|
+
try {
|
|
120
|
+
source = fs.readFileSync(file, 'utf8');
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return error.code === 'ENOENT' ? emptyLedger() : { corrupt: true };
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return normalize(parseJsonText(file, source)) ?? { corrupt: true };
|
|
126
|
+
} catch {
|
|
127
|
+
return { corrupt: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function updateLedgerFile({ stateDir, id, file, normalize, update }) {
|
|
132
|
+
const writer = stateDirWriter(stateDir);
|
|
133
|
+
// Plan_67 D8: allocation of seq and publication share the registered artifact's lock.
|
|
134
|
+
return withHomeFileLock(writer, id, `${file}.lock`, async () => {
|
|
135
|
+
const current = readLedgerFile(file, { normalize });
|
|
136
|
+
if (current.corrupt) throw new Error(`Cannot update corrupt observation ledger: ${file}`);
|
|
137
|
+
const next = update(current);
|
|
138
|
+
writeHomeJsonAtomic(writer, id, file, next);
|
|
139
|
+
return next;
|
|
140
|
+
}, { description: 'observation ledger' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function recordObservation({ stateDir, id, file, observation }) {
|
|
144
|
+
return updateLedgerFile({ stateDir, id, file, update: (current) => reduceObservation(current, observation) });
|
|
145
|
+
}
|