@lyupro/codex-bridge 0.6.6 → 0.6.7

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 (49) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +5 -5
  3. package/bin/codex-bridge.mjs +181 -178
  4. package/cli/dispatcher-contract-record.mjs +42 -0
  5. package/cli/dispatcher-contract.mjs +89 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-runs.mjs +55 -55
  8. package/cli/doctor.mjs +39 -3
  9. package/cli/handback-witness-check.mjs +39 -0
  10. package/cli/hook-targets.mjs +14 -6
  11. package/cli/hook.mjs +32 -23
  12. package/cli/host-contract.mjs +136 -135
  13. package/cli/install.mjs +9 -14
  14. package/cli/launcher-probe.mjs +38 -0
  15. package/cli/probe-contract.mjs +31 -62
  16. package/cli/probe-rig.mjs +53 -0
  17. package/cli/run-launcher.mjs +17 -0
  18. package/cli/settings-merge.mjs +42 -35
  19. package/cli/uninstall.mjs +15 -3
  20. package/cli/update.mjs +45 -16
  21. package/package.json +4 -4
  22. package/src/agents/codex-advisor.md +3 -4
  23. package/src/agents/codex-build.md +5 -5
  24. package/src/agents/codex-review.md +3 -3
  25. package/src/agents/codex-scout.md +3 -4
  26. package/src/home/hooks/dispatcher-gate.mjs +119 -0
  27. package/src/home/hooks/guard-tries.mjs +5 -6
  28. package/src/home/hooks/live-runs.mjs +1 -0
  29. package/src/home/hooks/order-gate.mjs +20 -5
  30. package/src/home/hooks/reply-guard.mjs +75 -10
  31. package/src/home/hooks/reply-verdicts.mjs +5 -0
  32. package/src/home/hooks/worktree-lock.mjs +108 -107
  33. package/src/home/lib/atomic-json.mjs +21 -0
  34. package/src/home/lib/brand-home.mjs +6 -0
  35. package/src/home/lib/dispatcher-command.mjs +79 -0
  36. package/src/home/lib/dispatcher-gate.mjs +95 -0
  37. package/src/home/lib/dispatcher-order.mjs +30 -8
  38. package/src/home/lib/dispatcher-state.mjs +111 -0
  39. package/src/home/lib/dispatcher-stop.mjs +52 -0
  40. package/src/home/lib/handback-witness.mjs +65 -0
  41. package/src/home/lib/hook-definitions.mjs +10 -0
  42. package/src/home/lib/hook-entry.mjs +20 -0
  43. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  44. package/src/home/lib/required-inputs.mjs +1 -1
  45. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  46. package/src/home/lib/runner/git-state.mjs +1 -1
  47. package/src/home/lib/runner/preflight.mjs +6 -3
  48. package/src/home/lib/runner/prompts.mjs +8 -7
  49. package/src/home/lib/runner/schemas.mjs +33 -7
@@ -1,25 +1,47 @@
1
1
  /** Reads and compares the order identity carried between dispatcher hook boundaries. */
2
2
  import fs from 'node:fs';
3
+ import path from 'node:path';
3
4
  import { parseJsonText } from './json-file.mjs';
4
5
  import { extractValue } from './required-inputs.mjs';
5
6
 
