@lyupro/codex-bridge 0.6.5 → 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.
- package/CHANGELOG.md +60 -0
- package/README.md +5 -5
- package/bin/codex-bridge.mjs +181 -178
- package/cli/dispatcher-contract-record.mjs +42 -0
- package/cli/dispatcher-contract.mjs +89 -0
- package/cli/doctor-hooks.mjs +104 -114
- package/cli/doctor-runs.mjs +55 -55
- package/cli/doctor.mjs +39 -3
- package/cli/handback-witness-check.mjs +39 -0
- package/cli/hook-targets.mjs +14 -6
- package/cli/hook.mjs +32 -23
- package/cli/host-contract.mjs +136 -135
- package/cli/install.mjs +9 -14
- package/cli/launcher-probe.mjs +38 -0
- package/cli/probe-contract.mjs +31 -62
- package/cli/probe-rig.mjs +53 -0
- package/cli/run-launcher.mjs +17 -0
- package/cli/settings-merge.mjs +42 -35
- package/cli/uninstall.mjs +15 -3
- package/cli/update.mjs +45 -16
- package/package.json +4 -4
- package/src/agents/codex-advisor.md +3 -4
- package/src/agents/codex-build.md +5 -5
- package/src/agents/codex-review.md +3 -3
- package/src/agents/codex-scout.md +3 -4
- package/src/home/hooks/dispatcher-gate.mjs +119 -0
- package/src/home/hooks/guard-tries.mjs +5 -6
- package/src/home/hooks/live-runs.mjs +1 -0
- package/src/home/hooks/order-gate.mjs +20 -5
- package/src/home/hooks/reply-guard.mjs +75 -10
- package/src/home/hooks/reply-verdicts.mjs +5 -0
- package/src/home/hooks/worktree-lock.mjs +108 -107
- package/src/home/lib/atomic-json.mjs +21 -0
- package/src/home/lib/brand-home.mjs +6 -0
- package/src/home/lib/dispatcher-command.mjs +79 -0
- package/src/home/lib/dispatcher-gate.mjs +95 -0
- package/src/home/lib/dispatcher-order.mjs +30 -8
- package/src/home/lib/dispatcher-state.mjs +111 -0
- package/src/home/lib/dispatcher-stop.mjs +52 -0
- package/src/home/lib/handback-witness.mjs +65 -0
- package/src/home/lib/hook-definitions.mjs +10 -0
- package/src/home/lib/hook-entry.mjs +20 -0
- package/src/home/lib/meta/advice-status.mjs +3 -7
- package/src/home/lib/meta/advice-verdict.mjs +37 -21
- package/src/home/lib/required-inputs.mjs +1 -1
- package/src/home/lib/runner/advise-carry.mjs +50 -0
- package/src/home/lib/runner/attach.mjs +11 -7
- package/src/home/lib/runner/codex-cmd.mjs +3 -2
- package/src/home/lib/runner/continuation.mjs +3 -1
- package/src/home/lib/runner/git-state.mjs +1 -1
- package/src/home/lib/runner/launcher.mjs +14 -13
- package/src/home/lib/runner/preflight.mjs +6 -3
- package/src/home/lib/runner/prompts.mjs +9 -8
- 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
|
-
/**
|
|
7
|
-
export function
|
|
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
|
|
10
|
-
const
|
|
11
|
-
const content =
|
|
12
|
-
const
|
|
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
|
|
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
|
+
}
|
|
@@ -32,14 +32,10 @@ export function adviceGap(runDir, result, eventData) {
|
|
|
32
32
|
|
|
33
33
|
let scopeResult;
|
|
34
34
|
if (phase === 'advise') {
|
|
35
|
-
if (
|
|
36
|
-
reasons.push('
|
|
35
|
+
if (!task?.scope || !Array.isArray(task.scope.predicted_risks)) {
|
|
36
|
+
reasons.push('advisor-task.json#scope is missing the phase-1 predicted_risks');
|
|
37
37
|
} else {
|
|
38
|
-
|
|
39
|
-
scopeResult = readJson(scopeFile);
|
|
40
|
-
if (!scopeResult || typeof scopeResult !== 'object' || !Array.isArray(scopeResult.predicted_risks)) {
|
|
41
|
-
reasons.push('phase-1 result.json or its predicted_risks is missing');
|
|
42
|
-
}
|
|
38
|
+
scopeResult = { predicted_risks: task.scope.predicted_risks, missing_paths: task.scope.missing_paths };
|
|
43
39
|
}
|
|
44
40
|
}
|
|
45
41
|
if (reasons.length) return contractReason(reasons);
|
|
@@ -76,10 +76,9 @@ function* addressesIn(value, root) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function citationProblem(
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
112
|
-
|
|
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
|
-
//
|
|
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 (
|
|
119
|
-
for (const address of addressesIn(value, root))
|
|
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
|
-
|
|
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'
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Owns the scope snapshot shared by advise readers because run 2026-09-23_141341_plan59-a1-stdin-hang left them with different inputs. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readJsonFileSync } from '../json-file.mjs';
|
|
4
|
+
import { parseAdvisorTask } from '../meta/advisor-task.mjs';
|
|
5
|
+
import { die } from './args.mjs';
|
|
6
|
+
|
|
7
|
+
export function advisorTaskArtifact({ taskText, phase, runsRoot, grantRun }) {
|
|
8
|
+
const task = parseAdvisorTask(taskText);
|
|
9
|
+
if (phase !== 'advise') return task;
|
|
10
|
+
if (typeof runsRoot !== 'string' || !runsRoot || typeof grantRun !== 'string' || !grantRun) {
|
|
11
|
+
throw new Error('Cannot carry advise scope result: runsRoot and grantRun are required.');
|
|
12
|
+
}
|
|
13
|
+
const scopeFile = path.join(runsRoot, grantRun, 'result.json');
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
result = readJsonFileSync(scopeFile);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Cannot read advise scope result at ${scopeFile}: ${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
if (!result || typeof result !== 'object' || Array.isArray(result) ||
|
|
21
|
+
!Array.isArray(result.predicted_risks) || !Array.isArray(result.missing_paths) ||
|
|
22
|
+
result.predicted_risks.some((risk) => !risk || typeof risk.id !== 'string' || typeof risk.risk !== 'string') ||
|
|
23
|
+
result.missing_paths.some((entry) => typeof entry !== 'string')) {
|
|
24
|
+
throw new Error(`Malformed advise scope result at ${scopeFile}: expected predicted_risks and missing_paths arrays.`);
|
|
25
|
+
}
|
|
26
|
+
return { ...task, scope: { run: grantRun, predicted_risks: result.predicted_risks, missing_paths: result.missing_paths } };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Called before makeRunDir: a scope result that cannot be carried is known before any quota is
|
|
30
|
+
// spent, so it refuses like every other preflight gate instead of leaving a registered run behind.
|
|
31
|
+
export function advisorTaskOrRefuse(input) {
|
|
32
|
+
try {
|
|
33
|
+
return advisorTaskArtifact(input);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return die(`${error.message} The run folder was not created; quota was not spent.`, 1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function adviseSection(artifact) {
|
|
40
|
+
if (!artifact?.scope) return '';
|
|
41
|
+
return [
|
|
42
|
+
'## Scope phase results',
|
|
43
|
+
'',
|
|
44
|
+
'Predicted risks:',
|
|
45
|
+
...artifact.scope.predicted_risks.map(({ id, risk }) => `- ${id}: ${risk}`),
|
|
46
|
+
'',
|
|
47
|
+
'Paths you may additionally read and cite:',
|
|
48
|
+
...artifact.scope.missing_paths.map((entry) => `- ${entry}`),
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
@@ -5,6 +5,7 @@ import { readJsonFileSync } from '../json-file.mjs';
|
|
|
5
5
|
import { workerMayBeAlive } from '../meta/run-liveness.mjs';
|
|
6
6
|
import { processAlive } from '../process-identity.mjs';
|
|
7
7
|
import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
|
|
8
|
+
import { sameRun } from './continuation.mjs';
|
|
8
9
|
import { conflictingOrderOwner, runsForOrder } from './order-owner.mjs';
|
|
9
10
|
|
|
10
11
|
// The attach call is the only process that waits for a worker it did not spawn. Keeping that
|
|
@@ -93,18 +94,21 @@ export async function waitForReply(runDir, workerPid, status) {
|
|
|
93
94
|
* path only covers a live run that has not recorded a verdict yet.
|
|
94
95
|
*
|
|
95
96
|
* A repeat that arrives after the verdict is answered from disk rather than refused, for the same
|
|
96
|
-
* reason. `--continue`
|
|
97
|
-
* the
|
|
97
|
+
* reason. `--continue` may attach to a run created from its grant: the 2026-09-23 dispatcher
|
|
98
|
+
* incident showed that repeating the identical continuation command must return to that run.
|
|
98
99
|
*/
|
|
99
|
-
export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, isContinue, noWait } = {}) {
|
|
100
|
-
if (isContinue) return null;
|
|
100
|
+
export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, grantRun, isContinue, noWait } = {}) {
|
|
101
101
|
const runs = chain || chainRuns(runsRoot, repo, slug, taskHash, orderId);
|
|
102
102
|
const runRecords = runs
|
|
103
103
|
.map((run) => ({ run, status: readJsonFile(path.join(runsRoot, run, 'status.json')) }))
|
|
104
104
|
.filter(({ status }) => status);
|
|
105
105
|
const sameOrder = runsForOrder(runRecords, orderId);
|
|
106
|
+
const attachRuns = isContinue
|
|
107
|
+
? sameOrder.filter(({ status }) => grantRun && status.continued_from && sameRun(runsRoot, status.continued_from, grantRun))
|
|
108
|
+
: sameOrder;
|
|
109
|
+
if (isContinue && !attachRuns.length) return null;
|
|
106
110
|
|
|
107
|
-
const owner = conflictingOrderOwner(runRecords, orderId, taskHash);
|
|
111
|
+
const owner = isContinue ? null : conflictingOrderOwner(runRecords, orderId, taskHash);
|
|
108
112
|
if (owner) {
|
|
109
113
|
const ownerDir = path.join(runsRoot, owner.run);
|
|
110
114
|
console.log(
|
|
@@ -120,8 +124,8 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
|
|
|
120
124
|
// order, and while it was in flight a repeat used to be answered by the first run's reply.txt —
|
|
121
125
|
// a stale verdict presented as this pass's answer. Found by the Plan_11-2 checklist, 2026-08-04.
|
|
122
126
|
let candidate = null;
|
|
123
|
-
for (let i =
|
|
124
|
-
const entry =
|
|
127
|
+
for (let i = attachRuns.length - 1; i >= 0; i -= 1) {
|
|
128
|
+
const entry = attachRuns[i];
|
|
125
129
|
const dir = path.join(runsRoot, entry.run);
|
|
126
130
|
if (fs.existsSync(path.join(dir, 'reply.txt'))) {
|
|
127
131
|
announceSavedReply(dir, orderId, entry.status.started_at);
|
|
@@ -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();
|
|
@@ -9,7 +9,9 @@ import path from 'node:path';
|
|
|
9
9
|
import { CONTINUATION_INPUT, parseContinuationGrant } from '../required-inputs.mjs';
|
|
10
10
|
import { readJson } from '../write-meta.mjs';
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
// One comparison of run names for the grant gate and for attach, so a repeat and a grant can never
|
|
13
|
+
// disagree about whether two spellings name the same folder.
|
|
14
|
+
export const sameRun = (runsRootPath, left, right) => {
|
|
13
15
|
const a = path.resolve(runsRootPath, left);
|
|
14
16
|
const b = path.resolve(runsRootPath, right);
|
|
15
17
|
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
@@ -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
|