@bjoernrochel/sevro 0.1.0-rc.8 → 1.0.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 (228) hide show
  1. package/CONTRIBUTING.md +22 -6
  2. package/README.md +5 -1
  3. package/docs/README.md +19 -15
  4. package/docs/architecture.md +138 -17
  5. package/docs/assets/README.md +5 -4
  6. package/docs/builtin-graders-v1.md +9 -2
  7. package/docs/codex-host.md +155 -57
  8. package/docs/contracts.md +2 -1
  9. package/docs/creating-cases.md +9 -2
  10. package/docs/development-cli.md +187 -19
  11. package/docs/documentation-quality.md +12 -2
  12. package/docs/evaluation-workflow.md +110 -0
  13. package/docs/extension-protocol-v1.md +26 -3
  14. package/docs/getting-started.md +23 -2
  15. package/docs/guide-evaluation.md +33 -1
  16. package/docs/identity-v1.md +25 -2
  17. package/docs/installing.md +11 -11
  18. package/docs/native-controls.md +76 -5
  19. package/docs/native-hosts.md +196 -7
  20. package/docs/reading-results.md +102 -0
  21. package/docs/recipes.md +489 -0
  22. package/docs/releases.md +31 -12
  23. package/docs/report-v1.md +29 -1
  24. package/docs/results-v1.md +122 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +255 -22
  27. package/docs/troubleshooting.md +12 -12
  28. package/docs/typescript-quality.md +89 -26
  29. package/examples/recipes/candidate.ts +26 -0
  30. package/examples/recipes/cases/coding-change.json +21 -0
  31. package/examples/recipes/cases/host-tools.json +21 -0
  32. package/examples/recipes/cases/mise.json +21 -0
  33. package/examples/recipes/cases/node.json +21 -0
  34. package/examples/recipes/cases/python-uv.json +21 -0
  35. package/examples/recipes/cases/semantic.json +17 -0
  36. package/examples/recipes/coding-change/check.ts +23 -0
  37. package/examples/recipes/extension-artifacts.ts +65 -0
  38. package/examples/recipes/extension-methods.ts +174 -0
  39. package/examples/recipes/extension.ts +37 -0
  40. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  41. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  42. package/examples/recipes/repository-skill/README.md +3 -0
  43. package/examples/recipes/run-inputs.ts +188 -0
  44. package/examples/recipes/run-options.ts +131 -0
  45. package/examples/recipes/run.ts +63 -0
  46. package/examples/recipes/runtime/empty.json +1 -0
  47. package/examples/recipes/runtime/goals.json +1 -0
  48. package/examples/recipes/runtime/mise.json +6 -0
  49. package/examples/recipes/runtime/path.json +1 -0
  50. package/examples/recipes/runtime/plugins.json +4 -0
  51. package/examples/recipes/runtime/python-uv.json +10 -0
  52. package/examples/recipes/semantic-judge.ts +29 -0
  53. package/examples/templates/coding/adapter.ts +17 -0
  54. package/examples/templates/coding/case.json +15 -0
  55. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  56. package/examples/templates/repository-skill/extension-data.ts +64 -0
  57. package/examples/templates/repository-skill/extension.ts +31 -0
  58. package/examples/templates/response/adapter.ts +12 -0
  59. package/examples/templates/response/case.json +13 -0
  60. package/package.json +11 -5
  61. package/schemas/report-v1.schema.json +9 -0
  62. package/schemas/run-evidence-v1.schema.json +2 -1
  63. package/schemas/runtime-v1.schema.json +43 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +42 -11
  66. package/src/candidate-codex-state.ts +185 -0
  67. package/src/case-validation.ts +73 -50
  68. package/src/cli/cancellation.ts +20 -0
  69. package/src/cli/command.ts +87 -0
  70. package/src/cli/completion.ts +15 -0
  71. package/src/cli/demo.ts +60 -0
  72. package/src/cli/evaluation.ts +55 -0
  73. package/src/cli/extension.ts +79 -0
  74. package/src/cli/help.ts +198 -0
  75. package/src/cli/hosts.ts +55 -0
  76. package/src/cli/init-settings.ts +30 -0
  77. package/src/cli/init.ts +111 -0
  78. package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
  79. package/src/cli/loading.ts +151 -0
  80. package/src/cli/preparation.ts +154 -0
  81. package/src/cli/presentation.ts +93 -0
  82. package/src/cli/progress.ts +6 -0
  83. package/src/cli/run-manifest.ts +128 -0
  84. package/src/cli/run-options.ts +403 -0
  85. package/src/cli/run-overrides.ts +69 -0
  86. package/src/cli/settings.ts +154 -0
  87. package/src/cli/summary.ts +43 -0
  88. package/src/cli.ts +13 -591
  89. package/src/contract-types.ts +94 -0
  90. package/src/engine.ts +9 -5
  91. package/src/evaluation/advisory.ts +150 -0
  92. package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
  93. package/src/evaluation/collection.ts +88 -0
  94. package/src/evaluation/context.ts +10 -0
  95. package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
  96. package/src/evaluation/grading.ts +235 -0
  97. package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
  98. package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
  99. package/src/evaluation/progress.ts +19 -0
  100. package/src/evaluation/resources.ts +241 -0
  101. package/src/evaluation/run.ts +206 -0
  102. package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +77 -87
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/{evaluation-types.ts → evaluation/types.ts} +79 -29
  105. package/src/evidence/host-results.ts +85 -0
  106. package/src/evidence/retained-artifacts.ts +18 -0
  107. package/src/evidence/trial-writer.ts +203 -0
  108. package/src/evidence/usage.ts +17 -0
  109. package/src/extension-client.ts +34 -96
  110. package/src/extension-session.ts +24 -48
  111. package/src/{advisory-fixture.ts → fixtures/advisory.ts} +12 -72
  112. package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
  113. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  114. package/src/fixtures/create.ts +183 -0
  115. package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
  116. package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
  117. package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
  118. package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
  119. package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
  120. package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
  121. package/src/{workspace-state.ts → fixtures/workspace-state.ts} +5 -5
  122. package/src/generated/cli-result.d.cts +2 -1
  123. package/src/generated/extension.d.cts +2 -1
  124. package/src/generated/report.cjs +1 -1
  125. package/src/generated/report.d.cts +2 -1
  126. package/src/generated/run-evidence.cjs +1 -1
  127. package/src/generated/run-evidence.d.cts +2 -1
  128. package/src/generated/runtime.cjs +1 -1
  129. package/src/generated/runtime.d.cts +2 -1
  130. package/src/git.ts +93 -0
  131. package/src/graders/git-head.ts +29 -84
  132. package/src/graders/shell.ts +50 -68
  133. package/src/host-diagnostic-redaction.ts +52 -0
  134. package/src/host-diagnostics.ts +331 -0
  135. package/src/hosts/claude-continuation.ts +57 -40
  136. package/src/hosts/claude-credential.ts +160 -63
  137. package/src/hosts/claude-events.ts +34 -18
  138. package/src/hosts/claude-keychain.ts +78 -21
  139. package/src/hosts/claude-login-recovery.ts +85 -0
  140. package/src/hosts/claude-login-refresh.ts +178 -0
  141. package/src/hosts/claude-login-store.ts +162 -0
  142. package/src/hosts/claude-login.ts +107 -0
  143. package/src/hosts/claude-native-goal.ts +15 -12
  144. package/src/hosts/claude-process.ts +10 -50
  145. package/src/hosts/claude-refresh-lock.ts +81 -0
  146. package/src/hosts/claude-repository-invocation.ts +9 -34
  147. package/src/hosts/claude-settings.ts +46 -9
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +28 -15
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +121 -81
  152. package/src/hosts/codex-app-server.ts +78 -52
  153. package/src/hosts/codex-configuration.ts +5 -10
  154. package/src/hosts/codex-exec-controls.ts +516 -0
  155. package/src/hosts/codex-marketplace.ts +12 -10
  156. package/src/hosts/codex-native-calls.ts +15 -122
  157. package/src/hosts/codex-process.ts +10 -183
  158. package/src/hosts/codex-profile.ts +36 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +134 -18
  161. package/src/hosts/codex.ts +210 -183
  162. package/src/hosts/isolation-common.ts +8 -11
  163. package/src/hosts/isolation-roots.ts +2 -2
  164. package/src/hosts/linux-sandbox.ts +15 -2
  165. package/src/hosts/mac-sandbox.ts +87 -18
  166. package/src/hosts/native-controls.ts +72 -22
  167. package/src/hosts/native-request.ts +55 -0
  168. package/src/hosts/process-lifecycle.ts +216 -48
  169. package/src/hosts/protected-worktrees.ts +1 -1
  170. package/src/hosts/skill-hook-catalog.ts +131 -0
  171. package/src/hosts/skill-hook-handler.ts +374 -0
  172. package/src/hosts/skill-hooks.ts +577 -0
  173. package/src/instrumentation.ts +7 -8
  174. package/src/native-transcript-state.ts +22 -3
  175. package/src/native-transcript-view.ts +52 -13
  176. package/src/native-transcripts.ts +26 -11
  177. package/src/paths.ts +22 -0
  178. package/src/process-lifecycle.ts +150 -0
  179. package/src/provenance.ts +27 -7
  180. package/src/prune.ts +300 -0
  181. package/src/report.ts +63 -36
  182. package/src/resolved-case.ts +1 -1
  183. package/src/results-lock.ts +42 -0
  184. package/src/results.ts +14 -18
  185. package/src/{runtime-config.ts → runtime/config.ts} +160 -46
  186. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  187. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  188. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  189. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  190. package/src/{runtime-hooks.ts → runtime/hooks.ts} +32 -22
  191. package/src/{runtime-paths.ts → runtime/paths.ts} +5 -19
  192. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  193. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  194. package/src/runtime/shared-cache.ts +130 -0
  195. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  196. package/src/schema-types.ts +73 -161
  197. package/src/shell.ts +4 -0
  198. package/src/transcript-retention.ts +146 -0
  199. package/src/value-guards.ts +22 -0
  200. package/docs/assets/sevro-logo.png +0 -0
  201. package/docs/assets/sevro-symbol.svg +0 -1
  202. package/docs/assets/sevro-wordmark.svg +0 -1
  203. package/docs/cache-ownership-validation.md +0 -49
  204. package/docs/claude-continuation-validation.md +0 -47
  205. package/docs/concurrency-validation.md +0 -86
  206. package/docs/configuration-root-validation.md +0 -70
  207. package/docs/engine-slice.md +0 -114
  208. package/docs/evidence/runtime-startup-30/README.md +0 -185
  209. package/docs/evidence/runtime-startup-30/after.json +0 -319
  210. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  211. package/docs/evidence/runtime-startup-30/before.json +0 -403
  212. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  213. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  214. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  216. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  217. package/docs/evidence.md +0 -17
  218. package/docs/fixture-cleanup-validation.md +0 -73
  219. package/docs/guide-verification.json +0 -239
  220. package/src/evaluation-context.ts +0 -259
  221. package/src/evaluation-fixture.ts +0 -302
  222. package/src/evaluation-run.ts +0 -200
  223. package/src/evaluation-trial.ts +0 -1016
  224. package/src/hosts/claude-nested-skills.ts +0 -272
  225. package/src/hosts/codex-command-output.ts +0 -510
  226. package/src/hosts/codex-executor-command.ts +0 -291
  227. package/src/hosts/codex-skill-reads.ts +0 -783
  228. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -0,0 +1,577 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { shellQuote as quote } from "../shell";
