@bjoernrochel/sevro 0.1.0-rc.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 (71) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +71 -0
  3. package/docs/advisory-fixture.md +23 -0
  4. package/docs/builtin-graders-v1.md +77 -0
  5. package/docs/cache-ownership-validation.md +49 -0
  6. package/docs/claude-continuation-validation.md +47 -0
  7. package/docs/codex-host.md +134 -0
  8. package/docs/concurrency-validation.md +86 -0
  9. package/docs/configuration-root-validation.md +70 -0
  10. package/docs/development-cli.md +327 -0
  11. package/docs/engine-slice.md +114 -0
  12. package/docs/extension-protocol-v1.md +292 -0
  13. package/docs/fixture-cleanup-validation.md +70 -0
  14. package/docs/identity-v1.md +65 -0
  15. package/docs/native-controls.md +29 -0
  16. package/docs/releases.md +88 -0
  17. package/docs/report-v1.md +32 -0
  18. package/docs/results-v1.md +118 -0
  19. package/examples/basic/graded.json +13 -0
  20. package/examples/basic/host.ts +8 -0
  21. package/examples/basic/prompt-only.json +7 -0
  22. package/package.json +45 -0
  23. package/schemas/cli-result-v1.schema.json +184 -0
  24. package/schemas/extension-v1.schema.json +659 -0
  25. package/schemas/report-v1.schema.json +164 -0
  26. package/schemas/run-evidence-v1.schema.json +510 -0
  27. package/src/advisory-fixture.ts +282 -0
  28. package/src/advisory.ts +105 -0
  29. package/src/cli.ts +837 -0
  30. package/src/engine.ts +2341 -0
  31. package/src/extension-client.ts +257 -0
  32. package/src/extension-session.ts +335 -0
  33. package/src/fixture-bin.ts +87 -0
  34. package/src/fixture-cleanup.ts +38 -0
  35. package/src/fixture-setup.ts +163 -0
  36. package/src/generated-fixture.ts +220 -0
  37. package/src/git-hooks.ts +63 -0
  38. package/src/graders/git-head.ts +176 -0
  39. package/src/graders/output.ts +359 -0
  40. package/src/graders/semantic.ts +120 -0
  41. package/src/graders/shell.ts +277 -0
  42. package/src/hosts/claude-continuation.ts +94 -0
  43. package/src/hosts/claude-credential.ts +73 -0
  44. package/src/hosts/claude-events.ts +128 -0
  45. package/src/hosts/claude-nested-skills.ts +218 -0
  46. package/src/hosts/claude-repository-invocation.ts +239 -0
  47. package/src/hosts/claude-settings.ts +50 -0
  48. package/src/hosts/claude-tool-calls.ts +159 -0
  49. package/src/hosts/claude.ts +511 -0
  50. package/src/hosts/codex-configuration.ts +85 -0
  51. package/src/hosts/codex-events.ts +102 -0
  52. package/src/hosts/codex-native-calls.ts +702 -0
  53. package/src/hosts/codex-profile.ts +78 -0
  54. package/src/hosts/codex-skill-reads.ts +345 -0
  55. package/src/hosts/codex.ts +769 -0
  56. package/src/hosts/isolation-roots.ts +60 -0
  57. package/src/hosts/mac-sandbox.ts +108 -0
  58. package/src/hosts/native-controls.ts +124 -0
  59. package/src/hosts/protected-worktrees.ts +56 -0
  60. package/src/hosts/workspace-fingerprint.ts +55 -0
  61. package/src/identity.ts +156 -0
  62. package/src/instrumentation.ts +106 -0
  63. package/src/preparation.ts +211 -0
  64. package/src/provenance.ts +244 -0
  65. package/src/report.ts +351 -0
  66. package/src/repository-fixture.ts +286 -0
  67. package/src/results.ts +160 -0
  68. package/src/run-owner.ts +276 -0
  69. package/src/schema.ts +28 -0
  70. package/src/storage.ts +23 -0
  71. package/src/trial-scheduler.ts +27 -0
