@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21

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