great-cto 3.70.1 → 3.72.0

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 (48) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/agents/architect.md +1 -1
  4. package/board/agents/db-migration-reviewer.md +3 -2
  5. package/board/agents/design-advisor.md +1 -1
  6. package/board/agents/devops.md +1 -1
  7. package/board/agents/growth-engineer.md +1 -1
  8. package/board/agents/knowledge-extractor.md +1 -1
  9. package/board/agents/l3-support.md +1 -1
  10. package/board/agents/performance-engineer.md +1 -1
  11. package/board/agents/pm.md +5 -1
  12. package/board/agents/qa-engineer.md +4 -4
  13. package/board/agents/security-officer.md +2 -2
  14. package/board/agents/smart-contract-auditor.md +1 -1
  15. package/board/packages/board/lib/routes.mjs +14 -1
  16. package/board/packages/board/lib/work.mjs +34 -3
  17. package/board/packages/board/public/assets/work-view.js +146 -18
  18. package/board/scripts/codex-pipeline.mjs +2 -0
  19. package/board/scripts/lib/approval-level.mjs +4 -2
  20. package/board/scripts/lib/claude-exec.mjs +49 -6
  21. package/board/scripts/lib/codex-exec.mjs +42 -18
  22. package/board/scripts/lib/codex-host-state.mjs +18 -2
  23. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  24. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  25. package/board/scripts/lib/host-graph-events.mjs +157 -0
  26. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  27. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  28. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  29. package/board/scripts/work-task.mjs +17 -4
  30. package/board/shared/pipeline.toml +7 -1
  31. package/dist/adapt.js +10 -21
  32. package/dist/codex.js +4 -15
  33. package/dist/companion.js +6 -122
  34. package/dist/installer.js +4 -72
  35. package/dist/main.js +44 -117
  36. package/dist/release-activation.mjs +561 -0
  37. package/dist/release-archive.mjs +165 -0
  38. package/dist/release-trust.mjs +514 -0
  39. package/dist/release-update.mjs +303 -0
  40. package/dist/self-upgrade.js +10 -39
  41. package/dist/update-check.js +1 -0
  42. package/dist/upgrade.js +5 -120
  43. package/package.json +1 -1
  44. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  45. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  46. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  47. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  48. package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