4
+ import { fileURLToPath } from "node:url";
5
+ import { isRecord, isUnknownArray } from "../value-guards";
6
+ import { skillHookCatalog, type SkillCatalogRoot } from "./skill-hook-catalog";
7
+ import type {
8
+ HookSkill,
9
+ SkillBodyRanges,
10
+ SkillHookConfiguration,
11
+ SkillHookReceipt,
12
+ } from "./skill-hook-handler";
13
+
14
+ const MAX_EVENTS_BYTES = 8 * 1024 * 1024;
15
+ const MAX_RETAINED_CALLS = 128;
16
+ export interface SkillTracking {
17
+ configuration: SkillHookConfiguration;
18
+ hooks: Record<string, unknown>;
19
+ command: string;
20
+ }
21
+
22
+ /** Install the runner's silent observer separately from authorized plugin hooks. */
23
+ export async function prepareSkillTracking(options: {
24
+ privateRoot: string;
25
+ host: "codex";
26
+ roots?: SkillCatalogRoot[];
27
+ }): Promise<SkillTracking> {
28
+ const root = join(options.privateRoot, "skill-tracking");
29
+ const receipts = join(root, "receipts");
30
+ await mkdir(receipts, { recursive: true, mode: 0o700 });
31
+ const configuration: SkillHookConfiguration = {
32
+ host: options.host,
33
+ receipts,
34
+ ...(await skillHookCatalog(options.roots ?? [])),
35
+ };
36
+ const configurationPath = join(root, "configuration.json");
37
+ await writeFile(configurationPath, JSON.stringify(configuration), {
38
+ flag: "wx",
39
+ mode: 0o600,
40
+ });
41
+ const command = [
42
+ "/usr/bin/env",
43
+ "-i",
44
+ "PATH=/usr/bin:/bin",
45
+ process.execPath,
46
+ fileURLToPath(new URL("./skill-hook-handler.ts", import.meta.url)),
47
+ configurationPath,
48
+ ]
49
+ .map(quote)
50
+ .join(" ");
51
+ return { configuration, command, hooks: hookDefinitions(command) };
52
+ }
53
+
54
+ function hookDefinitions(command: string) {
55
+ return Object.fromEntries(
56
+ ["SessionStart", "PreToolUse", "PostToolUse", "Stop"].map((event) => {
57
+ return [event, [{ hooks: [{ type: "command", command, timeout: 5 }] }]];
58
+ }),
59
+ );
60
+ }
61
+
62
+ function isReceipt(
63
+ value: unknown,
64
+ skills: HookSkill[],
65
+ ): value is SkillHookReceipt {
66
+ if (!isRecord(value)) return false;
67
+ return (
68
+ receiptLabels(value) &&
69
+ stringList(value.invocations) &&
70
+ stringList(value.ambiguousInvocations) &&
71
+ receiptPageFields(value, skills)
72
+ );
73
+ }
74
+
75
+ function receiptPageFields(
76
+ value: Record<string, unknown>,
77
+ skills: HookSkill[],
78
+ ) {
79
+ return (
80
+ typeof value.partial === "boolean" &&
81
+ validBodyRanges(value.bodyRanges, skills)
82
+ );
83
+ }
84
+
85
+ function validOffset(value: unknown): value is number {
86
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
87
+ }
88
+
89
+ function validRange(value: unknown, length: number): boolean {
90
+ if (!isOffsetRange(value)) return false;
91
+ const [start, end] = value;
92
+ return start < end && end <= length;
93
+ }
94
+
95
+ function isOffsetRange(value: unknown): value is [number, number] {
96
+ return (
97
+ isUnknownArray(value) && value.length === 2 && value.every(validOffset)
98
+ );
99
+ }
100
+
101
+ function validPage(value: unknown, skills: HookSkill[]): boolean {
102
+ if (!isRecord(value) || !Array.isArray(value.ranges)) return false;
103
+ const skill = skills.find(
104
+ (item) =>
105
+ item.invocation === value.invocation && item.sha256 === value.sha256,
106
+ );
107
+ if (!skill) return false;
108
+ return value.ranges.every((range: unknown) =>
109
+ validRange(range, skill.body.length),
110
+ );
111
+ }
112
+
113
+ function validBodyRanges(value: unknown, skills: HookSkill[]): boolean {
114
+ if (value === undefined) return true;
115
+ return (
116
+ Array.isArray(value) &&
117
+ value.every((page: unknown) => validPage(page, skills))
118
+ );
119
+ }
120
+
121
+ function stringList(value: unknown): value is string[] {
122
+ return (
123
+ Array.isArray(value) &&
124
+ value.every((item: unknown) => typeof item === "string")
125
+ );
126
+ }
127
+
128
+ function receiptLabels(value: Record<string, unknown>): boolean {
129
+ if (
130
+ !["SessionStart", "PreToolUse", "PostToolUse", "Stop"].includes(
131
+ String(value.event),
132
+ )
133
+ )
134
+ return false;
135
+ return [value.sessionId, value.turnId, value.toolUseId, value.toolName].every(
136
+ (field) => field === null || typeof field === "string",
137
+ );
138
+ }
139
+
140
+ async function receipts(tracking: SkillTracking) {
141
+ const root = tracking.configuration.receipts;
142
+ const result = { events: [] as SkillHookReceipt[], reasons: [] as string[] };
143
+ if (await Bun.file(join(root, "partial")).exists())
144
+ result.reasons.push("observer_failure");
145
+ try {
146
+ const path = join(root, "events.jsonl");
147
+ if ((await stat(path)).size > MAX_EVENTS_BYTES)
148
+ return { ...result, reasons: [...result.reasons, "receipt_limit"] };
149
+ parseReceipts(
150
+ await readFile(path, "utf8"),
151
+ tracking.configuration.skills,
152
+ result.events,
153
+ );
154
+ } catch (error) {
155
+ result.reasons.push(
156
+ error instanceof SyntaxError
157
+ ? "invalid_receipts"
158
+ : "receipts_unavailable",
159
+ );
160
+ }
161
+ return result;
162
+ }
163
+
164
+ function parseReceipts(
165
+ text: string,
166
+ skills: HookSkill[],
167
+ events: SkillHookReceipt[],
168
+ ) {
169
+ for (const line of text.trimEnd().split("\n")) {
170
+ const event: unknown = JSON.parse(line);
171
+ if (!isReceipt(event, skills))
172
+ throw new SyntaxError("invalid skill hook receipt");
173
+ events.push(event);
174
+ }
175
+ }
176
+
177
+ function hookKey(event: SkillHookReceipt): string {
178
+ return JSON.stringify([event.sessionId, event.toolUseId]);
179
+ }
180
+
181
+ function collectionSummary(
182
+ events: SkillHookReceipt[],
183
+ sessionId: string | null,
184
+ ) {
185
+ const ledger = {
186
+ pending: new Set<string>(),
187
+ finished: new Set<string>(),
188
+ diagnosticKeys: new Set<string>(),
189
+ duplicateToolCallCount: 0,
190
+ unmatchedPostToolUseCount: 0,
191
+ };
192
+ for (const event of events) updatePending(event, ledger);
193
+ return {
194
+ diagnosticKeys: new Set([...ledger.diagnosticKeys, ...ledger.pending]),
195
+ eventCount: events.length,
196
+ preToolUseCount: events.filter((event) => event.event === "PreToolUse")
197
+ .length,
198
+ postToolUseCount: events.filter((event) => event.event === "PostToolUse")
199
+ .length,
200
+ pendingToolCallCount: ledger.pending.size,
201
+ duplicateToolCallCount: ledger.duplicateToolCallCount,
202
+ unmatchedPostToolUseCount: ledger.unmatchedPostToolUseCount,
203
+ partialEventCount: events.filter((event) => event.partial).length,
204
+ ...lifecycleSummary(events, sessionId),
205
+ };
206
+ }
207
+
208
+ function lifecycleSummary(
209
+ events: SkillHookReceipt[],
210
+ sessionId: string | null,
211
+ ) {
212
+ const selected = events.filter((event) => event.sessionId === sessionId);
213
+ return {
214
+ sessionIdentified: sessionId !== null,
215
+ sessionStarted: selected.some((event) => event.event === "SessionStart"),
216
+ sessionStopped: selected.some((event) => event.event === "Stop"),
217
+ };
218
+ }
219
+
220
+ function updatePending(
221
+ event: SkillHookReceipt,
222
+ ledger: {
223
+ pending: Set<string>;
224
+ finished: Set<string>;
225
+ diagnosticKeys: Set<string>;
226
+ duplicateToolCallCount: number;
227
+ unmatchedPostToolUseCount: number;
228
+ },
229
+ ) {
230
+ const key = hookKey(event);
231
+ if (event.event === "PreToolUse") {
232
+ if (duplicateCall(key, ledger)) {
233
+ ledger.duplicateToolCallCount++;
234
+ ledger.diagnosticKeys.add(key);
235
+ }
236
+ ledger.pending.add(key);
237
+ }
238
+ if (event.event === "PostToolUse") {
239
+ if (!ledger.pending.delete(key)) {
240
+ ledger.unmatchedPostToolUseCount++;
241
+ ledger.diagnosticKeys.add(key);
242
+ }
243
+ ledger.finished.add(key);
244
+ }
245
+ }
246
+
247
+ function duplicateCall(
248
+ key: string,
249
+ ledger: { pending: Set<string>; finished: Set<string> },
250
+ ): boolean {
251
+ return ledger.pending.has(key) || ledger.finished.has(key);
252
+ }
253
+
254
+ function mergedRanges(ranges: [number, number][]): [number, number][] {
255
+ const merged: [number, number][] = [];
256
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
257
+ const previous = merged.at(-1);
258
+ if (previous && start <= previous[1])
259
+ previous[1] = Math.max(previous[1], end);
260
+ else merged.push([start, end]);
261
+ }
262
+ return merged;
263
+ }
264
+
265
+ function wholeBody(ranges: [number, number][], skill: HookSkill) {
266
+ const range = ranges[0];
267
+ return (
268
+ ranges.length === 1 &&
269
+ range !== undefined &&
270
+ range[0] === 0 &&
271
+ range[1] === skill.body.length
272
+ );
273
+ }
274
+
275
+ function completedPage(
276
+ receipt: SkillHookReceipt,
277
+ page: SkillBodyRanges,
278
+ skills: HookSkill[],
279
+ ) {
280
+ const aliases = skills.filter((skill) => skill.sha256 === page.sha256);
281
+ if (aliases.some((skill) => skill.invocation !== page.invocation)) {
282
+ receipt.ambiguousInvocations = [
283
+ ...new Set([
284
+ ...receipt.ambiguousInvocations,
285
+ ...aliases.map((skill) => skill.invocation),
286
+ ]),
287
+ ];
288
+ receipt.partial = true;
289
+ } else
290
+ receipt.invocations = [
291
+ ...new Set([...receipt.invocations, page.invocation]),
292
+ ];
293
+ }
294
+
295
+ function accumulatePage(
296
+ receipt: SkillHookReceipt,
297
+ page: SkillBodyRanges,
298
+ skills: HookSkill[],
299
+ coverage: Map<string, [number, number][]>,
300
+ ) {
301
+ const skill = skills.find(
302
+ (item) =>
303
+ item.invocation === page.invocation && item.sha256 === page.sha256,
304
+ );
305
+ if (!skill) {
306
+ receipt.partial = true;
307
+ return;
308
+ }
309
+ const key = JSON.stringify([
310
+ receipt.sessionId,
311
+ receipt.turnId,
312
+ page.invocation,
313
+ page.sha256,
314
+ ]);
315
+ const ranges = mergedRanges([...(coverage.get(key) ?? []), ...page.ranges]);
316
+ coverage.set(key, ranges);
317
+ if (wholeBody(ranges, skill)) completedPage(receipt, page, skills);
318
+ }
319
+
320
+ function pagedEvents(events: SkillHookReceipt[], skills: HookSkill[]) {
321
+ const coverage = new Map<string, [number, number][]>();
322
+ const settled = new Set<string>();
323
+ return events.map((event) => {
324
+ const receipt = { ...event };
325
+ for (const page of event.bodyRanges ?? []) {
326
+ if (!settled.has(page.invocation))
327
+ accumulatePage(receipt, page, skills, coverage);
328
+ }
329
+ for (const invocation of receipt.invocations) settled.add(invocation);
330
+ return receipt;
331
+ });
332
+ }
333
+
334
+ function publicReceipt(event: SkillHookReceipt) {
335
+ const receipt = { ...event };
336
+ delete receipt.bodyRanges;
337
+ return {
338
+ ...receipt,
339
+ fragmentSkills: [
340
+ ...new Set((event.bodyRanges ?? []).map((page) => page.invocation)),
341
+ ],
342
+ };
343
+ }
344
+
345
+ function incompleteSkills(events: SkillHookReceipt[]) {
346
+ const settled = new Set(
347
+ events.flatMap((event) => [
348
+ ...event.invocations,
349
+ ...event.ambiguousInvocations,
350
+ ]),
351
+ );
352
+ const fragments = new Set(
353
+ events.flatMap((event) =>
354
+ (event.bodyRanges ?? []).map((page) => page.invocation),
355
+ ),
356
+ );
357
+ return [...fragments].filter((invocation) => !settled.has(invocation));
358
+ }
359
+
360
+ function retainedCalls(
361
+ events: SkillHookReceipt[],
362
+ diagnosticKeys: Set<string>,
363
+ ) {
364
+ const calls: ReturnType<typeof publicReceipt>[] = [];
365
+ let truncated = false;
366
+ for (const event of events.filter((event) =>
367
+ relevantCall(event, diagnosticKeys),
368
+ )) {
369
+ calls.push(publicReceipt(event));
370
+ if (
371
+ calls.length > MAX_RETAINED_CALLS ||
372
+ Buffer.byteLength(JSON.stringify(calls)) > 24 * 1024
373
+ ) {
374
+ calls.pop();
375
+ truncated = true;
376
+ break;
377
+ }
378
+ }
379
+ return { calls, truncated };
380
+ }
381
+
382
+ function relevantCall(
383
+ event: SkillHookReceipt,
384
+ diagnosticKeys: Set<string>,
385
+ ): boolean {
386
+ return [
387
+ event.invocations.length > 0,
388
+ event.ambiguousInvocations.length > 0,
389
+ (event.bodyRanges ?? []).length > 0,
390
+ event.partial,
391
+ diagnosticKeys.has(hookKey(event)),
392
+ ].some(Boolean);
393
+ }
394
+
395
+ function primaryEvidence(events: SkillHookReceipt[], reasons: string[]) {
396
+ const index = events.findIndex((event) => event.invocations.length > 0);
397
+ const first = events[index];
398
+ const complete = primarySelectionComplete(events, index, reasons);
399
+ if (!complete)
400
+ return { primarySkill: null, primarySkillCompleteness: "partial" as const };
401
+ return {
402
+ primarySkill: first?.invocations[0] ?? null,
403
+ primarySkillCompleteness: "complete" as const,
404
+ };
405
+ }
406
+
407
+ function primarySelectionComplete(
408
+ events: SkillHookReceipt[],
409
+ index: number,
410
+ reasons: string[],
411
+ ) {
412
+ const matches = events[index]?.invocations.length ?? 0;
413
+ if (matches > 1) return false;
414
+ if (reasons.length === 0) return true;
415
+ return primaryBeforeGap(events, index, reasons);
416
+ }
417
+
418
+ function primaryBeforeGap(
419
+ events: SkillHookReceipt[],
420
+ index: number,
421
+ reasons: string[],
422
+ ) {
423
+ if (reasons.join(",") !== "pending_tool_calls") return false;
424
+ if (events[index]?.invocations.length !== 1) return false;
425
+ const prefix = collectionSummary(events.slice(0, index + 1), null);
426
+ return prefix.pendingToolCallCount === 0;
427
+ }
428
+
429
+ export function skillActivationClaims(
430
+ observation: Awaited<ReturnType<typeof skillTrackingObservation>>,
431
+ ) {
432
+ if (observation.completeness !== "partial") return [];
433
+ if (observation.data.primarySkillCompleteness !== "complete") return [];
434
+ return [
435
+ {
436
+ id: "sevro.host.skill-activation-claims",
437
+ completeness: "complete" as const,
438
+ data: {
439
+ method: "tool_result_body_match",
440
+ scope: "primary_and_positive_presence",
441
+ collectionCompleteness: "partial",
442
+ primarySkill: observation.data.primarySkill,
443
+ presentSkills: observation.data.observedSkills,
444
+ },
445
+ },
446
+ ];
447
+ }
448
+
449
+ export function skillExposureClaims(
450
+ observation: Awaited<ReturnType<typeof skillTrackingObservation>>,
451
+ ) {
452
+ const skillExposures = observation.data.skillExposures.filter(
453
+ (exposure) => exposure.status === "full" || exposure.status === "partial",
454
+ );
455
+ if (skillExposures.length === 0) return [];
456
+ return [
457
+ {
458
+ id: "sevro.host.skill-exposure-claims",
459
+ completeness: "complete" as const,
460
+ data: {
461
+ method: "tool_result_body_match",
462
+ scope: "positive_exposure",
463
+ collectionStatus: observation.data.collectionStatus,
464
+ skillExposures,
465
+ },
466
+ },
467
+ ];
468
+ }
469
+
470
+ function exposureSummary(
471
+ skills: HookSkill[],
472
+ events: SkillHookReceipt[],
473
+ collectionStatus: string,
474
+ ) {
475
+ const exposure = {
476
+ full: new Set(events.flatMap((event) => event.invocations)),
477
+ partial: new Set(incompleteSkills(events)),
478
+ ambiguous: new Set(events.flatMap((event) => event.ambiguousInvocations)),
479
+ };
480
+ return skills.map(({ invocation: skill }) => ({
481
+ skill,
482
+ status: skillExposureStatus(skill, exposure, collectionStatus),
483
+ }));
484
+ }
485
+
486
+ function skillExposureStatus(
487
+ skill: string,
488
+ exposure: { full: Set<string>; partial: Set<string>; ambiguous: Set<string> },
489
+ collectionStatus: string,
490
+ ) {
491
+ if (exposure.full.has(skill)) return "full" as const;
492
+ if (exposure.ambiguous.has(skill)) return "ambiguous" as const;
493
+ if (exposure.partial.has(skill)) return "partial" as const;
494
+ return collectionStatus === "complete"
495
+ ? ("none" as const)
496
+ : ("unknown" as const);
497
+ }
498
+
499
+ function exposureCollectionStatus(reasons: string[], eventCount: number) {
500
+ if (eventCount === 0) return "unavailable" as const;
501
+ return reasons.some(
502
+ (reason) =>
503
+ !["partial_skill_bodies", "ambiguous_skill_bodies"].includes(reason),
504
+ )
505
+ ? ("partial" as const)
506
+ : ("complete" as const);
507
+ }
508
+
509
+ export async function skillTrackingObservation(
510
+ tracking: SkillTracking,
511
+ sessionId: string | null,
512
+ completed: boolean,
513
+ ) {
514
+ const collected = await receipts(tracking);
515
+ const events = pagedEvents(collected.events, tracking.configuration.skills);
516
+ const partialSkills = incompleteSkills(events);
517
+ const { diagnosticKeys, ...collection } = collectionSummary(
518
+ events,
519
+ sessionId,
520
+ );
521
+ const { calls, truncated } = retainedCalls(events, diagnosticKeys);
522
+ const ambiguousSkills = [
523
+ ...new Set(events.flatMap((event) => event.ambiguousInvocations)),
524
+ ];
525
+ const conditions: [string, boolean][] = [
526
+ ["host_incomplete", !completed],
527
+ ["catalog_incomplete", !tracking.configuration.catalogComplete],
528
+ ["session_identity_missing", !collection.sessionIdentified],
529
+ ["session_start_missing", !collection.sessionStarted],
530
+ ["session_stop_missing", !collection.sessionStopped],
531
+ ["pending_tool_calls", collection.pendingToolCallCount > 0],
532
+ ["unmatched_post_tool_use", collection.unmatchedPostToolUseCount > 0],
533
+ ["duplicate_tool_calls", collection.duplicateToolCallCount > 0],
534
+ [
535
+ "partial_hook_events",
536
+ events.some(
537
+ (event) => event.partial && event.ambiguousInvocations.length === 0,
538
+ ),
539
+ ],
540
+ ["ambiguous_skill_bodies", ambiguousSkills.length > 0],
541
+ ["partial_skill_bodies", partialSkills.length > 0],
542
+ ["calls_truncated", truncated],
543
+ ];
544
+ const incompleteReasons = [
545
+ ...collected.reasons,
546
+ ...conditions.filter(([, applies]) => applies).map(([reason]) => reason),
547
+ ];
548
+ const complete = incompleteReasons.length === 0;
549
+ const collectionStatus = exposureCollectionStatus(
550
+ incompleteReasons,
551
+ events.length,
552
+ );
553
+ return {
554
+ id: "sevro.host.skill-activations",
555
+ completeness: complete ? ("complete" as const) : ("partial" as const),
556
+ data: {
557
+ method: "tool_result_body_match",
558
+ visibility: "tool_result",
559
+ scope: "prepared_skill_bodies",
560
+ catalogComplete: tracking.configuration.catalogComplete,
561
+ collectionStatus,
562
+ skillExposures: exposureSummary(
563
+ tracking.configuration.skills,
564
+ events,
565
+ collectionStatus,
566
+ ),
567
+ observedSkills: [...new Set(calls.flatMap((call) => call.invocations))],
568
+ ...primaryEvidence(events, incompleteReasons),
569
+ partialSkills,
570
+ ambiguousSkills,
571
+ calls,
572
+ truncated,
573
+ incompleteReasons,
574
+ collection,
575
+ },
576
+ };
577
+ }
@@ -1,4 +1,8 @@
1
- import { isRecord, isUnknownArray } from "./value-guards";
1
+ import {
2
+ isRecord,
3
+ isUnknownArray,
4
+ validQualifiedId as validInstrumentationId,
5
+ } from "./value-guards";
2
6
  import { canonicalJson } from "./identity";