6
- /** The transcript is diagnostic evidence; malformed or unavailable evidence must fail open. */
7
- export function transcriptOrderId(transcriptPath) {
7
+ /** Implements Plan_62 D7: keeps untrusted ids from redirecting the host-owned subagent transcript path. */
8
+ export function ownTranscriptPath(payload) {
9
+ if (!payload || typeof payload !== 'object') return null;
10
+ const { transcript_path, session_id, agent_id } = payload;
11
+ const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0;
12
+ const unsafeSegment = (value) => value.includes('/') || value.includes('\\') || value.includes('..');
13
+ if (!nonEmpty(transcript_path) || !nonEmpty(session_id) || !nonEmpty(agent_id)) return null;
14
+ if (unsafeSegment(session_id) || unsafeSegment(agent_id)) return null;
15
+ return path.join(path.dirname(transcript_path), session_id, 'subagents', `agent-${agent_id}.jsonl`);
16
+ }
17
+
18
+ function firstEntry(transcriptPath) {
8
19
  try {
9
- const firstEntry = fs.readFileSync(transcriptPath, 'utf8').split(/\r?\n/, 1)[0];
10
- const transcriptEntry = parseJsonText(transcriptPath, firstEntry);
11
- const content = transcriptEntry?.message?.content;
12
- const promptText = typeof content === 'string'
20
+ const line = fs.readFileSync(transcriptPath, 'utf8').split(/\r?\n/, 1)[0];
21
+ const entry = parseJsonText(transcriptPath, line);
22
+ const content = entry?.message?.content;
23
+ const text = typeof content === 'string'
13
24
  ? content
14
25
  : Array.isArray(content)
15
26
  ? content.filter((part) => part?.type === 'text').map((part) => part.text).join('\n')
16
27
  : '';
17
- return String(extractValue(promptText, 'order id') || '').trim();
28
+ return { type: entry?.type, text };
18
29
  } catch {
19
- return '';
30
+ return null;
20
31
  }
21
32
  }
22
33
 
34
+ /** Reads the first user message; missing or malformed host transcript evidence fails closed. */
35
+ export function transcriptPrompt(transcriptPath) {
36
+ const entry = firstEntry(transcriptPath);
37
+ return entry?.type === 'user' ? entry.text : null;
38
+ }
39
+
40
+ /** The reply guard's transcript is diagnostic evidence, so it keeps the fail-open, type-agnostic read. */
41
+ export function transcriptOrderId(transcriptPath) {
42
+ return String(extractValue(firstEntry(transcriptPath)?.text ?? '', 'order id') || '').trim();
43
+ }
44
+
23
45
  /** On 2026-08-15 another order's saved reply was returned as the current run's verdict. */
24
46
  export function runOrderMismatch(orderedOrderId, runStatus, runDir) {
25
47
  const ordered = String(orderedOrderId ?? '').trim();
@@ -0,0 +1,111 @@
1
+ /** Keeps dispatcher handback evidence under the package state home without trusting hook ids as paths. */
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { writeJsonAtomic } from './atomic-json.mjs';
6
+ import { parseJsonText } from './json-file.mjs';
7
+ import { withFileLock } from './file-lock.mjs';
8
+
9
+ function assertIdentity(sessionId, agentId) {
10
+ if (typeof sessionId !== 'string' || sessionId.length === 0
11
+ || typeof agentId !== 'string' || agentId.length === 0) {
12
+ throw new TypeError('sessionId and agentId must be non-empty strings');
13
+ }
14
+ }
15
+
16
+ export function dispatcherStatePath({ stateDir, sessionId, agentId }) {
17
+ assertIdentity(sessionId, agentId);
18
+ const digest = createHash('sha256').update(`${sessionId}\n${agentId}`).digest('hex').slice(0, 32);
19
+ return path.join(stateDir, 'dispatchers', `${digest}.json`);
20
+ }
21
+
22
+ export function readDispatcherState(ids) {
23
+ const file = dispatcherStatePath(ids);
24
+ let source;
25
+ try {
26
+ source = fs.readFileSync(file, 'utf8');
27
+ } catch (error) {
28
+ if (error.code === 'ENOENT') return null;
29
+ throw error;
30
+ }
31
+
32
+ let record;
33
+ try {
34
+ record = parseJsonText(file, source);
35
+ } catch {
36
+ return { corrupt: true };
37
+ }
38
+ if (!record || typeof record !== 'object' || Array.isArray(record)
39
+ || record.sessionId !== ids.sessionId || record.agentId !== ids.agentId) {
40
+ return { corrupt: true };
41
+ }
42
+ return record;
43
+ }
44
+
45
+ function timestamp(value) {
46
+ return new Date(value).toISOString();
47
+ }
48
+
49
+ function clockValue(ids) {
50
+ return typeof ids.now === 'function' ? ids.now() : (ids.now ?? Date.now());
51
+ }
52
+
53
+
54
+ export async function updateDispatcherState(ids, mutate) {
55
+ if (typeof mutate !== 'function') throw new TypeError('mutate must be a function');
56
+ const file = dispatcherStatePath(ids);
57
+ const { sessionId, agentId } = ids;
58
+ return withFileLock(`${file}.lock`, async () => {
59
+ let current = readDispatcherState(ids);
60
+ if (current === null) {
61
+ current = { sessionId, agentId, createdAt: timestamp(clockValue(ids)) };
62
+ }
63
+ const result = await mutate(current);
64
+ const candidate = result === undefined ? current : result;
65
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
66
+ throw new TypeError('mutate must produce a JSON record object');
67
+ }
68
+ const record = {
69
+ ...candidate,
70
+ sessionId,
71
+ agentId,
72
+ updatedAt: timestamp(clockValue(ids)),
73
+ };
74
+ writeJsonAtomic(file, record);
75
+ return record;
76
+ }, { description: 'dispatcher state' });
77
+ }
78
+
79
+ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 * 1000, now = Date.now() }) {
80
+ const directory = path.join(stateDir, 'dispatchers');
81
+ let entries;
82
+ try {
83
+ entries = fs.readdirSync(directory, { withFileTypes: true });
84
+ } catch (error) {
85
+ if (error.code === 'ENOENT') return 0;
86
+ throw error;
87
+ }
88
+
89
+ const cutoff = now - olderThanMs;
90
+ let removed = 0;
91
+ for (const entry of entries) {
92
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
93
+ const file = path.join(directory, entry.name);
94
+ let old;
95
+ try {
96
+ old = fs.statSync(file).mtimeMs < cutoff;
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') continue;
99
+ throw error;
100
+ }
101
+ if (!old) continue;
102
+ try {
103
+ fs.unlinkSync(file);
104
+ removed += 1;
105
+ } catch (error) {
106
+ if (error.code !== 'ENOENT') throw error;
107
+ }
108
+ fs.rmSync(`${file}.lock`, { force: true });
109
+ }
110
+ return removed;
111
+ }
@@ -0,0 +1,52 @@
1
+ /** Audits dispatcher tool calls because SubagentHandback delivers the answer before SubagentStop can block it. */
2
+ import fs from 'node:fs';
3
+ import { parseJsonText } from './json-file.mjs';
4
+ import { HANDBACK_TOOL } from './hook-definitions.mjs';
5
+
6
+ export function transcriptToolUses(transcriptPath) {
7
+ let source;
8
+ try {
9
+ source = fs.readFileSync(transcriptPath, 'utf8');
10
+ } catch {
11
+ return null;
12
+ }
13
+
14
+ const toolUses = [];
15
+ for (const line of source.split(/\r?\n/)) {
16
+ let record;
17
+ try {
18
+ record = parseJsonText(transcriptPath, line);
19
+ } catch {
20
+ continue;
21
+ }
22
+ if (record?.type !== 'assistant' && record?.message?.role !== 'assistant') continue;
23
+ const content = record.message?.content;
24
+ if (!Array.isArray(content)) continue;
25
+ for (const block of content) {
26
+ if (block?.type === 'tool_use') toolUses.push({ id: block.id, name: block.name });
27
+ }
28
+ }
29
+ return toolUses;
30
+ }
31
+
32
+ export function decideDispatcherStop({ state, toolUses }) {
33
+ const seenToolUseIds = !state?.corrupt && Array.isArray(state?.seenToolUseIds)
34
+ ? state.seenToolUseIds
35
+ : [];
36
+ const unseen = toolUses === null || (!state?.corrupt && state?.auditAlarmed)
37
+ ? []
38
+ : toolUses.filter((toolUse) => !seenToolUseIds.includes(toolUse.id));
39
+ const handbackAttempted = (state?.handbackAttempts ?? 0) >= 1
40
+ || toolUses?.some((toolUse) => toolUse.name === HANDBACK_TOOL) === true;
41
+
42
+ let route;
43
+ if (state?.handback === 'delivered') route = 'yield';
44
+ else if (handbackAttempted && state?.stopDemanded !== true) route = 'demand';
45
+ else if (handbackAttempted) route = 'yield';
46
+ else route = 'legacy';
47
+
48
+ const stateUpdate = {};
49
+ if (route === 'demand') stateUpdate.stopDemanded = true;
50
+ if (unseen.length) stateUpdate.auditAlarmed = true;
51
+ return { route, unseen, stateUpdate: Object.keys(stateUpdate).length ? stateUpdate : null };
52
+ }
@@ -0,0 +1,65 @@
1
+ /** Records the 2026-09-17..24 silent SubagentHandback contract break before it can go unnoticed again. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { writeJsonAtomic } from './atomic-json.mjs';
5
+ import { parseJsonText } from './json-file.mjs';
6
+ import { withFileLock } from './file-lock.mjs';
7
+
8
+ export const WITNESS_FILE = 'handback-witness.json';
9
+
10
+ export function hostSdkVersion(env = process.env) {
11
+ const version = env.CLAUDE_AGENT_SDK_VERSION;
12
+ if (typeof version !== 'string') return null;
13
+ return version.trim() || null;
14
+ }
15
+
16
+ function isWitness(record) {
17
+ const witnessEntry = (entry, alarm = false) => entry && typeof entry === 'object'
18
+ && !Array.isArray(entry)
19
+ && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string')
20
+ && typeof entry.at === 'string'
21
+ && (!alarm || typeof entry.detail === 'string');
22
+ return record && typeof record === 'object' && !Array.isArray(record)
23
+ && (record.lastSeen === null || witnessEntry(record.lastSeen))
24
+ && Array.isArray(record.alarms) && record.alarms.every((entry) => witnessEntry(entry, true));
25
+ }
26
+
27
+ export function readHandbackWitness({ stateDir }) {
28
+ const file = path.join(stateDir, WITNESS_FILE);
29
+ let source;
30
+ try {
31
+ source = fs.readFileSync(file, 'utf8');
32
+ } catch (error) {
33
+ if (error.code === 'ENOENT') return { lastSeen: null, alarms: [] };
34
+ return { corrupt: true };
35
+ }
36
+ try {
37
+ const record = parseJsonText(file, source);
38
+ return isWitness(record) ? record : { corrupt: true };
39
+ } catch {
40
+ return { corrupt: true };
41
+ }
42
+ }
43
+
44
+ function timestamp(now) {
45
+ return new Date(now).toISOString();
46
+ }
47
+
48
+
49
+ export async function recordHandbackWitness({ stateDir, kind, sdkVersion, detail = '', now = new Date() }) {
50
+ if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
51
+ if (sdkVersion !== null && typeof sdkVersion !== 'string') {
52
+ throw new TypeError('sdkVersion must be a string or null');
53
+ }
54
+ const file = path.join(stateDir, WITNESS_FILE);
55
+ return withFileLock(`${file}.lock`, async () => {
56
+ const current = readHandbackWitness({ stateDir });
57
+ if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
58
+ const at = timestamp(now);
59
+ const record = kind === 'seen'
60
+ ? { ...current, lastSeen: { sdkVersion, at } }
61
+ : { ...current, alarms: [...current.alarms, { sdkVersion, at, detail }].slice(-20) };
62
+ writeJsonAtomic(file, record);
63
+ return record;
64
+ }, { description: 'handback witness' });
65
+ }
@@ -39,6 +39,13 @@ export const SHELL_TOOLS = Object.freeze(['Bash', 'PowerShell']);
39
39
  /** A PreToolUse matcher is a regular expression over tool names, so alternation covers both. */
