@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +132 -0
  2. package/README.md +15 -4
  3. package/cli/dispatcher-model-check.mjs +59 -0
  4. package/cli/doctor-host-side.mjs +2 -1
  5. package/cli/doctor-installation.mjs +111 -111
  6. package/cli/doctor.mjs +13 -5
  7. package/cli/handback-witness-check.mjs +24 -18
  8. package/cli/hosts.mjs +2 -10
  9. package/cli/install-owners.mjs +1 -4
  10. package/cli/install-record.mjs +2 -1
  11. package/cli/inventory-transition.mjs +1 -1
  12. package/cli/lifecycle-lock-inspect.mjs +3 -2
  13. package/cli/lifecycle-lock.mjs +19 -132
  14. package/cli/manifest.mjs +6 -6
  15. package/cli/read.mjs +20 -0
  16. package/cli/settings-merge.mjs +3 -29
  17. package/package.json +2 -2
  18. package/scripts/home-write-inventory.mjs +12 -2
  19. package/src/agents/codex-advisor.md +37 -121
  20. package/src/agents/codex-build.md +27 -135
  21. package/src/agents/codex-review.md +19 -111
  22. package/src/agents/codex-scout.md +21 -130
  23. package/src/home/hooks/dispatcher-gate.mjs +14 -9
  24. package/src/home/hooks/live-runs.mjs +0 -41
  25. package/src/home/hooks/order-gate.mjs +80 -47
  26. package/src/home/hooks/reply-guard.mjs +52 -65
  27. package/src/home/hooks/reply-identity.mjs +64 -0
  28. package/src/home/hooks/reply-parser.mjs +1 -1
  29. package/src/home/hooks/reply-verdicts.mjs +31 -27
  30. package/src/home/hooks/reply-witness.mjs +48 -0
  31. package/src/home/lib/claude-layout.mjs +14 -0
  32. package/src/home/lib/command-path.mjs +32 -0
  33. package/src/home/lib/dispatcher-call.mjs +171 -0
  34. package/src/home/lib/dispatcher-command.mjs +18 -54
  35. package/src/home/lib/dispatcher-gate.mjs +4 -0
  36. package/src/home/lib/dispatcher-model.mjs +112 -0
  37. package/src/home/lib/dispatcher-order.mjs +21 -22
  38. package/src/home/lib/dispatcher-pin.mjs +60 -0
  39. package/src/home/lib/dispatcher-protocol.mjs +91 -0
  40. package/src/home/lib/dispatcher-stop.mjs +11 -2
  41. package/src/home/lib/handback-witness.mjs +137 -48
  42. package/src/home/lib/home-registry.mjs +7 -0
  43. package/src/home/lib/install-owner-roots.mjs +53 -0
  44. package/src/home/lib/kernel-lock.mjs +171 -0
  45. package/src/home/lib/meta/chain.mjs +19 -8
  46. package/src/home/lib/meta/context-only.mjs +43 -0
  47. package/src/home/lib/meta/events.mjs +14 -2
  48. package/src/home/lib/meta/launch-rows.mjs +23 -0
  49. package/src/home/lib/meta/paths.mjs +1 -1
  50. package/src/home/lib/meta/reply.mjs +39 -4
  51. package/src/home/lib/meta/run-state.mjs +3 -0
  52. package/src/home/lib/meta/verdict.mjs +16 -8
  53. package/src/home/lib/model-family.mjs +38 -0
  54. package/src/home/lib/observation-ledger.mjs +145 -0
  55. package/src/home/lib/order-schema.mjs +204 -0
  56. package/src/home/lib/order-values.mjs +46 -0
  57. package/src/home/lib/run-codex.mjs +6 -3
  58. package/src/home/lib/runner/advise-carry.mjs +2 -1
  59. package/src/home/lib/runner/args.mjs +140 -220
  60. package/src/home/lib/runner/attach.mjs +24 -17
  61. package/src/home/lib/runner/build-evidence.mjs +29 -0
  62. package/src/home/lib/runner/codex-availability.mjs +50 -0
  63. package/src/home/lib/runner/codex-cmd.mjs +2 -19
  64. package/src/home/lib/runner/continuation.mjs +174 -140
  65. package/src/home/lib/runner/exit-codes.mjs +13 -0
  66. package/src/home/lib/runner/flag-baseline.mjs +105 -0
  67. package/src/home/lib/runner/flag-scan.mjs +165 -0
  68. package/src/home/lib/runner/git-state.mjs +0 -33
  69. package/src/home/lib/runner/launcher.mjs +237 -277
  70. package/src/home/lib/runner/order-claim.mjs +84 -0
  71. package/src/home/lib/runner/order-options.mjs +30 -0
  72. package/src/home/lib/runner/order-owner.mjs +19 -0
  73. package/src/home/lib/runner/pass-gate.mjs +111 -0
  74. package/src/home/lib/runner/preflight.mjs +26 -28
  75. package/src/home/lib/runner/prompts.mjs +7 -2
  76. package/src/home/lib/runner/question-kind.mjs +41 -0
  77. package/src/home/lib/runner/run-env.mjs +2 -1
  78. package/src/home/lib/runner/sandbox-probe.mjs +1 -1
  79. package/src/home/lib/runner/scope-check.mjs +31 -12
  80. package/src/home/lib/runner/task-input.mjs +34 -12
  81. package/src/home/lib/runner/worker-admission.mjs +43 -0
  82. package/src/home/lib/runner/worker.mjs +19 -18
  83. package/src/home/lib/subagent-model-env.mjs +44 -0
  84. package/src/home/lib/task-header.mjs +158 -0
  85. package/src/home/lib/untyped-stop.mjs +30 -0
  86. package/src/home/lib/write-meta.mjs +17 -5
  87. package/src/home/lib/required-inputs.mjs +0 -261
  88. /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) || errors[0] || null;
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 --scope pattern as a regular expression. Deliberately a small glob dialect and not a
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. 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.
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
- ? 'OK — scope: sufficient; continue this run with --phase advise'
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.filter((q) => q && q.id) : [];
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(runDir, result) {
179
- const questions = questionsOf(runDir);
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
- if (eventData.hasStream && eventData.commands_executed === 0) {
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(runDir, result);
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
+ }