@lyupro/codex-bridge 0.6.6 → 0.6.8

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 (55) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -5
  3. package/bin/codex-bridge.mjs +186 -178
  4. package/cli/dispatcher-contract-record.mjs +51 -0
  5. package/cli/dispatcher-contract.mjs +90 -0
  6. package/cli/doctor-hooks.mjs +104 -114
  7. package/cli/doctor-installation.mjs +111 -111
  8. package/cli/doctor-runs.mjs +55 -55
  9. package/cli/doctor.mjs +53 -5
  10. package/cli/handback-witness-check.mjs +31 -0
  11. package/cli/hook-targets.mjs +14 -6
  12. package/cli/hook.mjs +32 -23
  13. package/cli/host-contract.mjs +145 -135
  14. package/cli/install.mjs +23 -17
  15. package/cli/launcher-probe.mjs +38 -0
  16. package/cli/probe-contract.mjs +55 -70
  17. package/cli/probe-rig.mjs +53 -0
  18. package/cli/probe-target.mjs +33 -0
  19. package/cli/run-launcher.mjs +17 -0
  20. package/cli/session-hosts.mjs +51 -0
  21. package/cli/settings-merge.mjs +42 -35
  22. package/cli/uninstall.mjs +16 -3
  23. package/cli/update.mjs +45 -16
  24. package/package.json +4 -4
  25. package/scripts/run-tests.mjs +7 -0
  26. package/src/agents/codex-advisor.md +3 -4
  27. package/src/agents/codex-build.md +5 -5
  28. package/src/agents/codex-review.md +3 -3
  29. package/src/agents/codex-scout.md +3 -4
  30. package/src/home/hooks/dispatcher-gate.mjs +132 -0
  31. package/src/home/hooks/guard-tries.mjs +5 -6
  32. package/src/home/hooks/live-runs.mjs +1 -0
  33. package/src/home/hooks/order-gate.mjs +20 -5
  34. package/src/home/hooks/reply-guard.mjs +95 -10
  35. package/src/home/hooks/reply-verdicts.mjs +5 -0
  36. package/src/home/hooks/worktree-lock.mjs +108 -107
  37. package/src/home/lib/atomic-json.mjs +21 -0
  38. package/src/home/lib/brand-home.mjs +6 -0
  39. package/src/home/lib/dispatcher-command.mjs +79 -0
  40. package/src/home/lib/dispatcher-gate.mjs +95 -0
  41. package/src/home/lib/dispatcher-order.mjs +30 -8
  42. package/src/home/lib/dispatcher-state.mjs +111 -0
  43. package/src/home/lib/dispatcher-stop.mjs +52 -0
  44. package/src/home/lib/handback-witness.mjs +70 -0
  45. package/src/home/lib/hook-definitions.mjs +10 -0
  46. package/src/home/lib/hook-entry.mjs +20 -0
  47. package/src/home/lib/host-observations.mjs +57 -0
  48. package/src/home/lib/host-version.mjs +27 -0
  49. package/src/home/lib/meta/advice-verdict.mjs +37 -21
  50. package/src/home/lib/required-inputs.mjs +1 -1
  51. package/src/home/lib/runner/codex-cmd.mjs +3 -2
  52. package/src/home/lib/runner/git-state.mjs +1 -1
  53. package/src/home/lib/runner/preflight.mjs +6 -3
  54. package/src/home/lib/runner/prompts.mjs +8 -7
  55. 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,70 @@