3
7
 
4
8
  export interface InstrumentationRequest {
@@ -18,12 +22,6 @@ export class InstrumentationEvidenceError extends Error {
18
22
  }
19
23
  }
20
24
 
21
- const ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
22
-
23
- function validInstrumentationId(value: unknown): value is string {
24
- return typeof value === "string" && ID.test(value);
25
- }
26
-
27
25
  function validRequest(value: unknown): value is InstrumentationRequest {
28
26
  return (
29
27
  isRecord(value) &&
@@ -39,7 +37,8 @@ function validateSupportedInstrumentation(
39
37
  if (
40
38
  supported.some(
41
39
  (item) =>
42
- !ID.test(item.id) || typeof item.executionChanging !== "boolean",
40
+ !validInstrumentationId(item.id) ||
41
+ typeof item.executionChanging !== "boolean",
43
42
  ) ||
44
43
  ids.size !== supported.length
45
44
  )
@@ -84,20 +84,39 @@ export async function requireNativeTranscriptViews(
84
84
  /** Curated hook copies are a separate runner-owned exception, never policy roots. */
85
85
  export async function requireNativeHookRoots(
86
86
  roots: string[] | undefined,
87
+ privateRoot: string,
87
88
  ): Promise<void> {
88
- for (const root of roots ?? []) await requireNativeHookRoot(root);
89
+ for (const root of roots ?? [])
90
+ await requireNativeHookRoot(root, privateRoot);
89
91
  }
90
92
 
91
- async function requireNativeHookRoot(root: string): Promise<void> {
93
+ async function requireNativeHookRoot(
94
+ root: string,
95
+ privateRoot: string,
96
+ ): Promise<void> {
92
97
  const parent = await nativeStateParent();
93
98
  const state = dirname(dirname(root));
94
99
  if (!nativeHookLayout(root, state, parent) || (await realpath(root)) !== root)
95
100
  throw new Error("native hook read root must be a canonical curated mount");
101
+ await requireNativeHookOwner(state, privateRoot);
96
102
  const info = await lstat(root);
97
103
  if (!info.isDirectory() || info.uid !== userInfo().uid)
98
104
  throw new Error("native hook read root must be owned");
99
105
  }
100
106
 
107
+ async function requireNativeHookOwner(
108
+ state: string,
109
+ privateRoot: string,
110
+ ): Promise<void> {
111
+ if (
112
+ dirname(privateRoot) !== state ||
113
+ (await realpath(privateRoot)) !== privateRoot
114
+ )
115
+ throw new Error("native hook read root must belong to the current request");
116
+ requirePrivateNamespace(await lstat(state));
117
+ requirePrivateNamespace(await lstat(privateRoot));
118
+ }
119
+
101
120
  function nativeHookLayout(
102
121
  root: string,
103
122
  state: string,
@@ -105,7 +124,7 @@ function nativeHookLayout(
105
124
  ): boolean {
106
125
  return (
107
126
  dirname(state) === parent &&
108
- basename(state).startsWith("claude-") &&
127
+ /^(?:claude|codex)-/.test(basename(state)) &&
109
128
  basename(dirname(root)) === "runtime-plugins" &&
110
129
  /^plugin-[0-9]+$/.test(basename(root))
111
130
  );