40
40
  export const SHELL_TOOL_MATCHER = SHELL_TOOLS.join('|');
41
41
 
42
+ // Probed live on 2026-09-24; this is the one place the host handback tool name is spelled.
43
+ export const HANDBACK_TOOL = 'SubagentHandback';
44
+
45
+ // Plan_62 D15: a dispatcher has only its shell whitelist and handback tool; `*` would tax every session.
46
+ export const DISPATCHER_TOOLS = Object.freeze([...SHELL_TOOLS, HANDBACK_TOOL]);
47
+ export const DISPATCHER_TOOL_MATCHER = DISPATCHER_TOOLS.join('|');
48
+
42
49
  // Plan_31 routes host stop requests through the package guard; keep the matcher derived from
43
50
  // the tool list so registration and uninstall cannot drift from the host event spelling.
44
51
  export const STOP_TOOLS = Object.freeze(['TaskStop']);
@@ -66,4 +73,7 @@ export const HOOK_DEFINITIONS = Object.freeze([
66
73
  Object.freeze({ name: 'prune-guard', event: 'PreToolUse', matcher: SHELL_TOOL_MATCHER, file: 'prune-guard.mjs' }),
67
74
  Object.freeze({ name: 'worktree-witness', event: 'PostToolUse', matcher: SHELL_TOOL_MATCHER, file: 'worktree-witness.mjs' }),
68
75
  Object.freeze({ name: 'stop-guard', event: 'PreToolUse', matcher: STOP_TOOL_MATCHER, file: 'stop-guard.mjs' }),
76
+ Object.freeze({ name: 'dispatcher-gate', event: 'PreToolUse', matcher: DISPATCHER_TOOL_MATCHER, file: 'dispatcher-gate.mjs' }),
77
+ Object.freeze({ name: 'dispatcher-capture', event: 'PostToolUse', matcher: SHELL_TOOL_MATCHER, file: 'dispatcher-gate.mjs' }),
78
+ Object.freeze({ name: 'dispatcher-capture-failure', event: 'PostToolUseFailure', matcher: SHELL_TOOL_MATCHER, file: 'dispatcher-gate.mjs' }),
69
79
  ]);