1
+ /** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
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
+ import { transcriptHostVersion } from './host-version.mjs';
8
+
9
+ export const WITNESS_FILE = 'handback-witness.json';
10
+
11
+ export async function witnessHostVersion(payload) {
12
+ return transcriptHostVersion(payload?.transcript_path);
13
+ }
14
+
15
+ function normalizeWitness(record) {
16
+ const alarmEntry = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
17
+ && (entry.hostVersion === null || typeof entry.hostVersion === 'string')
18
+ && typeof entry.at === 'string' && typeof entry.detail === 'string';
19
+ if (!record || typeof record !== 'object' || Array.isArray(record) || !Array.isArray(record.alarms)) return null;
20
+ if (record.alarms.every(alarmEntry) && record.lastSeen && typeof record.lastSeen === 'object'
21
+ && !Array.isArray(record.lastSeen) && !Object.hasOwn(record.lastSeen, 'sdkVersion')
22
+ && !Object.hasOwn(record.lastSeen, 'at') && Object.values(record.lastSeen).every((at) => typeof at === 'string')) return record;
23
+ const legacy = record.lastSeen === null || (record.lastSeen && typeof record.lastSeen === 'object'
24
+ && !Array.isArray(record.lastSeen) && typeof record.lastSeen.sdkVersion === 'string' && typeof record.lastSeen.at === 'string');
25
+ const legacyAlarm = (entry) => entry && typeof entry === 'object' && !Array.isArray(entry)
26
+ && (entry.sdkVersion === null || typeof entry.sdkVersion === 'string') && typeof entry.at === 'string'
27
+ && typeof entry.detail === 'string';
28
+ if (legacy && record.alarms.every(legacyAlarm)) return { lastSeen: {}, alarms: record.alarms.map(({ at, detail }) => ({ hostVersion: null, at, detail })) };
29
+ return null;
30
+ }
31
+
32
+ export function readHandbackWitness({ stateDir }) {
33
+ const file = path.join(stateDir, WITNESS_FILE);
34
+ let source;
35
+ try {
36
+ source = fs.readFileSync(file, 'utf8');
37
+ } catch (error) {
38
+ if (error.code === 'ENOENT') return { lastSeen: {}, alarms: [] };
39
+ return { corrupt: true };
40
+ }
41
+ try {
42
+ const record = parseJsonText(file, source);
43
+ return normalizeWitness(record) || { corrupt: true };
44
+ } catch {
45
+ return { corrupt: true };
46
+ }
47
+ }
48
+
49
+ function timestamp(now) {
50
+ return new Date(now).toISOString();
51
+ }
52
+
53
+
54
+ export async function recordHandbackWitness({ stateDir, kind, hostVersion, detail = '', now = new Date() }) {
55
+ if (kind !== 'seen' && kind !== 'alarm') throw new TypeError("kind must be 'seen' or 'alarm'");
56
+ if (hostVersion !== null && typeof hostVersion !== 'string') {
57
+ throw new TypeError('hostVersion must be a string or null');
58
+ }
59
+ const file = path.join(stateDir, WITNESS_FILE);
60
+ return withFileLock(`${file}.lock`, async () => {
61
+ const current = readHandbackWitness({ stateDir });
62
+ if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
63
+ const at = timestamp(now);
64
+ const record = kind === 'seen'
65
+ ? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
66
+ : { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
67
+ writeJsonAtomic(file, record);
68
+ return record;
69
+ }, { description: 'handback witness' });
70
+ }
@@ -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
+ }
@@ -0,0 +1,57 @@
1
+ /** Records session host versions after the 2026-09-25 PATH host identity incident. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { writeJsonAtomic } from './atomic-json.mjs';
5
+ import { withFileLock } from './file-lock.mjs';
6
+ import { parseJsonText } from './json-file.mjs';
7
+ import { transcriptHostVersion } from './host-version.mjs';
8
+
9
+ export const HOST_OBSERVATIONS_FILE = 'host-observations.json';
10
+
11
+ export function readHostObservations({ stateDir }) {
12
+ const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
13
+ let raw;
14
+ try {
15
+ raw = fs.readFileSync(file, 'utf8');
16
+ } catch (error) {
17
+ if (error.code === 'ENOENT') return { hosts: {}, sessions: {} };
18
+ return { hosts: {}, sessions: {}, corrupt: true };
19
+ }
20
+ try {
21
+ const record = parseJsonText(file, raw);
22
+ if (!record || typeof record !== 'object' || Array.isArray(record)
23
+ || !record.hosts || typeof record.hosts !== 'object' || Array.isArray(record.hosts)
24
+ || !record.sessions || typeof record.sessions !== 'object' || Array.isArray(record.sessions)) {
25
+ return { hosts: {}, sessions: {}, corrupt: true };
26
+ }
27
+ return { hosts: record.hosts, sessions: record.sessions };
28
+ } catch {
29
+ return { hosts: {}, sessions: {}, corrupt: true };
30
+ }
31
+ }
32
+
33
+ export async function observeSessionHost({
34
+ stateDir, sessionId, transcriptPath, now = new Date(), readVersion = transcriptHostVersion,
35
+ }) {
36
+ const file = path.join(stateDir, HOST_OBSERVATIONS_FILE);
37
+ if (readHostObservations({ stateDir }).sessions[sessionId]) return { recorded: false };
38
+ const version = await readVersion(transcriptPath);
39
+ if (version === null) return { recorded: false };
40
+ return withFileLock(`${file}.lock`, async () => {
41
+ const current = readHostObservations({ stateDir });
42
+ if (current.corrupt) throw new Error(`Cannot update corrupt host observations: ${file}`);
43
+ if (current.sessions[sessionId]) return { recorded: false };
44
+ const at = new Date(now).toISOString();
45
+ const cutoffSessions = Date.parse(at) - 7 * 24 * 60 * 60 * 1000;
46
+ const cutoffHosts = Date.parse(at) - 30 * 24 * 60 * 60 * 1000;
47
+ const sessions = Object.fromEntries(Object.entries(current.sessions)
48
+ .filter(([, entry]) => Date.parse(entry.at) >= cutoffSessions));
49
+ const hosts = Object.fromEntries(Object.entries(current.hosts)
50
+ .filter(([, entry]) => Date.parse(entry.lastSeen) >= cutoffHosts));
51
+ sessions[sessionId] = { version, at };
52
+ const existing = hosts[version];
53
+ hosts[version] = { firstSeen: existing?.firstSeen || at, lastSeen: at };
54
+ writeJsonAtomic(file, { hosts, sessions });
55
+ return { recorded: true };
56
+ }, { description: 'host observations' });
57
+ }
@@ -0,0 +1,27 @@
1
+ /** Reads the host version from the transcript tail after the 2026-09-25 PATH host identity incident. */
2
+ import fs from 'node:fs/promises';
3
+ import { parseJsonText } from './json-file.mjs';
4
+
5
+ export async function transcriptHostVersion(transcriptPath, { tailBytes = 65536 } = {}) {
6
+ try {
7
+ const handle = await fs.open(transcriptPath, 'r');
8
+ try {
9
+ const { size } = await handle.stat();
10
+ const length = Math.min(size, tailBytes);
11
+ const buffer = Buffer.alloc(length);
12
+ await handle.read(buffer, 0, length, size - length);
13
+ const lines = buffer.toString('utf8').split(/\r?\n/);
14
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
15
+ try {
16
+ const entry = parseJsonText(transcriptPath, lines[index]);
17
+ if (typeof entry?.version === 'string' && /^\d+\.\d+\.\d+/.test(entry.version)) {
18
+ return entry.version;
19
+ }
20
+ } catch {}
21
+ }
22
+ } finally {
23
+ await handle.close();
24
+ }
25
+ } catch {}
26
+ return null;
27
+ }
@@ -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
  },