@@ -0,0 +1,218 @@
1
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ type Entry = Record<string, unknown>;
5
+ const MAX_BYTES = 8 * 1024 * 1024;
6
+ const MAX_AGENTS = 64;
7
+ const MAX_SKILLS = 128;
8
+
9
+ function record(value: unknown): value is Entry {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ function label(value: unknown): value is string {
14
+ return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,256}$/.test(value);
15
+ }
16
+
17
+ function identity(value: unknown): value is string {
18
+ return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
19
+ }
20
+
21
+ function blocks(entry: Entry): Entry[] {
22
+ const content = record(entry.message) ? entry.message.content : null;
23
+ return Array.isArray(content) ? content.filter(record) : [];
24
+ }
25
+
26
+ function entries(text: string): Entry[] {
27
+ return text
28
+ .split(/\r?\n/)
29
+ .filter(Boolean)
30
+ .map((line) => {
31
+ const parsed: unknown = JSON.parse(line);
32
+ if (!record(parsed)) throw new Error("invalid Claude session entry");
33
+ return parsed;
34
+ });
35
+ }
36
+
37
+ async function readSession(path: string): Promise<string> {
38
+ const file = await lstat(path);
39
+ if (!file.isFile() || file.isSymbolicLink() || file.size > MAX_BYTES)
40
+ throw new Error("Claude session file is unavailable");
41
+ return readFile(path, "utf8");
42
+ }
43
+
44
+ type NestedSkill = {
45
+ ancestorToolUseId: string;
46
+ skill: string;
47
+ invocation: string;
48
+ };
49
+
50
+ type State = {
51
+ sessionId: string;
52
+ projectDir: string;
53
+ seen: Set<string>;
54
+ calls: NestedSkill[];
55
+ };
56
+
57
+ async function sidecarChild(
58
+ state: State,
59
+ toolUseId: string,
60
+ parentAgentId: string | null,
61
+ ): Promise<string> {
62
+ const directory = join(state.projectDir, state.sessionId, "subagents");
63
+ const names = (await readdir(directory)).filter((name) =>
64
+ /^agent-[A-Za-z0-9_-]+\.meta\.json$/.test(name),
65
+ );
66
+ if (names.length > MAX_AGENTS) throw new Error("excessive Claude agents");
67
+ const matches: string[] = [];
68
+ for (const name of names) {
69
+ const metadata: unknown = JSON.parse(
70
+ await readSession(join(directory, name)),
71
+ );
72
+ if (
73
+ record(metadata) &&
74
+ metadata.toolUseId === toolUseId &&
75
+ (metadata.parentAgentId ?? null) === parentAgentId
76
+ )
77
+ matches.push(name.slice(6, -10));
78
+ }
79
+ if (matches.length !== 1 || !identity(matches[0]))
80
+ throw new Error("Claude child identity is unavailable");
81
+ return matches[0]!;
82
+ }
83
+
84
+ async function childId(
85
+ entry: Entry,
86
+ state: State,
87
+ toolUseId: string,
88
+ parentAgentId: string | null,
89
+ ): Promise<string> {
90
+ const result = record(entry.toolUseResult) ? entry.toolUseResult : null;
91
+ if (result) {
92
+ if (result.status !== "completed" || !identity(result.agentId))
93
+ throw new Error("Claude child did not complete");
94
+ return result.agentId;
95
+ }
96
+ return sidecarChild(state, toolUseId, parentAgentId);
97
+ }
98
+
99
+ async function childSession(state: State, agentId: string) {
100
+ const directory = join(state.projectDir, state.sessionId, "subagents");
101
+ const path = join(directory, `agent-${agentId}.jsonl`);
102
+ if ((await realpath(path)) !== path)
103
+ throw new Error("Claude child session is redirected");
104
+ return readSession(path);
105
+ }
106
+
107
+ function skillCall(block: Entry, ancestorToolUseId: string | null) {
108
+ const invocation = record(block.input) ? block.input.skill : null;
109
+ if (!label(invocation)) throw new Error("invalid Claude Skill call");
110
+ return ancestorToolUseId
111
+ ? {
112
+ ancestorToolUseId,
113
+ skill: invocation.split(":").at(-1)!,
114
+ invocation,
115
+ }
116
+ : null;
117
+ }
118
+
119
+ async function visit(
120
+ text: string,
121
+ state: State,
122
+ agentId: string | null,
123
+ ancestorToolUseId: string | null,
124
+ ): Promise<void> {
125
+ const pending = new Set<string>();
126
+ for (const entry of entries(text)) {
127
+ if (entry.type !== "assistant" && entry.type !== "user") continue;
128
+ if (
129
+ entry.sessionId !== state.sessionId ||
130
+ (entry.agentId ?? null) !== agentId
131
+ )
132
+ throw new Error("Claude session identity mismatch");
133
+ for (const block of blocks(entry)) {
134
+ if (entry.type === "assistant" && block.type === "tool_use") {
135
+ if (block.name === "Skill") {
136
+ const call = skillCall(block, ancestorToolUseId);
137
+ if (call) state.calls.push(call);
138
+ if (state.calls.length > MAX_SKILLS)
139
+ throw new Error("excessive Claude Skill calls");
140
+ }
141
+ if (block.name === "Agent" || block.name === "Task") {
142
+ if (!identity(block.id) || pending.has(block.id))
143
+ throw new Error("invalid Claude Agent call");
144
+ pending.add(block.id);
145
+ }
146
+ }
147
+ if (
148
+ entry.type === "user" &&
149
+ block.type === "tool_result" &&
150
+ identity(block.tool_use_id) &&
151
+ pending.has(block.tool_use_id)
152
+ ) {
153
+ if (block.is_error === true)
154
+ throw new Error("Claude child call failed");
155
+ const child = await childId(entry, state, block.tool_use_id, agentId);
156
+ if (state.seen.has(child) || state.seen.size >= MAX_AGENTS)
157
+ throw new Error("repeated Claude child");
158
+ state.seen.add(child);
159
+ await visit(
160
+ await childSession(state, child),
161
+ state,
162
+ child,
163
+ ancestorToolUseId ?? block.tool_use_id,
164
+ );
165
+ pending.delete(block.tool_use_id);
166
+ }
167
+ }
168
+ }
169
+ if (pending.size) throw new Error("incomplete Claude Agent graph");
170
+ }
171
+
172
+ export async function claudeNestedSkillsObservation(
173
+ stream: string,
174
+ configRoot: string,
175
+ workspace: string,
176
+ ) {
177
+ const unavailable = {
178
+ id: "sevro.claude.nested-skills" as const,
179
+ completeness: "partial" as const,
180
+ data: { method: "native_session_graph", calls: [] as NestedSkill[] },
181
+ };
182
+ try {
183
+ const sessions = [
184
+ ...new Set(
185
+ entries(stream)
186
+ .filter((entry) => entry.type === "result")
187
+ .map((entry) => entry.session_id),
188
+ ),
189
+ ];
190
+ if (sessions.length !== 1 || !identity(sessions[0])) return unavailable;
191
+ const canonicalWorkspace = await realpath(workspace);
192
+ const canonicalConfigRoot = await realpath(configRoot);
193
+ const projectDir = join(
194
+ canonicalConfigRoot,
195
+ "projects",
196
+ canonicalWorkspace.replace(/[^A-Za-z0-9]/g, "-"),
197
+ );
198
+ const state: State = {
199
+ sessionId: sessions[0],
200
+ projectDir,
201
+ seen: new Set(),
202
+ calls: [],
203
+ };
204
+ await visit(
205
+ await readSession(join(projectDir, `${state.sessionId}.jsonl`)),
206
+ state,
207
+ null,
208
+ null,
209
+ );
210
+ return {
211
+ id: "sevro.claude.nested-skills" as const,
212
+ completeness: "complete" as const,
213
+ data: { method: "native_session_graph", calls: state.calls },
214
+ };
215
+ } catch {
216
+ return unavailable;
217
+ }
218
+ }
@@ -0,0 +1,239 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { HostAdapter } from "../engine";
4
+ import type { ClaudeToolCallsObservation } from "./claude-tool-calls";
5
+
6
+ type Entry = Record<string, unknown>;
7
+ export interface ClaudeRepositoryInvocation {
8
+ skillName: string;
9
+ skillDir: string;
10
+ skillText: string;
11
+ prompt: string;
12
+ }
13
+
14
+ function record(value: unknown): value is Entry {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16
+ }
17
+
18
+ function entries(text: string): Entry[] {
19
+ return text
20
+ .split(/\r?\n/)
21
+ .filter((line) => line.trim())
22
+ .map((line) => {
23
+ const value: unknown = JSON.parse(line);
24
+ if (!record(value)) throw new Error("invalid Claude session entry");
25
+ return value;
26
+ });
27
+ }
28
+
29
+ function content(entry: Entry): string {
30
+ const blocks = record(entry.message) ? entry.message.content : null;
31
+ if (typeof blocks === "string") return blocks;
32
+ if (!Array.isArray(blocks)) return "";
33
+ return blocks
34
+ .filter(record)
35
+ .filter((block) => block.type === "text")
36
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
37
+ .join("\n");
38
+ }
39
+
40
+ /** Validate the native project mount before candidate execution. */
41
+ export async function verifyClaudeRepositoryInvocation(
42
+ request: Parameters<HostAdapter["run"]>[0],
43
+ ): Promise<ClaudeRepositoryInvocation> {
44
+ const selected = request.explicitSkillInvocation;
45
+ if (!selected || selected.scope !== "repository")
46
+ throw new Error("Claude repository invocation is not declared");
47
+ const { skillName, token } = selected;
48
+ if (
49
+ !/^[A-Za-z0-9._-]+$/.test(skillName) ||
50
+ [".", ".."].includes(skillName) ||
51
+ token !== `/${skillName}` ||
52
+ request.prompt.split(token).length - 1 !== 1 ||
53
+ (request.prompt !== token &&
54
+ !request.prompt.startsWith(token + " ") &&
55
+ !request.prompt.startsWith(token + "\n"))
56
+ )
57
+ throw new Error("invalid Claude repository skill invocation");
58
+ let path = await realpath(request.workspace);
59
+ for (const [index, part] of [
60
+ ".claude",
61
+ "skills",
62
+ skillName,
63
+ "SKILL.md",
64
+ ].entries()) {
65
+ path = join(path, part);
66
+ const entry = await lstat(path).catch(() => null);
67
+ if (
68
+ !entry ||
69
+ entry.isSymbolicLink() ||
70
+ (index === 3
71
+ ? !entry.isFile() || entry.size > 1024 * 1024
72
+ : !entry.isDirectory())
73
+ )
74
+ throw new Error(
75
+ "invoked Claude repository skill is unavailable or unsafe",
76
+ );
77
+ }
78
+ return {
79
+ skillName,
80
+ skillDir: join(path, ".."),
81
+ skillText: await readFile(path, "utf8"),
82
+ prompt: request.prompt,
83
+ };
84
+ }
85
+
86
+ /** Match one exact command and complete body before the assistant starts. */
87
+ export function matchClaudeRepositoryInvocation(
88
+ input: ClaudeRepositoryInvocation & {
89
+ transcript: string;
90
+ sessionId: string;
91
+ },
92
+ ): { accepted: boolean | null; reason: string } {
93
+ let native: Entry[];
94
+ try {
95
+ native = entries(input.transcript);
96
+ } catch {
97
+ return { accepted: null, reason: "malformed native session" };
98
+ }
99
+ const firstAssistant = native.findIndex(
100
+ (entry) => entry.type === "assistant",
101
+ );
102
+ if (firstAssistant < 0)
103
+ return { accepted: null, reason: "missing assistant turn" };
104
+ const assistant = native[firstAssistant]!;
105
+ if (assistant.sessionId !== input.sessionId || assistant.isSidechain === true)
106
+ return { accepted: false, reason: "assistant session differs" };
107
+ const before = native.slice(0, firstAssistant);
108
+ const token = `/${input.skillName}`;
109
+ if (
110
+ input.prompt !== token &&
111
+ !input.prompt.startsWith(token + " ") &&
112
+ !input.prompt.startsWith(token + "\n")
113
+ )
114
+ return { accepted: false, reason: "prompt differs from mounted command" };
115
+ const args = input.prompt.slice(token.length).trim();
116
+ const command =
117
+ `<command-message>${input.skillName}</command-message>\n<command-name>${token}</command-name>` +
118
+ (args ? `\n<command-args>${args}</command-args>` : "");
119
+ const commands = before.filter(
120
+ (entry) =>
121
+ entry.type === "user" &&
122
+ content(entry).includes(`<command-name>${token}</command-name>`),
123
+ );
124
+ if (commands.length !== 1)
125
+ return { accepted: false, reason: "missing or duplicate native command" };
126
+ const called = commands[0]!;
127
+ if (
128
+ called.sessionId !== input.sessionId ||
129
+ called.isMeta === true ||
130
+ called.isSidechain === true ||
131
+ content(called) !== command
132
+ )
133
+ return { accepted: false, reason: "command session or arguments differ" };
134
+ const body = input.skillText
135
+ .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
136
+ .trim();
137
+ if (!body) return { accepted: null, reason: "empty mounted body" };
138
+ const prefix = `Base directory for this skill: ${input.skillDir}\n\n${body}`;
139
+ const expanded = before.filter(
140
+ (entry) =>
141
+ entry.type === "user" &&
142
+ (content(entry) === prefix || content(entry).startsWith(prefix + "\n")),
143
+ );
144
+ if (expanded.length !== 1)
145
+ return {
146
+ accepted: false,
147
+ reason: "missing, partial, or duplicate mounted body",
148
+ };
149
+ const loaded = expanded[0]!;
150
+ if (
151
+ loaded.sessionId !== input.sessionId ||
152
+ loaded.isMeta !== true ||
153
+ loaded.isSidechain === true ||
154
+ before.indexOf(loaded) <= before.indexOf(called)
155
+ )
156
+ return { accepted: false, reason: "mounted body is uncorrelated" };
157
+ return {
158
+ accepted: true,
159
+ reason: "native command and complete mounted body matched",
160
+ };
161
+ }
162
+
163
+ /** Read only the completed bound native session; retain no arguments or body. */
164
+ export async function claudeRepositoryInvocationObservation(input: {
165
+ invocation: ClaudeRepositoryInvocation;
166
+ stream: string;
167
+ configRoot: string;
168
+ workspace: string;
169
+ tools: ClaudeToolCallsObservation;
170
+ }) {
171
+ let receipt: { accepted: boolean | null; reason: string } = {
172
+ accepted: null,
173
+ reason: "native session evidence unavailable",
174
+ };
175
+ try {
176
+ const sessions = [
177
+ ...new Set(
178
+ entries(input.stream)
179
+ .filter(
180
+ (entry) =>
181
+ entry.type === "result" ||
182
+ (entry.type === "system" && entry.subtype === "init"),
183
+ )
184
+ .map((entry) => entry.session_id),
185
+ ),
186
+ ];
187
+ if (
188
+ sessions.length === 1 &&
189
+ typeof sessions[0] === "string" &&
190
+ /^[A-Za-z0-9_-]{1,128}$/.test(sessions[0])
191
+ ) {
192
+ const workspace = await realpath(input.workspace);
193
+ const path = join(
194
+ await realpath(input.configRoot),
195
+ "projects",
196
+ workspace.replace(/[^A-Za-z0-9]/g, "-"),
197
+ `${sessions[0]}.jsonl`,
198
+ );
199
+ const entry = await lstat(path);
200
+ if (
201
+ entry.isFile() &&
202
+ !entry.isSymbolicLink() &&
203
+ entry.size <= 8 * 1024 * 1024 &&
204
+ (await realpath(path)) === path
205
+ ) {
206
+ receipt = matchClaudeRepositoryInvocation({
207
+ ...input.invocation,
208
+ sessionId: sessions[0],
209
+ transcript: await readFile(path, "utf8"),
210
+ });
211
+ }
212
+ }
213
+ } catch {
214
+ /* Unavailable or malformed native evidence stays partial. */
215
+ }
216
+ const observed = input.tools.data.calls
217
+ .filter((call) => call.name === "Skill")
218
+ .map((call) => call.skill);
219
+ const observedSkills = [
220
+ ...new Set([
221
+ ...(receipt.accepted === true ? [input.invocation.skillName] : []),
222
+ ...observed,
223
+ ]),
224
+ ];
225
+ return {
226
+ id: "sevro.claude.repository-invocation" as const,
227
+ completeness:
228
+ receipt.accepted === true && input.tools.completeness === "complete"
229
+ ? ("complete" as const)
230
+ : ("partial" as const),
231
+ data: {
232
+ method: "native_repository_command",
233
+ ...receipt,
234
+ skill: input.invocation.skillName,
235
+ primarySkill: observedSkills[0] ?? null,
236
+ observedSkills,
237
+ },
238
+ };
239
+ }
@@ -0,0 +1,50 @@
1
+ import { isAbsolute } from "node:path";
2
+
3
+ function absoluteRule(root: string, tool: "Read" | "Edit"): string {
4
+ if (!isAbsolute(root) || /[\r\n()]/.test(root))
5
+ throw new Error("Claude protected path is invalid");
6
+ return `${tool}(/${root}/**)`;
7
+ }
8
+
9
+ /** Keep agent tools away from the CLI's private state while core auth works. */
10
+ export function claudeHostSettings(
11
+ privateRoot: string,
12
+ credentialFile: string,
13
+ pluginRoots: string[] = [],
14
+ protectedRoots: string[] = [],
15
+ ): Record<string, unknown> {
16
+ if (
17
+ !isAbsolute(privateRoot) ||
18
+ !isAbsolute(credentialFile) ||
19
+ !credentialFile.startsWith(`${privateRoot}/`) ||
20
+ [...pluginRoots, ...protectedRoots].some(
21
+ (root) => !isAbsolute(root) || /[\r\n()]/.test(root),
22
+ )
23
+ )
24
+ throw new Error("Claude private state paths are invalid");
25
+ return {
26
+ disableAllHooks: true,
27
+ sandbox: {
28
+ enabled: true,
29
+ allowUnsandboxedCommands: false,
30
+ failIfUnavailable: true,
31
+ filesystem: {
32
+ denyRead: [privateRoot, ...protectedRoots],
33
+ denyWrite: [privateRoot, ...pluginRoots, ...protectedRoots],
34
+ },
35
+ credentials: {
36
+ files: [{ path: credentialFile, mode: "deny" }],
37
+ },
38
+ },
39
+ permissions: {
40
+ allow: ["Bash", "Read", "Edit", "Skill", "Agent"],
41
+ deny: [
42
+ ...[privateRoot, ...protectedRoots].flatMap((root) => [
43
+ absoluteRule(root, "Read"),
44
+ absoluteRule(root, "Edit"),
45
+ ]),
46
+ ...pluginRoots.map((root) => absoluteRule(root, "Edit")),
47
+ ],
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,159 @@
1
+ import { createHash } from "node:crypto";
2
+ import { summarizeClaudeEvents } from "./claude-events";
3
+
4
+ const MAX_RETAINED_CALLS = 128;
5
+ type Entry = Record<string, unknown>;
6
+
7
+ function record(value: unknown): value is Entry {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+
11
+ function label(value: unknown, max = 256): string | null {
12
+ return typeof value === "string" &&
13
+ value.length >= 1 &&
14
+ value.length <= max &&
15
+ /^[A-Za-z0-9_.:-]+$/.test(value)
16
+ ? value
17
+ : null;
18
+ }
19
+
20
+ interface SkillCall {
21
+ ordinal: number;
22
+ actor: "parent" | "nested";
23
+ parentToolUseId: string | null;
24
+ name: "Skill";
25
+ skill: string;
26
+ invocation: string;
27
+ }
28
+
29
+ interface AgentCall {
30
+ ordinal: number;
31
+ actor: "parent" | "nested";
32
+ parentToolUseId: string | null;
33
+ name: "Agent";
34
+ toolUseId: string | null;
35
+ subagentType: string | null;
36
+ runInBackground: boolean | null;
37
+ model: string | null;
38
+ promptSha256: string | null;
39
+ promptFirstLineSha256: string | null;
40
+ }
41
+
42
+ export interface ClaudeToolCallsObservation {
43
+ id: "sevro.claude.tool-calls";
44
+ completeness: "complete" | "partial";
45
+ data: {
46
+ method: "stream_tool_calls";
47
+ calls: Array<SkillCall | AgentCall>;
48
+ truncated: boolean;
49
+ };
50
+ }
51
+
52
+ /** Retain ordered host call metadata, never tool prompts or results. */
53
+ export function claudeToolCallsObservation(
54
+ stream: string,
55
+ exitCode: number,
56
+ ): ClaudeToolCallsObservation {
57
+ const summary = summarizeClaudeEvents(stream, exitCode);
58
+ const calls: Array<SkillCall | AgentCall> = [];
59
+ let malformed = false;
60
+ let truncated = false;
61
+ let ordinal = 0;
62
+ for (const line of stream.split("\n")) {
63
+ if (!line.trim()) continue;
64
+ let event: unknown;
65
+ try {
66
+ event = JSON.parse(line);
67
+ } catch {
68
+ malformed = true;
69
+ continue;
70
+ }
71
+ if (!record(event) || event.type !== "assistant") continue;
72
+ const blocks = record(event.message) ? event.message.content : null;
73
+ if (!Array.isArray(blocks)) {
74
+ malformed = true;
75
+ continue;
76
+ }
77
+ const parentToolUseId = label(event.parent_tool_use_id, 128);
78
+ const actor = parentToolUseId ? "nested" : "parent";
79
+ if (
80
+ event.parent_tool_use_id !== undefined &&
81
+ event.parent_tool_use_id !== null &&
82
+ actor === "parent"
83
+ )
84
+ malformed = true;
85
+ for (const value of blocks) {
86
+ if (!record(value) || value.type !== "tool_use") continue;
87
+ ordinal++;
88
+ if (!["Skill", "Agent", "Task"].includes(String(value.name))) continue;
89
+ if (calls.length >= MAX_RETAINED_CALLS) {
90
+ truncated = true;
91
+ break;
92
+ }
93
+ const input = record(value.input) ? value.input : null;
94
+ if (value.name === "Skill") {
95
+ const invocation = label(input?.skill);
96
+ if (!invocation) {
97
+ malformed = true;
98
+ continue;
99
+ }
100
+ calls.push({
101
+ ordinal,
102
+ actor,
103
+ parentToolUseId,
104
+ name: "Skill",
105
+ skill: invocation.split(":").at(-1)!,
106
+ invocation,
107
+ });
108
+ continue;
109
+ }
110
+ const toolUseId = label(value.id, 128);
111
+ const subagentType = label(input?.subagent_type ?? input?.subagentType);
112
+ const runInBackground =
113
+ typeof input?.run_in_background === "boolean"
114
+ ? input.run_in_background
115
+ : null;
116
+ const model = input?.model === undefined ? null : label(input.model, 128);
117
+ const boundedPrompt =
118
+ typeof input?.prompt === "string" &&
119
+ Buffer.byteLength(input.prompt, "utf8") <= 1024 * 1024
120
+ ? input.prompt
121
+ : null;
122
+ const promptSha256 = boundedPrompt
123
+ ? createHash("sha256").update(boundedPrompt).digest("hex")
124
+ : null;
125
+ const promptFirstLineSha256 = boundedPrompt
126
+ ? createHash("sha256")
127
+ .update(boundedPrompt.split(/\r?\n/, 1)[0]!)
128
+ .digest("hex")
129
+ : null;
130
+ if (
131
+ !toolUseId ||
132
+ !subagentType ||
133
+ runInBackground === null ||
134
+ (input?.model !== undefined && !model) ||
135
+ !promptSha256
136
+ )
137
+ malformed = true;
138
+ calls.push({
139
+ ordinal,
140
+ actor,
141
+ parentToolUseId,
142
+ name: "Agent",
143
+ toolUseId,
144
+ subagentType,
145
+ runInBackground,
146
+ model,
147
+ promptSha256,
148
+ promptFirstLineSha256,
149
+ });
150
+ }
151
+ if (truncated) break;
152
+ }
153
+ return {
154
+ id: "sevro.claude.tool-calls",
155
+ completeness:
156
+ summary.complete && !malformed && !truncated ? "complete" : "partial",
157
+ data: { method: "stream_tool_calls", calls, truncated },
158
+ };
159
+ }