great-cto 3.70.1 → 3.71.1
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/README.md +9 -1
- package/board/.claude-plugin/plugin.json +93 -1
- package/board/packages/board/lib/routes.mjs +14 -1
- package/board/packages/board/lib/work.mjs +34 -3
- package/board/packages/board/public/assets/work-view.js +146 -18
- package/board/scripts/codex-pipeline.mjs +2 -0
- package/board/scripts/lib/claude-exec.mjs +49 -6
- package/board/scripts/lib/codex-exec.mjs +42 -18
- package/board/scripts/lib/codex-host-state.mjs +18 -2
- package/board/scripts/lib/codex-pipeline.mjs +44 -7
- package/board/scripts/lib/host-graph-bindings.mjs +135 -0
- package/board/scripts/lib/host-graph-events.mjs +157 -0
- package/board/scripts/lib/host-graph-journal.mjs +210 -0
- package/board/scripts/lib/host-graph-observer.mjs +61 -0
- package/board/scripts/lib/host-graph-runtime.mjs +38 -0
- package/board/scripts/work-task.mjs +17 -4
- package/dist/adapt.js +10 -21
- package/dist/codex.js +4 -15
- package/dist/companion.js +6 -122
- package/dist/installer.js +4 -72
- package/dist/main.js +44 -117
- package/dist/release-activation.mjs +561 -0
- package/dist/release-archive.mjs +165 -0
- package/dist/release-trust.mjs +514 -0
- package/dist/release-update.mjs +303 -0
- package/dist/self-upgrade.js +10 -39
- package/dist/update-check.js +1 -0
- package/dist/upgrade.js +5 -120
- package/package.json +1 -1
- package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
- package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
- package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
- package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
- package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Restricted Claude Code subprocess for controller-owned, read-only proposals. */
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
3
4
|
|
|
4
5
|
const PROPOSAL_SCHEMA = JSON.stringify({
|
|
5
6
|
type: 'object',
|
|
@@ -35,24 +36,56 @@ export function parseClaudeResult(raw) {
|
|
|
35
36
|
usage: body.usage || null, model: body.model || null };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.env.GREAT_CTO_CLAUDE_BIN || 'claude' }) {
|
|
39
|
+
export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.env.GREAT_CTO_CLAUDE_BIN || 'claude', onEvent = null }) {
|
|
39
40
|
return new Promise(resolve => {
|
|
41
|
+
const deadline = performance.now() + timeoutMs;
|
|
40
42
|
// Safe mode removes hooks, skills, plugins and project instructions. Restricted
|
|
41
43
|
// mode confines file tools to cwd. The tools allow only inspection; the
|
|
42
44
|
// controller remains the sole writer and gate owner.
|
|
43
|
-
const
|
|
45
|
+
const streaming = typeof onEvent === 'function';
|
|
46
|
+
const args = ['--print', '--output-format', streaming ? 'stream-json' : 'json', ...(streaming ? ['--verbose'] : []), '--no-session-persistence',
|
|
44
47
|
'--safe-mode', '--restricted', '--strict-mcp-config', '--tools', 'Read,Glob,Grep',
|
|
45
48
|
'--permission-mode', 'dontAsk', '--permission-prompts', 'none', '--json-schema', PROPOSAL_SCHEMA];
|
|
46
49
|
const group = process.platform !== 'win32';
|
|
47
50
|
const proc = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: group });
|
|
48
51
|
let out = '', err = '', settled = false, timedOut = false, stdinError = null;
|
|
52
|
+
let observationHealth = 'ok', line = '', dropping = false;
|
|
53
|
+
const observationLimit = 1024 * 1024;
|
|
54
|
+
const observeLine = value => {
|
|
55
|
+
if (!value.trim()) return;
|
|
56
|
+
try {
|
|
57
|
+
const event = JSON.parse(value);
|
|
58
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) { observationHealth = 'degraded'; return; }
|
|
59
|
+
const pending = onEvent(event);
|
|
60
|
+
if (pending && typeof pending.then === 'function') {
|
|
61
|
+
// Observations are synchronous: do not create an untracked writer lifetime.
|
|
62
|
+
observationHealth = 'degraded'; Promise.resolve(pending).catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
} catch { observationHealth = 'degraded'; }
|
|
65
|
+
};
|
|
66
|
+
const observeChunk = chunk => {
|
|
67
|
+
let offset = 0;
|
|
68
|
+
while (offset < chunk.length) {
|
|
69
|
+
const newline = chunk.indexOf('\n', offset), end = newline < 0 ? chunk.length : newline;
|
|
70
|
+
if (!dropping) {
|
|
71
|
+
const part = chunk.slice(offset, end);
|
|
72
|
+
if (Buffer.byteLength(line, 'utf8') + Buffer.byteLength(part, 'utf8') > observationLimit) {
|
|
73
|
+
line = ''; dropping = true; observationHealth = 'degraded';
|
|
74
|
+
} else line += part;
|
|
75
|
+
}
|
|
76
|
+
if (newline < 0) break;
|
|
77
|
+
if (!dropping) observeLine(line);
|
|
78
|
+
line = ''; dropping = false; offset = newline + 1;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
49
81
|
const finish = result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
|
|
50
82
|
const kill = () => {
|
|
51
83
|
try { if (group && proc.pid) process.kill(-proc.pid, 'SIGKILL'); else proc.kill('SIGKILL'); }
|
|
52
84
|
catch { try { proc.kill('SIGKILL'); } catch { /* process already exited */ } }
|
|
53
85
|
};
|
|
54
86
|
const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
|
|
55
|
-
proc.stdout.
|
|
87
|
+
proc.stdout.setEncoding('utf8');
|
|
88
|
+
proc.stdout.on('data', bytes => { out += bytes; if (streaming) observeChunk(bytes); });
|
|
56
89
|
proc.stderr.on('data', bytes => { err += String(bytes); });
|
|
57
90
|
const inputFailed = error => {
|
|
58
91
|
stdinError ||= typeof error?.code === 'string' ? error.code : 'STDIN_ERROR';
|
|
@@ -62,14 +95,24 @@ export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.e
|
|
|
62
95
|
proc.on('error', error => finish({ state: 'unreadable', finalText: null, errors: [String(error.message)], usage: null, code: null }));
|
|
63
96
|
proc.on('close', code => {
|
|
64
97
|
if (group && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* gone */ } }
|
|
65
|
-
|
|
98
|
+
if (streaming && line && !dropping) observeLine(line);
|
|
99
|
+
// Final execution parsing is independent of the bounded observer and its callback.
|
|
100
|
+
let final = out;
|
|
101
|
+
if (streaming) {
|
|
102
|
+
final = '';
|
|
103
|
+
for (const candidate of out.split('\n')) {
|
|
104
|
+
try { if (JSON.parse(candidate)?.type === 'result') final = candidate; } catch { /* observer reports malformed lines */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const parsed = parseClaudeResult(final);
|
|
66
108
|
if (err.trim()) parsed.errors.push(err.trim().slice(-4000));
|
|
67
109
|
if (stdinError) {
|
|
68
110
|
parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null;
|
|
69
111
|
parsed.errors.push(`prompt transport failed: ${stdinError}`);
|
|
70
112
|
}
|
|
71
|
-
if (
|
|
72
|
-
|
|
113
|
+
if (performance.now() >= deadline) timedOut = true;
|
|
114
|
+
if (timedOut) { parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null; parsed.errors.push(`timed out after ${timeoutMs}ms`); }
|
|
115
|
+
finish({ ...parsed, code, timedOut, ...(streaming ? { observationHealth } : {}) });
|
|
73
116
|
});
|
|
74
117
|
try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
|
|
75
118
|
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* available version, auth mode and the model it will run, read from disk
|
|
14
14
|
*/
|
|
15
15
|
import { spawn, spawnSync } from 'node:child_process';
|
|
16
|
+
import { performance } from 'node:perf_hooks';
|
|
16
17
|
import { existsSync, readFileSync } from 'node:fs';
|
|
17
18
|
import { homedir } from 'node:os';
|
|
18
19
|
import { join } from 'node:path';
|
|
@@ -115,6 +116,7 @@ export function runCodexExec({
|
|
|
115
116
|
sandbox = 'read-only', ephemeral = true, extraArgs = [], onEvent = null,
|
|
116
117
|
}) {
|
|
117
118
|
return new Promise((resolve) => {
|
|
119
|
+
const deadline = performance.now() + timeoutMs;
|
|
118
120
|
const args = ['exec', '--json', '--skip-git-repo-check'];
|
|
119
121
|
if (sandbox) args.push('-s', sandbox);
|
|
120
122
|
if (ephemeral) args.push('--ephemeral');
|
|
@@ -139,24 +141,41 @@ export function runCodexExec({
|
|
|
139
141
|
};
|
|
140
142
|
const timer = setTimeout(() => { timedOut = true; killGroup(); }, timeoutMs);
|
|
141
143
|
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
144
|
+
// Observation failures remain separate from the final execution parser.
|
|
145
|
+
const observing = typeof onEvent === 'function';
|
|
146
|
+
let observationHealth = 'ok', pending = '', dropping = false;
|
|
147
|
+
const observationLimit = 1024 * 1024;
|
|
148
|
+
const observeLine = line => {
|
|
149
|
+
const text = line.trim();
|
|
150
|
+
if (!text.startsWith('{') && !text.startsWith('[')) return;
|
|
151
|
+
try {
|
|
152
|
+
const event = JSON.parse(text);
|
|
153
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) { observationHealth = 'degraded'; return; }
|
|
154
|
+
const result = onEvent(event);
|
|
155
|
+
if (result && typeof result.then === 'function') {
|
|
156
|
+
observationHealth = 'degraded'; Promise.resolve(result).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
} catch { observationHealth = 'degraded'; }
|
|
159
|
+
};
|
|
160
|
+
const observeChunk = chunk => {
|
|
161
|
+
let offset = 0;
|
|
162
|
+
while (offset < chunk.length) {
|
|
163
|
+
const newline = chunk.indexOf('\n', offset), end = newline < 0 ? chunk.length : newline;
|
|
164
|
+
if (!dropping) {
|
|
165
|
+
const part = chunk.slice(offset, end);
|
|
166
|
+
if (Buffer.byteLength(pending, 'utf8') + Buffer.byteLength(part, 'utf8') > observationLimit) {
|
|
167
|
+
pending = ''; dropping = true; observationHealth = 'degraded';
|
|
168
|
+
} else pending += part;
|
|
169
|
+
}
|
|
170
|
+
if (newline < 0) break;
|
|
171
|
+
if (!dropping) observeLine(pending);
|
|
172
|
+
pending = ''; dropping = false; offset = newline + 1;
|
|
159
173
|
}
|
|
174
|
+
};
|
|
175
|
+
proc.stdout.setEncoding('utf8');
|
|
176
|
+
proc.stdout.on('data', chunk => {
|
|
177
|
+
out += chunk;
|
|
178
|
+
if (observing) observeChunk(chunk);
|
|
160
179
|
});
|
|
161
180
|
proc.stderr.on('data', (b) => { err += String(b); });
|
|
162
181
|
const inputFailed = error => {
|
|
@@ -174,6 +193,7 @@ export function runCodexExec({
|
|
|
174
193
|
clearTimeout(timer);
|
|
175
194
|
// Best effort: a child the CLI left behind must not outlive the review.
|
|
176
195
|
if (group && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* group already gone */ } }
|
|
196
|
+
if (observing && pending && !dropping) observeLine(pending);
|
|
177
197
|
const parsed = parseCodexStream(out);
|
|
178
198
|
// stderr is kept even on success: Codex writes warnings there that change
|
|
179
199
|
// how a result should be read.
|
|
@@ -188,11 +208,15 @@ export function runCodexExec({
|
|
|
188
208
|
}
|
|
189
209
|
// A run cut off by the clock may have printed a verdict before it was done.
|
|
190
210
|
// That is a truncated answer, and a truncated answer is not an answer.
|
|
211
|
+
const returnedAfterDeadline = performance.now() >= deadline;
|
|
212
|
+
if (returnedAfterDeadline && !timedOut) parsed.text = null;
|
|
213
|
+
if (returnedAfterDeadline) timedOut = true;
|
|
191
214
|
if (timedOut) {
|
|
215
|
+
parsed.finalText = null;
|
|
192
216
|
parsed.errors.push(`timed out after ${timeoutMs}ms`);
|
|
193
217
|
if (parsed.state === 'ok') parsed.state = 'unreadable';
|
|
194
218
|
}
|
|
195
|
-
resolve({ ...parsed, code, model, timedOut });
|
|
219
|
+
resolve({ ...parsed, code, model, timedOut, ...(observing ? { observationHealth } : {}) });
|
|
196
220
|
});
|
|
197
221
|
|
|
198
222
|
try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
|
|
@@ -7,11 +7,22 @@ import { detectCodex } from './codex-exec.mjs';
|
|
|
7
7
|
import { detectClaude } from './claude-exec.mjs';
|
|
8
8
|
import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
|
|
9
9
|
import { projectExecutionTrace } from './execution-trace.mjs';
|
|
10
|
+
import { graphBinding, projectHostObservations } from './host-graph-observer.mjs';
|
|
11
|
+
import { invocationIdentity } from './invocation-identity.mjs';
|
|
10
12
|
|
|
11
13
|
export const codexRunStore = ({ home = homedir(), env = process.env } = {}) =>
|
|
12
14
|
env.GREAT_CTO_CODEX_RUNS_DIR || join(home, '.great_cto', 'codex-runs');
|
|
13
15
|
|
|
14
16
|
export function projectCodexState(state) {
|
|
17
|
+
const observationInvocations = (state.attempts || []).flatMap(a => [{ ...a, observationHost: a.host || 'codex', observationId: a.invocationId }, ...(a.verifierInvocationId ? [{ ...a, observationHost: 'codex', observationId: a.verifierInvocationId }] : [])]).filter(a => a.observationId);
|
|
18
|
+
// A frozen wave is persisted before dispatch; prepared attempts appear later.
|
|
19
|
+
for (const role of state.wave?.roles || []) {
|
|
20
|
+
const id = state.wave.attemptIds?.[role], observationHost = state.wave.hosts?.[role];
|
|
21
|
+
const observationId = invocationIdentity({ session_id: state.id, agent_id: id });
|
|
22
|
+
if (observationId && ['codex', 'claude-code'].includes(observationHost)
|
|
23
|
+
&& !observationInvocations.some(a => a.observationId === observationId && a.observationHost === observationHost))
|
|
24
|
+
observationInvocations.push({ id, observationHost, observationId });
|
|
25
|
+
}
|
|
15
26
|
return {
|
|
16
27
|
id: state.id,
|
|
17
28
|
version: state.version,
|
|
@@ -23,11 +34,16 @@ export function projectCodexState(state) {
|
|
|
23
34
|
hostRoutes: state.hostRoutes || {},
|
|
24
35
|
controllerDispatch: dispatchEvidenceSummary(state),
|
|
25
36
|
executionGraph: projectExecutionTrace(state),
|
|
37
|
+
hostObservationHealth: state.hostObservationHealth || null,
|
|
38
|
+
hostObservationBindings: observationInvocations.map(a => ({ attemptId: a.id, sessionId: a.observationId, host: a.observationHost })),
|
|
39
|
+
hostObservationOmittedAttempts: Math.max(0, observationInvocations.length - 8),
|
|
40
|
+
hostObservations: state.observeHostGraph === true ? observationInvocations.slice(-8).map(a =>
|
|
41
|
+
projectHostObservations(graphBinding({ root: state.root, runId: state.id, sessionId: a.observationId, attemptId: a.id, host: a.observationHost }))) : [],
|
|
26
42
|
wave: state.wave ? { id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
|
|
27
43
|
rolesCompleted: Object.keys(state.results || {}),
|
|
28
|
-
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId }) =>
|
|
44
|
+
attempts: (state.attempts || []).map(({ id, role, host, number, phase, status, startedAt, finishedAt, invocationId, verifierInvocationId }) =>
|
|
29
45
|
({ id, role, host: host || 'codex', number, phase, status, startedAt, finishedAt,
|
|
30
|
-
...(invocationId ? { invocationId } : {}) })),
|
|
46
|
+
...(invocationId ? { invocationId } : {}), ...(verifierInvocationId ? { verifierInvocationId } : {}) })),
|
|
31
47
|
pending: state.pending ? { role: state.pending.role, gates: state.pending.gates || [], createdAt: state.pending.createdAt } : null,
|
|
32
48
|
release: state.release ? {
|
|
33
49
|
adapter: state.release.adapter,
|
|
@@ -7,6 +7,8 @@ import { parsePipelineToml } from './pipeline-toml.mjs';
|
|
|
7
7
|
import { scan } from './secret-patterns.mjs';
|
|
8
8
|
import { runCodexExec, codexToolEvent } from './codex-exec.mjs';
|
|
9
9
|
import { runClaudeExec } from './claude-exec.mjs';
|
|
10
|
+
import { auditHostGraphRuntimes } from './host-graph-runtime.mjs';
|
|
11
|
+
import { graphBinding, observeHostGraph, recordHostGraphGap } from './host-graph-observer.mjs';
|
|
10
12
|
import { appendEvent } from './agent-events.mjs';
|
|
11
13
|
import { snapshotTurn, pruneTurns } from './turn-snapshot.mjs';
|
|
12
14
|
import { treeReceipt } from './receipt.mjs';
|
|
@@ -74,11 +76,37 @@ function emit(state, event) {
|
|
|
74
76
|
// The user ran this pipeline here, so it keeps its log here even before /start.
|
|
75
77
|
return appendEvent(join(state.root, '.great_cto'), { ...event, session: state.id }, { explicit: true });
|
|
76
78
|
}
|
|
77
|
-
const toolListener = (state, agent, invocationId = null) => (ev) => {
|
|
79
|
+
const toolListener = (state, agent, invocationId = null, sourceAudited = false, trustedAttemptId = null) => (ev) => {
|
|
80
|
+
if (state.observeHostGraph === true && invocationId) {
|
|
81
|
+
const attempt = (state.attempts || []).find(a => a.invocationId === invocationId || a.verifierInvocationId === invocationId);
|
|
82
|
+
// Parallel worker callbacks precede prepared attempts, but their wave ID is durable.
|
|
83
|
+
const host = agent.startsWith('claude-') ? 'claude-code' : 'codex';
|
|
84
|
+
const observed = observeHostGraph({ binding: graphBinding({ root: state.root, runId: state.id, sessionId: invocationId, attemptId: attempt?.id || trustedAttemptId, host }),
|
|
85
|
+
host, mode: 'controlled', sourceVersion: sourceAudited ? state.hostObservationSources?.find(s => s.host === host)?.sourceVersion || null : null, event: ev });
|
|
86
|
+
state.hostObservationHealth ||= { state: 'recorded', recorded: 0 };
|
|
87
|
+
state.hostObservationHealth.recorded += observed.recorded;
|
|
88
|
+
if (observed.state === 'degraded') state.hostObservationHealth.state = 'degraded';
|
|
89
|
+
}
|
|
78
90
|
const e = codexToolEvent(ev);
|
|
79
91
|
if (e) emit(state, { ...e, agent, invocation_id: invocationId });
|
|
80
92
|
};
|
|
81
93
|
|
|
94
|
+
/** Provider-free audit is refreshed for each controller action, never a completeness attestation. */
|
|
95
|
+
export function refreshHostObservationSources(state, { save = () => {}, audit = auditHostGraphRuntimes } = {}) {
|
|
96
|
+
if (state.observeHostGraph !== true) return;
|
|
97
|
+
state.hostObservationSources = audit({ binaries: {
|
|
98
|
+
codex: process.env.GREAT_CTO_CODEX_BIN || 'codex', 'claude-code': process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
99
|
+
} });
|
|
100
|
+
save(state);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function observeRunnerHealth(state, response, { host, invocationId, attemptId }) {
|
|
104
|
+
if (!state.observeHostGraph || response.observationHealth !== 'degraded') return;
|
|
105
|
+
state.hostObservationHealth ||= { state: 'recorded', recorded: 0 };
|
|
106
|
+
state.hostObservationHealth.state = 'degraded';
|
|
107
|
+
recordHostGraphGap(graphBinding({ root: state.root, runId: state.id, sessionId: invocationId, attemptId, host }), host, 'controlled', ['observer-transport-failure']);
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* ADR-026 — what a stage is told about the stages before it.
|
|
84
112
|
*
|
|
@@ -175,8 +203,10 @@ function releaseSummary(state) {
|
|
|
175
203
|
|
|
176
204
|
export async function verifyStage(state, role, proposal, execute) {
|
|
177
205
|
const agent = 'codex-verifier';
|
|
206
|
+
const verifierAttempt = state.attempts?.findLast(a => a.role === role);
|
|
178
207
|
const invocationId = invocationIdentity({ session_id: state.id,
|
|
179
|
-
agent_id: `${
|
|
208
|
+
agent_id: `${verifierAttempt?.id || randomUUID()}:verifier` });
|
|
209
|
+
if (verifierAttempt) verifierAttempt.verifierInvocationId = invocationId;
|
|
180
210
|
const t0 = Date.now();
|
|
181
211
|
let ok = false;
|
|
182
212
|
emit(state, { kind: 'agent-start', agent, invocation_id: invocationId });
|
|
@@ -189,10 +219,10 @@ export async function verifyStage(state, role, proposal, execute) {
|
|
|
189
219
|
if (attempt && admitted.evidence) attempt.domainVerifierContext = admitted.evidence;
|
|
190
220
|
return execute(admitted.options);
|
|
191
221
|
};
|
|
192
|
-
const
|
|
222
|
+
const verifierResponse = await invoke({
|
|
193
223
|
cwd: state.root, sandbox: 'read-only', ephemeral: true, extraArgs: workerArgs,
|
|
194
224
|
bin: process.env.GREAT_CTO_CODEX_BIN || 'codex', timeoutMs: 300000,
|
|
195
|
-
invocationId, onEvent: toolListener(state, agent, invocationId),
|
|
225
|
+
invocationId, onEvent: toolListener(state, agent, invocationId, execute === runCodexExec),
|
|
196
226
|
prompt: `You are an independent verifier for the ${role} stage. Read the ACTUAL files and assess whether they satisfy the task for this stage.\n` +
|
|
197
227
|
frozenPolicyContext(state) +
|
|
198
228
|
(state.specialistStages?.[role] ? `Controlled phase: ${state.specialistStages[role].phase}, before implementation. Verify an implementable contract/threat model and its evidence; do not require nonexistent implementation or treat design sign-off as code approval. Capability: ${codexRoleProfile(specialistRole(state, role))}\n` : '') +
|
|
@@ -220,7 +250,9 @@ export async function verifyStage(state, role, proposal, execute) {
|
|
|
220
250
|
`Do not treat file existence, a previous agent's statement or tests that were not executed as evidence of correctness. ` +
|
|
221
251
|
`Return ONLY JSON {"state":"verified|rework|unverifiable","findings":["..."],"checks":["what you actually inspected or ran"]}, plus dependencyAttestation when scoped dependency evidence is provided. ` +
|
|
222
252
|
`Use unverifiable if unable to inspect the evidence. Use rework when you find defects. No gate approval or file proposals.`,
|
|
223
|
-
})
|
|
253
|
+
});
|
|
254
|
+
observeRunnerHealth(state, verifierResponse, { host: 'codex', invocationId, attemptId: verifierAttempt?.id || null });
|
|
255
|
+
const result = cleanResponse(verifierResponse);
|
|
224
256
|
if (!['verified', 'rework', 'unverifiable'].includes(result.state) || !Array.isArray(result.findings) || !Array.isArray(result.checks) || !result.checks.length) throw Error('invalid or empty verifier evidence');
|
|
225
257
|
if (attestWorkflow && result.state === 'verified') {
|
|
226
258
|
const a = result.workflowAttestation;
|
|
@@ -650,6 +682,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
650
682
|
if (state.status === 'cancelled') return state;
|
|
651
683
|
if (state.active) throw Error('interrupted stage: inspect state and files before starting a new run');
|
|
652
684
|
if (state.status !== 'ready') return state;
|
|
685
|
+
refreshHostObservationSources(state, { save });
|
|
653
686
|
preflightSpecialistEpoch(state, save);
|
|
654
687
|
if (state.steps >= 32) throw Error('32-stage run limit reached');
|
|
655
688
|
assertArtifacts(state);
|
|
@@ -753,8 +786,9 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
753
786
|
stageStarted = true; emit(state, { kind: 'agent-start', agent, invocation_id: attempt.invocationId });
|
|
754
787
|
return observeControllerCall(state, { id: `${attempt.id}:worker`, attemptId: attempt.id, host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root, sandbox: 'read-only', ephemeral: true,
|
|
755
788
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
756
|
-
invocationId: attempt.invocationId, timeoutMs: 300000, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, attempt.invocationId) }), save);
|
|
789
|
+
invocationId: attempt.invocationId, timeoutMs: 300000, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: roleHost(state, role) === 'codex' || state.observeHostGraph === true ? toolListener(state, agent, attempt.invocationId, runner === runCodexExec || runner === runClaudeExec) : null }), save);
|
|
757
790
|
});
|
|
791
|
+
observeRunnerHealth(state, response, { host: roleHost(state, role), invocationId: attempt.invocationId, attemptId: attempt.id });
|
|
758
792
|
const proposalSource = traceKey(state, `dispatch:${attempt.id}`) || traceKey(state, `reuse:${attempt.id}`) || contextNode;
|
|
759
793
|
// Codex can recover its session-index lookup without degrading the worker.
|
|
760
794
|
// Keep the diagnostic in the receipt; every other warning/error blocks.
|
|
@@ -833,6 +867,7 @@ export async function runStage(state, { execute = null, runners = { codex: runCo
|
|
|
833
867
|
const beforeVerifier = stageReceipt(state, treeReceipt(state.root), 'verifier input');
|
|
834
868
|
if (JSON.stringify(beforeVerifier) !== JSON.stringify(receipt)) throw Error('working tree changed before verification');
|
|
835
869
|
if (!attempt.checks || attempt.checks.state === 'passed') {
|
|
870
|
+
attempt.verifierInvocationId = invocationIdentity({ session_id: state.id, agent_id: `${attempt.id}:verifier` });
|
|
836
871
|
recordTrace(state, 'verifier', { key: `verifier:${attempt.id}`, role, attemptId: attempt.id, host: 'codex', status: 'intent',
|
|
837
872
|
parents: [traceKey(state, `checks:${attempt.id}`) || proposalSource,
|
|
838
873
|
...traceParents(state, Object.keys(state.results), false)], relation: 'verification', input: receipt });
|
|
@@ -959,6 +994,7 @@ function preflightParallelProposals(state, roles, responses) {
|
|
|
959
994
|
/** Dispatch two read-only workers together, then apply their proposals one by one. */
|
|
960
995
|
export async function runParallelWave(state, { runners = { codex: runCodexExec, 'claude-code': runClaudeExec },
|
|
961
996
|
verify = verifyStage, checks = runChecks, save = () => {}, contextStore = null } = {}) {
|
|
997
|
+
refreshHostObservationSources(state, { save });
|
|
962
998
|
preflightSpecialistEpoch(state, save);
|
|
963
999
|
if (!state.wave) {
|
|
964
1000
|
const roles = parallelPair(state);
|
|
@@ -998,7 +1034,8 @@ export async function runParallelWave(state, { runners = { codex: runCodexExec,
|
|
|
998
1034
|
const result = await observeControllerCall(state, { id: `${waveId}:${role}`, attemptId: state.wave.attemptIds[role], host: roleHost(state, role), role, kind: 'worker' }, () => runner({ prompt: admitted.options.prompt, cwd: state.root,
|
|
999
1035
|
sandbox: 'read-only', ephemeral: true, timeoutMs: 300000,
|
|
1000
1036
|
bin: roleHost(state, role) === 'codex' ? process.env.GREAT_CTO_CODEX_BIN || 'codex' : process.env.GREAT_CTO_CLAUDE_BIN || 'claude',
|
|
1001
|
-
invocationId, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: toolListener(state, agent, invocationId) }), save);
|
|
1037
|
+
invocationId, extraArgs: roleHost(state, role) === 'codex' ? workerArgs : [], onEvent: roleHost(state, role) === 'codex' || state.observeHostGraph === true ? toolListener(state, agent, invocationId, runner === runCodexExec || runner === runClaudeExec, state.wave.attemptIds[role]) : null }), save);
|
|
1038
|
+
observeRunnerHealth(state, result, { host: roleHost(state, role), invocationId, attemptId: state.wave.attemptIds[role] });
|
|
1002
1039
|
ok = true;
|
|
1003
1040
|
return result;
|
|
1004
1041
|
} finally {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Explicit local enrollment; native observation grants no execution authority. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants, openSync, closeSync, fstatSync, fsyncSync, linkSync, mkdirSync, lstatSync, existsSync, readFileSync, writeFileSync, realpathSync, readdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join, resolve, dirname } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { assertExternal, projectIdentity } from './work-tasks.mjs';
|
|
7
|
+
import { readHostGraph } from './host-graph-journal.mjs';
|
|
8
|
+
import { graphBinding, observeHostGraph, projectHostObservations, recordHostGraphGap } from './host-graph-observer.mjs';
|
|
9
|
+
import { codexNativeTurnIdentity } from './host-graph-events.mjs';
|
|
10
|
+
const hostSet = new Set(['codex', 'claude-code']);
|
|
11
|
+
const validVersion = value => value === null || (typeof value === 'string' && value.length <= 80 && /^\d{1,5}\.\d{1,5}\.\d{1,5}(?:[-.+][A-Za-z0-9.-]{1,32})?$/.test(value));
|
|
12
|
+
const identity = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
13
|
+
export const nativeGraphStore = () => process.env.GREAT_CTO_GRAPH_BINDINGS_DIR || join(homedir(), '.great_cto', 'graph-bindings');
|
|
14
|
+
const key = (root, host, sessionId) => createHash('sha256').update(JSON.stringify([projectIdentity(root), host, sessionId])).digest('hex');
|
|
15
|
+
function secure(root, store, create = false) {
|
|
16
|
+
assertExternal(root, store);
|
|
17
|
+
let current = resolve(store);
|
|
18
|
+
while (current !== dirname(current)) {
|
|
19
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink()) throw Error('graph binding store has a symbolic link');
|
|
20
|
+
current = dirname(current);
|
|
21
|
+
}
|
|
22
|
+
if (create) mkdirSync(store, { recursive: true, mode: 0o700 });
|
|
23
|
+
if (existsSync(store) && (!lstatSync(store).isDirectory() || (lstatSync(store).mode & 0o077) || (process.getuid && lstatSync(store).uid !== process.getuid()))) throw Error('graph bindings require private permissions');
|
|
24
|
+
}
|
|
25
|
+
function syncDirectory(store) { const fd = openSync(store, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); } }
|
|
26
|
+
function readPrivate(file) {
|
|
27
|
+
const fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
28
|
+
try {
|
|
29
|
+
const stat = fstatSync(fd);
|
|
30
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.mode & 0o077 || stat.size > 4096 || (process.getuid && stat.uid !== process.getuid())) throw Error('unsafe graph enrollment');
|
|
31
|
+
return JSON.parse(readFileSync(fd, 'utf8'));
|
|
32
|
+
} finally { closeSync(fd); }
|
|
33
|
+
}
|
|
34
|
+
function load(root, host, sessionId, store) {
|
|
35
|
+
secure(root, store);
|
|
36
|
+
const file = join(store, `${key(root, host, sessionId)}.json`);
|
|
37
|
+
if (!existsSync(file)) return null;
|
|
38
|
+
const b = readPrivate(file);
|
|
39
|
+
if (b.version !== 1 || b.root !== realpathSync(root) || b.projectId !== projectIdentity(root) || b.host !== host || b.sessionId !== sessionId || b.runId !== sessionId) throw Error('graph enrollment binding mismatch');
|
|
40
|
+
return b;
|
|
41
|
+
}
|
|
42
|
+
export function bindNativeGraph({ root, host, sessionId, sourceVersion = null }, { store = nativeGraphStore() } = {}) {
|
|
43
|
+
if (!hostSet.has(host) || !identity(sessionId) || !validVersion(sourceVersion)) throw Error('invalid native graph binding');
|
|
44
|
+
secure(root, store, true);
|
|
45
|
+
const binding = { version: 1, ...graphBinding({ root: realpathSync(root), runId: sessionId, sessionId }), host, sourceVersion };
|
|
46
|
+
const file = join(store, `${key(root, host, sessionId)}.json`), tmp = `${file}.${process.pid}.tmp`;
|
|
47
|
+
if (existsSync(file)) { load(root, host, sessionId, store); throw Error('session already enrolled; remove binding before changing capabilities'); }
|
|
48
|
+
try { writeFileSync(tmp, JSON.stringify(binding), { mode: 0o600, flag: 'wx' });
|
|
49
|
+
const fd = openSync(tmp, constants.O_RDONLY); try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
50
|
+
linkSync(tmp, file); }
|
|
51
|
+
finally { rmSync(tmp, { force: true }); syncDirectory(store); }
|
|
52
|
+
return { state: 'enrolled', host, sessionId, projectId: binding.projectId, authority: 'observation-only' };
|
|
53
|
+
}
|
|
54
|
+
export function unbindNativeGraph({ root, host, sessionId }, { store = nativeGraphStore() } = {}) {
|
|
55
|
+
if (!hostSet.has(host) || !identity(sessionId)) throw Error('invalid native graph binding');
|
|
56
|
+
const b = load(root, host, sessionId, store);
|
|
57
|
+
if (b) { rmSync(join(store, `${key(root, host, sessionId)}.json`)); syncDirectory(store); }
|
|
58
|
+
return { state: b ? 'unenrolled' : 'absent', authority: 'observation-only' };
|
|
59
|
+
}
|
|
60
|
+
export function observeNativeGraphHook({ root, host, event }, { store = nativeGraphStore(), journalStore } = {}) {
|
|
61
|
+
const sessionId = event?.session_id;
|
|
62
|
+
if (!hostSet.has(host) || !identity(sessionId)) return { state: 'ignored' };
|
|
63
|
+
let b = load(root, host, sessionId, store);
|
|
64
|
+
if (!b && event.hook_event_name === 'SessionStart') {
|
|
65
|
+
const policyFile = join(store, `${key(root, host, 'project-policy')}.policy`);
|
|
66
|
+
if (existsSync(policyFile)) {
|
|
67
|
+
const policy = readPrivate(policyFile);
|
|
68
|
+
if (policy.version !== 1 || policy.projectId !== projectIdentity(root) || policy.root !== realpathSync(root) || policy.host !== host || policy.enabled !== true) throw Error('invalid graph observation policy');
|
|
69
|
+
bindNativeGraph({ root, host, sessionId, sourceVersion: policy.sourceVersion }, { store });
|
|
70
|
+
b = load(root, host, sessionId, store);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!b) return { state: 'not-enrolled' };
|
|
74
|
+
const turnHook = ['UserPromptSubmit', 'Stop'].includes(event.hook_event_name);
|
|
75
|
+
const sourceIdentityPresent = host === 'codex' && turnHook ? !!codexNativeTurnIdentity(event) : !!event.event_id;
|
|
76
|
+
if ((turnHook || (event.hook_event_name === 'SessionStart' && event.source === 'resume')) && !sourceIdentityPresent) {
|
|
77
|
+
// Retain each received delivery without pretending it has a replay-safe host ID.
|
|
78
|
+
b = { ...b, sourceEventId: randomUUID() };
|
|
79
|
+
recordHostGraphGap(b, host, 'native', ['missing-source-event-identity'], { store: journalStore });
|
|
80
|
+
}
|
|
81
|
+
if (['SubagentStart', 'SubagentStop'].includes(event.hook_event_name)) {
|
|
82
|
+
const roots = readHostGraph(b, { store: journalStore }).events.filter(e => e.host === host && e.kind === 'session' && e.phase === 'started');
|
|
83
|
+
if (roots.length) b = { ...b, parentIds: [roots[0].id] }; // Same explicitly enrolled session; containment, not dispatch order.
|
|
84
|
+
}
|
|
85
|
+
return observeHostGraph({ binding: b, host, mode: 'native', sourceVersion: b.sourceVersion, event, store: journalStore });
|
|
86
|
+
}
|
|
87
|
+
export function nativeGraphObservations(root, { store = nativeGraphStore(), journalStore } = {}) {
|
|
88
|
+
secure(root, store);
|
|
89
|
+
if (!existsSync(store)) return [];
|
|
90
|
+
const rows = [];
|
|
91
|
+
// Enrollment files are bounded and read only after exact project matching.
|
|
92
|
+
const names = readdirSync(store).filter(n => /^[a-f0-9]{64}\.json$/.test(n));
|
|
93
|
+
if (names.length > 4096) throw Error('native graph enrollment read limit');
|
|
94
|
+
for (const name of names) {
|
|
95
|
+
const file = join(store, name);
|
|
96
|
+
const candidate = readPrivate(file);
|
|
97
|
+
if (candidate.projectId !== projectIdentity(root)) continue;
|
|
98
|
+
if (!hostSet.has(candidate.host) || !identity(candidate.sessionId)) throw Error('invalid graph enrollment');
|
|
99
|
+
const b = load(root, candidate.host, candidate.sessionId, store);
|
|
100
|
+
if (name !== `${key(root, b.host, b.sessionId)}.json`) throw Error('invalid graph enrollment filename');
|
|
101
|
+
rows.push({ host: b.host, sessionId: b.sessionId, runId: b.runId,
|
|
102
|
+
graph: projectHostObservations(b, { store: journalStore }) });
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Bounded page of exactly one explicitly enrolled session. */
|
|
108
|
+
export function readNativeGraphPage({ root, host, sessionId, cursor = null, limit = 200 }, { store = nativeGraphStore(), journalStore } = {}) {
|
|
109
|
+
if (!hostSet.has(host) || !identity(sessionId)) throw Error('invalid native graph binding');
|
|
110
|
+
const b = load(root, host, sessionId, store);
|
|
111
|
+
if (!b) throw Error('native session is not enrolled in this project');
|
|
112
|
+
return projectHostObservations(b, { store: journalStore, cursor, limit });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Project opt-in before SessionStart permits observation from the real start event. */
|
|
116
|
+
export function enableNativeGraph({ root, host, sourceVersion = null }, { store = nativeGraphStore() } = {}) {
|
|
117
|
+
if (!hostSet.has(host) || !validVersion(sourceVersion)) throw Error('invalid graph observation policy');
|
|
118
|
+
secure(root, store, true);
|
|
119
|
+
const file = join(store, `${key(root, host, 'project-policy')}.policy`);
|
|
120
|
+
const body = { version: 1, projectId: projectIdentity(root), root: realpathSync(root), host, sourceVersion, enabled: true };
|
|
121
|
+
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
122
|
+
try { writeFileSync(fd, JSON.stringify(body)); fsyncSync(fd); } finally { closeSync(fd); }
|
|
123
|
+
syncDirectory(store);
|
|
124
|
+
return { state: 'enabled', host, projectId: body.projectId, scope: 'new native sessions in this exact project', authority: 'observation-only' };
|
|
125
|
+
}
|
|
126
|
+
export function disableNativeGraph({ root, host }, { store = nativeGraphStore() } = {}) {
|
|
127
|
+
if (!hostSet.has(host)) throw Error('invalid graph host');
|
|
128
|
+
secure(root, store);
|
|
129
|
+
const file = join(store, `${key(root, host, 'project-policy')}.policy`);
|
|
130
|
+
if (existsSync(file)) { const p = readPrivate(file); if (p.projectId !== projectIdentity(root) || p.host !== host) throw Error('graph policy mismatch'); rmSync(file); }
|
|
131
|
+
// Revocation also stops already-enrolled sessions for this host/project.
|
|
132
|
+
for (const row of nativeGraphObservations(root, { store })) if (row.host === host) unbindNativeGraph({ root, host, sessionId: row.sessionId }, { store });
|
|
133
|
+
if (existsSync(store)) syncDirectory(store);
|
|
134
|
+
return { state: 'disabled', authority: 'observation-only', retainedHistory: true };
|
|
135
|
+
}
|