@@ -0,0 +1,20 @@
1
+ /** Runs the installed home copy so the registering package cannot drift from its live guards. */
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { HOOK_DEFINITIONS } from './hook-definitions.mjs';
5
+
6
+ export async function runHook(name, io = console) {
7
+ const definition = HOOK_DEFINITIONS.find(({ name: candidate }) => candidate === name);
8
+ try {
9
+ if (!definition) throw new Error(`unknown hook name "${name}"`);
10
+ // Plan_25 replaces absolute paths in foreign settings.json with this command. Importing the
11
+ // proven top-level script keeps its stdin reader and process.exit() contract intact; exporting
12
+ // guard functions would be a second implementation of the live hook boundary.
13
+ const hooksDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
14
+ await import(pathToFileURL(path.join(hooksDir, definition.file)).href);
15
+ return typeof process.exitCode === 'number' ? process.exitCode : 0;
16
+ } catch (error) {
17
+ io.error(`codex-bridge hook: guard "${name}" did not run: ${error?.message ?? String(error)}. Run codex-bridge doctor.`);
18
+ return 1;
19
+ }
20
+ }
@@ -76,10 +76,9 @@ function* addressesIn(value, root) {
76
76
  }
77
77
  }
78
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]);
79
+ function citationProblem(file, start, end, root, allowed) {
80
+ if (typeof file !== 'string' || !file) return 'use a repository-relative path:line or path:line-line address';
81
+ const entry = entryInRepo(root, file);
83
82
  if (!entry?.isFile) return 'cite an existing file under repoRoot';
84
83
  let count;
85
84
  try {
@@ -87,8 +86,6 @@ function citationProblem(raw, root, allowed) {
87
86
  } catch {
88
87
  return 'cite a readable file under repoRoot';
89
88
  }
90
- const start = Number(match[2]);
91
- const end = match[3] === undefined ? start : Number(match[3]);
92
89
  if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || end > count) {
93
90
  return `use an ascending line range within this file's ${count} lines`;
94
91
  }
@@ -103,40 +100,59 @@ function checkCitations({ phase, result, task, repoRoot, scopeResult }, reasons)
103
100
  const root = path.resolve(repoRoot);
104
101
  const allowed = [...task.paths, ...(phase === 'scope' ? result.missing_paths : scopeResult?.missing_paths ?? [])]
105
102
  .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}.`);
103
+ const locationLabel = ({ file, line_start, line_end }) =>
104
+ `${file}:${line_start}${line_end === line_start ? '' : `-${line_end}`}`;
105
+ const checkLocation = (field, location) => {
106
+ const { file, line_start: start, line_end: end } = location;
107
+ const label = locationLabel(location);
108
+ const problem = citationProblem(file, start, end, root, allowed);
109
+ if (problem) reasons.push(`D3 ${field} ${JSON.stringify(label)}: ${problem}.`);
109
110
  };
110
111
  const checkAddress = (field, raw) => {
111
- const trimmed = raw.trim();
112
- check(field, /^`.*`$/.test(trimmed) ? trimmed.slice(1, -1) : trimmed);
112
+ const match = raw.match(/^(.+):(\d+)(?:-(\d+))?$/);
113
+ if (!match) {
114
+ reasons.push(`D3 ${field} ${JSON.stringify(raw)}: use a repository-relative path:line or path:line-line address.`);
115
+ return;
116
+ }
117
+ const start = Number(match[2]);
118
+ checkLocation(field, {
119
+ file: match[1],
120
+ line_start: start,
121
+ line_end: match[3] === undefined ? start : Number(match[3]),
122
+ });
113
123
  };
114
124
  // D3 covers every address anywhere in the answer. A list of prose fields had to be edited by
115
125
  // 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.
126
+ // Structured evidence and the free-text early_check.target are handled separately below.
117
127
  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);
128
+ if (/(?:^|\.)evidence(?:\[|\.|$)|\.early_check\.target$/.test(field)) continue;
129
+ for (const address of addressesIn(value, root)) checkAddress(field, address);
120
130
  }
131
+ const checkEvidence = (field, evidence) => {
132
+ for (const [index, location] of evidence.entries()) checkLocation(`${field}[${index}]`, location);
133
+ };
121
134
  if (result.independent_checks) {
122
135
  for (const [index, item] of result.independent_checks.entries()) {
123
- checkAddress(`independent_checks[${index}].address`, item.address);
136
+ checkEvidence(`independent_checks[${index}].evidence`, item.evidence);
124
137
  }
125
138
  }
126
139
  if (phase === 'advise') {
127
140
  for (const [index, item] of result.assumptions.entries()) {
128
- if (item.rating === 'VERIFIED' || item.address !== '') {
129
- checkAddress(`assumptions[${index}].address`, item.address);
141
+ if (item.rating === 'VERIFIED' && item.evidence.length === 0) {
142
+ reasons.push(`D10 assumptions[${index}].evidence []: a VERIFIED assumption needs at least one location.`);
130
143
  }
144
+ checkEvidence(`assumptions[${index}].evidence`, item.evidence);
131
145
  }
132
146
  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);
147
+ const { early_check: earlyCheck } = item;
148
+ if (earlyCheck.kind === 'inspect' && earlyCheck.evidence.length === 0) {
149
+ reasons.push(`D10 pre_mortem[${index}].early_check.evidence []: an inspect check needs at least one location.`);
135
150
  }
151
+ checkEvidence(`pre_mortem[${index}].early_check.evidence`, earlyCheck.evidence);
136
152
  }
137
- // An outcome's address is evidence whether or not the phase-1 answer is at hand.
153
+ // A risk outcome's locations are evidence whether or not the phase-1 answer is at hand.
138
154
  for (const [index, item] of result.risk_outcomes.entries()) {
139
- checkAddress(`risk_outcomes[${index}].address`, item.address);
155
+ checkEvidence(`risk_outcomes[${index}].evidence`, item.evidence);
140
156
  }
141
157
  }
142
158
  }
@@ -119,7 +119,7 @@ export function isPlaceholder(value) {
119
119
  }
120
120
 
121
121
  // Plan_59: scope is a real phase value, while it remains a placeholder for existing inputs.
122
- const isInputPlaceholder = (value, label) =>
122
+ export const isInputPlaceholder = (value, label) =>
123
123
  !(label === 'phase' && cleanValue(value) === 'scope') && isPlaceholder(value);
124
124
 
125
125
  export function extractValue(promptText, label) {
@@ -77,7 +77,7 @@ export function spawnCaptured(command, args, options) {
77
77
  throw new TypeError('spawnCaptured requires a finite timeout greater than zero');
78
78
  }
79
79
  return new Promise((resolve) => {
80
- const child = spawn(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'] });
80
+ const child = spawn(command, args, { ...rest, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
81
81
  let stdout = Buffer.alloc(0);
82
82
  let stderr = Buffer.alloc(0);
83
83
  let captureError = null;
@@ -232,6 +232,7 @@ export function runCodex(args, taskText, eventsPath, budgetMinutes, graceMs = ST
232
232
  const child = spawn(spec.command, spec.args, {
233
233
  ...spec.options,
234
234
  stdio: ['pipe', 'pipe', 'pipe'],
235
+ windowsHide: true,
235
236
  });
236
237
 
237
238
  return new Promise((resolve) => {
@@ -345,7 +346,7 @@ export function codexUnavailableReason() {
345
346
  const probe = spawnSync(
346
347
  process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : 'codex',
347
348
  process.platform === 'win32' ? ['/d', '/s', '/c', 'codex --version'] : ['--version'],
348
- { encoding: 'utf8' },
349
+ { encoding: 'utf8', windowsHide: true },
349
350
  );
350
351
  if (probe.error || probe.status !== 0) {
351
352
  return (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
@@ -15,7 +15,7 @@ import { runsRoot } from './runs-root.mjs';
15
15
  export const MAX_LOG = 256 * 1024 * 1024;
16
16
 
17
17
  export const git = (repo, args) =>
18
- spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: MAX_LOG });
18
+ spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: MAX_LOG, windowsHide: true });
19
19
 
20
20
  /**
21
21
  * Prefix of the run folders as seen from inside the repository, or null when they live
@@ -33,8 +33,10 @@ export function taskPreflight({ agent, taskText }) {
33
33
  }
34
34
  if (path.isAbsolute(advice)) {
35
35
  try {
36
- if (fs.statSync(advice).isDirectory() &&
37
- readJson(path.join(advice, 'meta.json'))?.agent === 'codex-advisor') {
36
+ // Plan_59 D26: only a judged advice authorizes a construction. On 2026-09-24 the gate took
37
+ // any codex-advisor folder, so a scope run or a FAIL advice would have passed as a second opinion.
38
+ const meta = fs.statSync(advice).isDirectory() ? readJson(path.join(advice, 'meta.json')) : null;
39
+ if (meta?.agent === 'codex-advisor' && meta.phase === 'advise' && meta.status === 'OK') {
38
40
  return { refusal: null, advice };
39
41
  }
40
42
  } catch {
@@ -43,7 +45,8 @@ export function taskPreflight({ agent, taskText }) {
43
45
  }
44
46
  }
45
47
  return freeRefusal('codex-build requires exactly one advice: line: mechanical | revert | docs-only | test-only ' +
46
- 'or an absolute path to an existing advisor run directory with meta.json agent codex-advisor. ' +
48
+ 'or an absolute path to an existing advisor run directory whose meta.json says agent codex-advisor, ' +
49
+ 'phase advise, status OK. ' +
47
50
  'An order that invents a construction needs a second opinion first; only work with no design choice may skip it.');
48
51
  }
49
52
 
@@ -18,8 +18,9 @@ const BODIES = {
18
18
  Rules for both phases:
19
19
  - Read ONLY the paths listed under \`## Paths\` and, in the advise phase, under \`## Scope phase results\`, plus \`git log\` / \`git show\` on those paths.
20
20
  - Write nothing. Do your own reading and checks; do not spawn or delegate to other agents.
21
- - Every claim about code must carry a \`path:line\` address inside those paths. Addresses are
22
- machine-checked for existence; do not invent addresses or treat an unchecked claim as evidence.
21
+ - Prose fields cite code as \`path:line\`; every \`evidence\` field is a list of
22
+ \`{file, line_start, line_end}\` objects, ONE location per object. Several places mean several
23
+ objects, never a list inside \`file\`. Locations are machine-checked for existence.
23
24
  - Do not guess which option the caller prefers: the task carries no preference on purpose.
24
25
  - Two failure modes are forbidden: agreeing without independent checks ("both options are good",
25
26
  "you know better"), and disagreeing to look independent. A disagreement without an address is
@@ -32,18 +33,18 @@ Scope phase:
32
33
  (facts accepted from the task without checking, never empty), together with predicted_risks.
33
34
  ` : `
34
35
  Advise phase:
35
- - Settle every predicted risk listed under \`## Scope phase results\` in risk_outcomes: its risk_id, an outcome of
36
- confirmed or refuted, and an address supporting that outcome.
36
+ - Settle every predicted risk listed under \`## Scope phase results\` in risk_outcomes with its
37
+ risk_id, an outcome of confirmed or refuted, a \`note\` explaining it, and evidence.
37
38
  - Give ONE recommendation by task option_id, or none-of-these with a described unlisted_option.
38
39
  Put every rejected task option in rejected with its cost; for none-of-these this means EVERY
39
40
  task option. Set unlisted_option to "none" when recommending a listed option.
40
41
  - Each field is a distinct lens: strongest_counterargument comes from a skeptic;
41
42
  pre_mortem comes from the executor who has only this advice and the repository. Give each
42
- scenario an early_check of kind test | command | inspect with a target; inspect requires an address.
43
+ scenario an early_check of kind test | command | inspect with a target; inspect requires evidence.
43
44
  - question_defect comes from the requester who needs the problem solved, not an option picked.
44
- - Rate assumptions VERIFIED (requires an address) | REASONABLE | FRAGILE. Anything the requester
45
+ - Rate assumptions VERIFIED (requires evidence) | REASONABLE | FRAGILE. Anything the requester
45
46
  could refute with knowledge you lack belongs in open_questions, not why.
46
- - independent_checks lists what you verified yourself, with addresses. Ground why in those checks
47
+ - independent_checks lists what you verified yourself, with evidence. Ground why in those checks
47
48
  and state confidence honestly.
48
49
  `}`;
49
50
  },
@@ -150,6 +150,30 @@ const REVIEW_SCHEMA = {
150
150
  },
151
151
  };
152
152
 
153
+ // The 2026-09-24_094352_plan62-g1-dispatcher-gate run folded 3-5 citations into one advisor
154
+ // address; like the 2026-07-31 build changes[].file precedent, constrain a single location here.
155
+ export const EVIDENCE_LOCATION = {
156
+ type: 'object',
157
+ additionalProperties: false,
158
+ required: ['file', 'line_start', 'line_end'],
159
+ properties: {
160
+ file: {
161
+ type: 'string',
162
+ minLength: 1,
163
+ pattern: '^[^:;,*?{}\\r\\n]+$',
164
+ description: 'Exactly one repository-root-relative path. Spaces are allowed; lists, globs, drive letters and line suffixes are not — use a separate location object for each place.',
165
+ },
166
+ line_start: { type: 'integer', minimum: 1 },
167
+ line_end: { type: 'integer', minimum: 1 },
168
+ },
169
+ };
170
+
171
+ const EVIDENCE_LIST = (minItems) => ({
172
+ type: 'array',
173
+ ...(minItems === undefined ? {} : { minItems }),
174
+ items: EVIDENCE_LOCATION,
175
+ });
176
+
153
177
  /** Plan_59: execution schemas stay static; schemaFor selects the advisor contract by phase. */
154
178
  export const SCHEMAS = {
155
179
  'codex-scout': SCOUT_SCHEMA,
@@ -220,11 +244,11 @@ export const PHASE_SCHEMAS = {
220
244
  items: {
221
245
  type: 'object',
222
246
  additionalProperties: false,
223
- required: ['claim', 'rating', 'address'],
247
+ required: ['claim', 'rating', 'evidence'],
224
248
  properties: {
225
249
  claim: { type: 'string' },
226
250
  rating: { type: 'string', enum: ['VERIFIED', 'REASONABLE', 'FRAGILE'] },
227
- address: { type: 'string' },
251
+ evidence: EVIDENCE_LIST(),
228
252
  },
229
253
  },
230
254
  },
@@ -233,11 +257,12 @@ export const PHASE_SCHEMAS = {
233
257
  items: {
234
258
  type: 'object',
235
259
  additionalProperties: false,
236
- required: ['risk_id', 'outcome', 'address'],
260
+ required: ['risk_id', 'outcome', 'note', 'evidence'],
237
261
  properties: {
238
262
  risk_id: { type: 'string' },
239
263
  outcome: { type: 'string', enum: ['confirmed', 'refuted'] },
240
- address: { type: 'string' },
264
+ note: { type: 'string' },
265
+ evidence: EVIDENCE_LIST(1),
241
266
  },
242
267
  },
243
268
  },
@@ -254,10 +279,11 @@ export const PHASE_SCHEMAS = {
254
279
  early_check: {
255
280
  type: 'object',
256
281
  additionalProperties: false,
257
- required: ['kind', 'target'],
282
+ required: ['kind', 'target', 'evidence'],
258
283
  properties: {
259
284
  kind: { type: 'string', enum: ['test', 'command', 'inspect'] },
260
285
  target: { type: 'string', minLength: 3 },
286
+ evidence: EVIDENCE_LIST(),
261
287
  },
262
288
  },
263
289
  },
@@ -271,8 +297,8 @@ export const PHASE_SCHEMAS = {
271
297
  items: {
272
298
  type: 'object',
273
299
  additionalProperties: false,
274
- required: ['check', 'address'],
275
- properties: { check: { type: 'string' }, address: { type: 'string' } },
300
+ required: ['check', 'evidence'],
301
+ properties: { check: { type: 'string' }, evidence: EVIDENCE_LIST(1) },
276
302
  },
277
303
  },
278
304
  },