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.
- package/README.md +9 -1
- package/board/.claude-plugin/plugin.json +93 -1
- package/board/agents/architect.md +1 -1
- package/board/agents/db-migration-reviewer.md +3 -2
- package/board/agents/design-advisor.md +1 -1
- package/board/agents/devops.md +1 -1
- package/board/agents/growth-engineer.md +1 -1
- package/board/agents/knowledge-extractor.md +1 -1
- package/board/agents/l3-support.md +1 -1
- package/board/agents/performance-engineer.md +1 -1
- package/board/agents/pm.md +5 -1
- package/board/agents/qa-engineer.md +4 -4
- package/board/agents/security-officer.md +2 -2
- package/board/agents/smart-contract-auditor.md +1 -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/approval-level.mjs +4 -2
- 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/board/shared/pipeline.toml +7 -1
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|