@@ -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
+ }
@@ -0,0 +1,157 @@
1
+ /** Host observations are metadata, never controller approval or execution authority. */
2
+ import { createHash } from 'node:crypto';
3
+
4
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ const safeId = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value) ? value : null;
6
+ const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
7
+ const version = value => typeof value === 'string' && /^\d{1,5}(?:\.\d{1,5}){0,3}(?:[-+][a-zA-Z0-9.-]{1,32})?$/.test(value) ? value : null;
8
+ const hosts = new Set(['codex', 'claude-code']);
9
+ const modes = new Set(['controlled', 'native']);
10
+ const timestamp = value => typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
11
+
12
+ /** Supported event families do not attest that a particular run was fully observed. */
13
+ export function hostGraphCapabilities({ host, mode, sourceVersion } = {}) {
14
+ const known = false; // Version compatibility is established by the collector's capability audit, never semver syntax alone.
15
+ return { lifecycle: known ? 'supported' : 'unknown', tools: known ? 'supported' : 'unknown',
16
+ parentChild: known ? 'supported' : 'unknown', verification: 'unsupported', gates: 'unsupported', artifacts: 'unsupported' };
17
+ }
18
+
19
+ function toolClass(name) {
20
+ if (typeof name !== 'string') return 'other';
21
+ if (/^(?:mcp__|mcp:)/.test(name)) return 'mcp';
22
+ const classes = { Bash: 'shell', shell: 'shell', command_execution: 'shell', Read: 'read', read_file: 'read',
23
+ Edit: 'write', Write: 'write', MultiEdit: 'write', apply_patch: 'write', file_change: 'write',
24
+ Glob: 'search', Grep: 'search', WebSearch: 'search', web_search: 'search',
25
+ Agent: 'agent', Task: 'agent', collab_tool_call: 'agent', mcp_tool_call: 'mcp' };
26
+ return Object.hasOwn(classes, name) ? classes[name] : 'other';
27
+ }
28
+ const opaque = value => typeof value === 'string' && value.length > 0 && value.length <= 512 ? hash(value) : null;
29
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
30
+ export function codexNativeTurnIdentity(raw) {
31
+ if (!['UserPromptSubmit', 'Stop'].includes(raw?.hook_event_name)) return null;
32
+ return safeId(raw.event_id) || safeId(raw.turn_id);
33
+ }
34
+ const codexTrackedItems = new Set(['command_execution', 'file_change', 'mcp_tool_call', 'web_search', 'collab_tool_call']);
35
+ function ignoredClaudeBlock(block) {
36
+ if (!record(block)) return false;
37
+ return (block.type === 'text' && typeof block.text === 'string')
38
+ || (block.type === 'thinking' && typeof block.thinking === 'string')
39
+ || (block.type === 'redacted_thinking' && typeof block.data === 'string');
40
+ }
41
+ function ignoredPayload(host, mode, raw, sourceVersion) {
42
+ if (host === 'codex' && !(mode === 'native' && raw.hook_event_name)) {
43
+ if (!['item.started', 'item.updated', 'item.completed'].includes(raw.type) || !record(raw.item) || !opaque(raw.item.id)) return false;
44
+ const item = raw.item;
45
+ if (raw.type === 'item.updated' && codexTrackedItems.has(item.type)) return true;
46
+ if (['agent_message', 'reasoning'].includes(item.type)) return typeof item.text === 'string';
47
+ return item.type === 'todo_list' && Array.isArray(item.items)
48
+ && item.items.every(value => record(value) && typeof value.text === 'string' && typeof value.completed === 'boolean');
49
+ }
50
+ if (host !== 'claude-code') return false;
51
+ // Audited 2.1.292 schema: this push invalidates rendered UI trees only.
52
+ // Keep future versions, instance variants and extra structural fields visible
53
+ // as gaps until independently classified; subtype alone is not evidence.
54
+ if (mode === 'controlled' && sourceVersion === '2.1.292' && raw.type === 'system' && raw.subtype === 'ui_invalidate'
55
+ && raw.event === 'ui.render' && uuid(raw.uuid) && uuid(raw.session_id)
56
+ && Object.keys(raw).length === 5
57
+ && Object.keys(raw).every(key => ['type', 'subtype', 'event', 'uuid', 'session_id'].includes(key))) return true;
58
+ if (['assistant', 'user'].includes(raw.type)) {
59
+ const content = raw.message?.content;
60
+ return Array.isArray(content) && content.length > 0 && content.every(ignoredClaudeBlock);
61
+ }
62
+ // These advisory frames carry no graph lifecycle or causal relationship.
63
+ if (raw.type === 'system' && raw.subtype === 'status') return raw.status === null || raw.status === 'compacting';
64
+ if (raw.type === 'system' && raw.subtype === 'compact_boundary') return record(raw.compact_metadata)
65
+ && ['manual', 'auto'].includes(raw.compact_metadata.trigger) && Number.isSafeInteger(raw.compact_metadata.pre_tokens) && raw.compact_metadata.pre_tokens >= 0;
66
+ return raw.type === 'rate_limit_event' && record(raw.rate_limit_info)
67
+ && ['allowed', 'allowed_warning', 'rejected'].includes(raw.rate_limit_info.status);
68
+ }
69
+ function codexEvent(raw) {
70
+ if (raw.type === 'thread.started') return { kind: 'session', phase: 'started', source: raw.thread_id };
71
+ if (raw.type === 'turn.started') return { kind: 'turn', phase: 'started' };
72
+ if (raw.type === 'turn.completed') return { kind: 'turn', phase: 'completed', outcome: 'succeeded' };
73
+ if (raw.type === 'turn.failed') return { kind: 'turn', phase: 'failed', outcome: 'failed' };
74
+ if (raw.type === 'error') return { kind: 'error', phase: 'failed', outcome: 'failed' };
75
+ if (!['item.started', 'item.completed'].includes(raw.type) || !raw.item || typeof raw.item !== 'object') return null;
76
+ const item = raw.item;
77
+ if (!codexTrackedItems.has(item.type)) return null;
78
+ const status = String(item.status || '').toLowerCase();
79
+ const started = raw.type === 'item.started';
80
+ const outcome = started ? 'unknown' : ['failed', 'errored'].includes(status) || (Number.isInteger(item.exit_code) && item.exit_code !== 0) ? 'failed'
81
+ : ['declined', 'cancelled', 'canceled', 'interrupted'].includes(status) ? 'cancelled' : status === 'completed' ? 'succeeded' : 'unknown';
82
+ return { kind: item.type === 'collab_tool_call' ? 'child' : 'tool', phase: started ? 'started' : outcome === 'failed' ? 'failed' : 'completed',
83
+ outcome, source: item.id, toolClass: toolClass(item.type) };
84
+ }
85
+ function claudeEvent(raw) {
86
+ const hook = raw.hook_event_name;
87
+ const hooks = {
88
+ SessionStart: { kind: 'session', phase: 'started' }, SessionEnd: { kind: 'session', phase: 'stopped' },
89
+ UserPromptSubmit: { kind: 'turn', phase: 'started' }, Stop: { kind: 'turn', phase: 'stopped' },
90
+ PreToolUse: { kind: 'tool', phase: 'started' }, PostToolUse: { kind: 'tool', phase: 'completed', outcome: 'succeeded' },
91
+ PostToolUseFailure: { kind: 'tool', phase: 'failed', outcome: 'failed' },
92
+ SubagentStart: { kind: 'child', phase: 'started' }, SubagentStop: { kind: 'child', phase: 'stopped' },
93
+ };
94
+ if (Object.hasOwn(hooks, hook || '')) return { ...hooks[hook], source: raw.tool_use_id || raw.agent_id || raw.event_id,
95
+ toolClass: hooks[hook].kind === 'tool' ? toolClass(raw.tool_name) : hooks[hook].kind === 'child' ? 'agent' : null };
96
+ if (raw.type === 'system' && raw.subtype === 'init') return { kind: 'session', phase: 'started', source: raw.session_id };
97
+ if (raw.type === 'result') return { kind: 'turn', phase: raw.is_error === true ? 'failed' : 'completed',
98
+ outcome: raw.is_error === true ? 'failed' : raw.is_error === false || raw.subtype === 'success' ? 'succeeded' : 'unknown', source: raw.uuid };
99
+ if (!['assistant', 'user'].includes(raw.type)) return null;
100
+ const content = raw.message?.content;
101
+ if (!Array.isArray(content) || content.length !== 1 || !record(content[0])) return null;
102
+ const b = content[0];
103
+ if (b.type === 'tool_use' ? !opaque(b.id) || typeof b.name !== 'string' || !b.name.length
104
+ : b.type === 'tool_result' ? !opaque(b.tool_use_id) || (b.is_error !== undefined && typeof b.is_error !== 'boolean') : true) return null;
105
+ return b.type === 'tool_use' ? { kind: 'tool', phase: 'started', source: b.id, toolClass: toolClass(b.name) }
106
+ : { kind: 'tool', phase: b.is_error === true ? 'failed' : 'completed', outcome: b.is_error === true ? 'failed' : b.is_error === false ? 'succeeded' : 'unknown', source: b.tool_use_id, toolClass: 'other' };
107
+ }
108
+
109
+ /** Bindings come from the operator-owned collector, not fields inside the host payload. */
110
+ export function normalizeHostEvent({ host, mode, sourceVersion, event: raw, binding } = {}) {
111
+ const capabilities = hostGraphCapabilities({ host, mode, sourceVersion });
112
+ const rejected = reason => ({ event: null, diagnostics: [reason], capabilities });
113
+ if (!hosts.has(host) || !modes.has(mode)) return rejected('unsupported-host-or-mode');
114
+ if (!binding || !safeId(binding.projectId) || !safeId(binding.runId) || !safeId(binding.sessionId)
115
+ || (binding.attemptId != null && !safeId(binding.attemptId))) return rejected('invalid-trusted-binding');
116
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return rejected('invalid-event');
117
+ if (ignoredPayload(host, mode, raw, sourceVersion)) return { event: null, diagnostics: [], capabilities };
118
+ const hookShape = mode === 'native' && typeof raw.hook_event_name === 'string';
119
+ const parsed = host === 'codex' && !hookShape ? codexEvent(raw) : claudeEvent(raw);
120
+ if (!parsed) return rejected('unsupported-event-shape');
121
+ if (host === 'codex' && hookShape) {
122
+ // Codex PostToolUse also fires for failed calls; output content is not a status.
123
+ if (raw.hook_event_name === 'PostToolUse') parsed.outcome = 'unknown';
124
+ if (['UserPromptSubmit', 'Stop'].includes(raw.hook_event_name)) parsed.source = codexNativeTurnIdentity(raw);
125
+ }
126
+ const parents = binding.parentIds ?? [];
127
+ if (!Array.isArray(parents) || parents.length > 16 || parents.some(p => !safeId(p))) return rejected('invalid-trusted-parents');
128
+ const diagnostics = [];
129
+ if (parsed.kind === 'child' && !parents.length) diagnostics.push('missing-explicit-parent');
130
+ const sequence = Number.isSafeInteger(binding.sequence) && binding.sequence >= 0 ? binding.sequence : null;
131
+ const rawSourceId = binding.sourceEventId ?? parsed.source ?? binding.turnId ?? null;
132
+ const sourceEventId = opaque(rawSourceId);
133
+ if (rawSourceId != null && !sourceEventId) diagnostics.push('missing-source-event-identity');
134
+ if (!sourceEventId && sequence === null && (['tool', 'child'].includes(parsed.kind) || (parsed.kind === 'turn' && binding.attemptId == null))) diagnostics.push('missing-source-event-identity');
135
+ if (!version(sourceVersion)) diagnostics.push('source-version-unverified');
136
+ const event = { schemaVersion: 1, projectId: binding.projectId, runId: binding.runId, sessionId: binding.sessionId,
137
+ attemptId: binding.attemptId ?? null, host, mode, sourceVersion: version(sourceVersion), sourceEventId, sequence,
138
+ kind: parsed.kind, phase: parsed.phase, outcome: parsed.outcome || 'unknown', parentIds: [...new Set(parents)],
139
+ occurredAt: timestamp(raw.timestamp ?? raw.at), toolClass: parsed.toolClass ?? null,
140
+ callId: ['tool', 'child'].includes(parsed.kind) ? opaque(parsed.source ?? binding.sourceEventId ?? null) : null };
141
+ // Collector wall clock is excluded: replaying an identical source event is idempotent.
142
+ return { event: { ...event, id: hash(event), observedAt: new Date().toISOString() }, diagnostics: [...new Set(diagnostics)], capabilities };
143
+ }
144
+
145
+ /** A streamed Claude message may carry several independent tool blocks. */
146
+ export function normalizeHostEvents(input = {}) {
147
+ const content = input.event?.message?.content;
148
+ if (input.host !== 'claude-code' || !['assistant', 'user'].includes(input.event?.type) || !Array.isArray(content)) {
149
+ const result = normalizeHostEvent(input);
150
+ return { events: result.event ? [result.event] : [], diagnostics: result.diagnostics, capabilities: result.capabilities };
151
+ }
152
+ if (!content.length) return { events: [], diagnostics: ['unsupported-event-shape'], capabilities: hostGraphCapabilities(input) };
153
+ const results = content.map(block => normalizeHostEvent({ ...input,
154
+ event: { ...input.event, message: { content: [block] } } }));
155
+ return { events: results.flatMap(r => r.event ? [r.event] : []),
156
+ diagnostics: [...new Set(results.flatMap(r => r.diagnostics))], capabilities: hostGraphCapabilities(input) };
157
+ }