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.
Files changed (34) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/packages/board/lib/routes.mjs +14 -1
  4. package/board/packages/board/lib/work.mjs +34 -3
  5. package/board/packages/board/public/assets/work-view.js +146 -18
  6. package/board/scripts/codex-pipeline.mjs +2 -0
  7. package/board/scripts/lib/claude-exec.mjs +49 -6
  8. package/board/scripts/lib/codex-exec.mjs +42 -18
  9. package/board/scripts/lib/codex-host-state.mjs +18 -2
  10. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  11. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  12. package/board/scripts/lib/host-graph-events.mjs +157 -0
  13. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  14. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  15. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  16. package/board/scripts/work-task.mjs +17 -4
  17. package/dist/adapt.js +10 -21
  18. package/dist/codex.js +4 -15
  19. package/dist/companion.js +6 -122
  20. package/dist/installer.js +4 -72
  21. package/dist/main.js +44 -117
  22. package/dist/release-activation.mjs +561 -0
  23. package/dist/release-archive.mjs +165 -0
  24. package/dist/release-trust.mjs +514 -0
  25. package/dist/release-update.mjs +303 -0
  26. package/dist/self-upgrade.js +10 -39
  27. package/dist/update-check.js +1 -0
  28. package/dist/upgrade.js +5 -120
  29. package/package.json +1 -1
  30. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  31. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  32. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  33. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  34. 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 args = ['--print', '--output-format', 'json', '--no-session-persistence',
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.on('data', bytes => { out += String(bytes); });
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
- const parsed = parseClaudeResult(out);
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 (timedOut) { parsed.state = 'unreadable'; parsed.errors.push(`timed out after ${timeoutMs}ms`); }
72
- finish({ ...parsed, code, timedOut });
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
- // Each JSON event is handed to onEvent as it arrives, so a watcher sees a tool
143
- // call while the run is still going. A listener that throws is its own bug:
144
- // it must not fail the run, and the final parse below is unchanged.
145
- let pending = '';
146
- proc.stdout.on('data', (b) => {
147
- const chunk = String(b);
148
- out += chunk;
149
- if (typeof onEvent !== 'function') return;
150
- pending += chunk;
151
- let nl;
152
- while ((nl = pending.indexOf('\n')) >= 0) {
153
- const line = pending.slice(0, nl).trim();
154
- pending = pending.slice(nl + 1);
155
- if (!line.startsWith('{')) continue;
156
- let ev;
157
- try { ev = JSON.parse(line); } catch { continue; }
158
- try { onEvent(ev); } catch { /* a failing listener must not fail the run */ }
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: `${state.attempts?.findLast(a => a.role === role)?.id || randomUUID()}:verifier` });
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 result = cleanResponse(await invoke({
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
+ }