@bjoernrochel/sevro 0.1.0-rc.7 → 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 (230) 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 +17 -3
  7. package/docs/codex-host.md +155 -49
  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 +141 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +307 -18
  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 +63 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +206 -0
  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 +13 -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} +133 -115
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/evaluation/types.ts +207 -0
  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} +15 -76
  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/fixtures/workspace-state.ts +212 -0
  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 +57 -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 +91 -14
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +46 -16
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +122 -73
  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 -54
  158. package/src/hosts/codex-profile.ts +50 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +141 -19
  161. package/src/hosts/codex.ts +221 -179
  162. package/src/hosts/isolation-common.ts +15 -11
  163. package/src/hosts/isolation-roots.ts +13 -20
  164. package/src/hosts/linux-sandbox.ts +27 -3
  165. package/src/hosts/mac-sandbox.ts +111 -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 +221 -45
  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 +34 -15
  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/run-owner.ts +4 -4
  186. package/src/{runtime-config.ts → runtime/config.ts} +182 -44
  187. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  188. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  189. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  190. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  191. package/src/{runtime-hooks.ts → runtime/hooks.ts} +33 -22
  192. package/src/{runtime-paths.ts → runtime/paths.ts} +6 -18
  193. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  194. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  195. package/src/runtime/shared-cache.ts +130 -0
  196. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  197. package/src/schema-types.ts +73 -161
  198. package/src/shell.ts +4 -0
  199. package/src/transcript-retention.ts +146 -0
  200. package/src/value-guards.ts +22 -0
  201. package/docs/assets/sevro-logo.png +0 -0
  202. package/docs/assets/sevro-symbol.svg +0 -1
  203. package/docs/assets/sevro-wordmark.svg +0 -1
  204. package/docs/cache-ownership-validation.md +0 -49
  205. package/docs/claude-continuation-validation.md +0 -47
  206. package/docs/concurrency-validation.md +0 -86
  207. package/docs/configuration-root-validation.md +0 -70
  208. package/docs/engine-slice.md +0 -114
  209. package/docs/evidence/runtime-startup-30/README.md +0 -185
  210. package/docs/evidence/runtime-startup-30/after.json +0 -319
  211. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  212. package/docs/evidence/runtime-startup-30/before.json +0 -403
  213. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  214. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  216. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  217. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  218. package/docs/evidence.md +0 -17
  219. package/docs/fixture-cleanup-validation.md +0 -70
  220. package/docs/guide-verification.json +0 -239
  221. package/src/evaluation-context.ts +0 -257
  222. package/src/evaluation-fixture.ts +0 -313
  223. package/src/evaluation-run.ts +0 -198
  224. package/src/evaluation-trial.ts +0 -946
  225. package/src/evaluation-types.ts +0 -149
  226. package/src/hosts/claude-nested-skills.ts +0 -272
  227. package/src/hosts/codex-command-output.ts +0 -510
  228. package/src/hosts/codex-executor-command.ts +0 -291
  229. package/src/hosts/codex-skill-reads.ts +0 -609
  230. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -0,0 +1,374 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isRecord } from "../value-guards";
5
+
6
+ const MAX_INPUT_BYTES = 8 * 1024 * 1024;
7
+ const MAX_RECEIPT_BYTES = 4 * 1024 * 1024;
8
+
9
+ export interface HookSkill {
10
+ invocation: string;
11
+ sha256: string;
12
+ body: string;
13
+ }
14
+ export interface SkillHookConfiguration {
15
+ host: "codex";
16
+ receipts: string;
17
+ skills: HookSkill[];
18
+ catalogComplete: boolean;
19
+ }
20
+ export interface SkillHookReceipt {
21
+ event: string;
22
+ sessionId: string | null;
23
+ turnId: string | null;
24
+ toolUseId: string | null;
25
+ toolName: string | null;
26
+ invocations: string[];
27
+ ambiguousInvocations: string[];
28
+ bodyRanges?: SkillBodyRanges[];
29
+ partial: boolean;
30
+ }
31
+ export interface SkillBodyRanges {
32
+ invocation: string;
33
+ sha256: string;
34
+ ranges: [number, number][];
35
+ }
36
+
37
+ const MIN_PAGE_CHARACTERS = 32;
38
+ interface MatchBudget {
39
+ comparisons: number;
40
+ anchors: number;
41
+ }
42
+
43
+ function spend(budget: MatchBudget, count: number) {
44
+ budget.comparisons -= count;
45
+ if (budget.comparisons < 0)
46
+ throw new Error("skill page matching exceeds limit");
47
+ }
48
+
49
+ function spendAnchor(budget: MatchBudget) {
50
+ budget.anchors--;
51
+ if (budget.anchors < 0) throw new Error("skill page anchors exceed limit");
52
+ }
53
+
54
+ function matchingLength(
55
+ body: string,
56
+ text: string,
57
+ bodyStart: number,
58
+ textStart: number,
59
+ direction: number,
60
+ budget: MatchBudget,
61
+ ): number {
62
+ const available =
63
+ direction === 1
64
+ ? Math.min(body.length - bodyStart, text.length - textStart)
65
+ : Math.min(bodyStart + 1, textStart + 1);
66
+ for (let length = 0; length < available; length++) {
67
+ spend(budget, 1);
68
+ if (
69
+ body[bodyStart + direction * length] !==
70
+ text[textStart + direction * length]
71
+ )
72
+ return length;
73
+ }
74
+ return available;
75
+ }
76
+
77
+ function lineOffsets(body: string) {
78
+ const offsets = new Map<string, number[]>();
79
+ for (const line of body.matchAll(/[^\n]*(?:\n|$)/g)) {
80
+ if (!line[0].trim()) continue;
81
+ const positions = offsets.get(line[0]) ?? [];
82
+ positions.push(line.index);
83
+ offsets.set(line[0], positions);
84
+ }
85
+ return offsets;
86
+ }
87
+
88
+ function anchoredRange(
89
+ body: string,
90
+ text: string,
91
+ bodyStart: number,
92
+ textStart: number,
93
+ anchorLength: number,
94
+ budget: MatchBudget,
95
+ ) {
96
+ spendAnchor(budget);
97
+ const before = matchingLength(
98
+ body,
99
+ text,
100
+ bodyStart - 1,
101
+ textStart - 1,
102
+ -1,
103
+ budget,
104
+ );
105
+ const after = matchingLength(
106
+ body,
107
+ text,
108
+ bodyStart + anchorLength,
109
+ textStart + anchorLength,
110
+ 1,
111
+ budget,
112
+ );
113
+ return {
114
+ start: bodyStart - before,
115
+ end: bodyStart + anchorLength + after,
116
+ outputEnd: textStart + anchorLength + after,
117
+ };
118
+ }
119
+
120
+ function uniqueRange(
121
+ body: string,
122
+ start: number,
123
+ end: number,
124
+ budget: MatchBudget,
125
+ ) {
126
+ spend(budget, body.length * 2);
127
+ const fragment = body.slice(start, end);
128
+ return (
129
+ body.indexOf(fragment) === start && body.indexOf(fragment, start + 1) === -1
130
+ );
131
+ }
132
+
133
+ function lineRanges(
134
+ body: string,
135
+ text: string,
136
+ line: RegExpExecArray,
137
+ positions: number[],
138
+ budget: MatchBudget,
139
+ ) {
140
+ const result = {
141
+ found: false,
142
+ ranges: [] as [number, number][],
143
+ matchedUntil: 0,
144
+ };
145
+ for (const offset of positions) {
146
+ const range = anchoredRange(
147
+ body,
148
+ text,
149
+ offset,
150
+ line.index,
151
+ line[0].length,
152
+ budget,
153
+ );
154
+ if (range.end - range.start < MIN_PAGE_CHARACTERS) continue;
155
+ result.found = true;
156
+ if (uniqueRange(body, range.start, range.end, budget))
157
+ result.ranges.push([range.start, range.end]);
158
+ result.matchedUntil = Math.max(result.matchedUntil, range.outputEnd);
159
+ }
160
+ return result;
161
+ }
162
+
163
+ function textRanges(body: string, text: string, budget: MatchBudget) {
164
+ const offsets = lineOffsets(body);
165
+ const result = { found: false, ranges: [] as [number, number][] };
166
+ let matchedUntil = 0;
167
+ for (const line of text.matchAll(/[^\n]*(?:\n|$)/g)) {
168
+ if (line.index < matchedUntil) continue;
169
+ const match = lineRanges(
170
+ body,
171
+ text,
172
+ line,
173
+ offsets.get(line[0]) ?? [],
174
+ budget,
175
+ );
176
+ result.found ||= match.found;
177
+ result.ranges.push(...match.ranges);
178
+ matchedUntil = Math.max(matchedUntil, match.matchedUntil);
179
+ }
180
+ return result;
181
+ }
182
+
183
+ function skillRanges(
184
+ skill: HookSkill,
185
+ texts: string[],
186
+ budget: MatchBudget,
187
+ ): SkillBodyRanges[] {
188
+ const matches = texts.map((text) => textRanges(skill.body, text, budget));
189
+ if (!matches.some((match) => match.found)) return [];
190
+ return [
191
+ {
192
+ invocation: skill.invocation,
193
+ sha256: skill.sha256,
194
+ ranges: matches.flatMap((match) => match.ranges),
195
+ },
196
+ ];
197
+ }
198
+
199
+ function label(value: unknown): string | null {
200
+ return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,256}$/.test(value)
201
+ ? value
202
+ : null;
203
+ }
204
+
205
+ function responseTexts(value: unknown, depth = 0): string[] {
206
+ if (depth > 16) throw new Error("skill hook response nesting exceeds limit");
207
+ if (typeof value === "string") return [value];
208
+ return responseValues(value).flatMap((child) =>
209
+ responseTexts(child, depth + 1),
210
+ );
211
+ }
212
+
213
+ function responseValues(value: unknown): unknown[] {
214
+ if (Array.isArray(value)) return value;
215
+ return isRecord(value) ? Object.values(value) : [];
216
+ }
217
+
218
+ function completeBodyRanges(
219
+ text: string,
220
+ skill: HookSkill,
221
+ budget: MatchBudget,
222
+ ): [number, number][] {
223
+ spend(budget, text.length);
224
+ const ranges: [number, number][] = [];
225
+ let start = text.indexOf(skill.body);
226
+ while (start !== -1) {
227
+ spendAnchor(budget);
228
+ ranges.push([start, start + skill.body.length]);
229
+ start = text.indexOf(skill.body, start + 1);
230
+ }
231
+ return ranges;
232
+ }
233
+
234
+ function textOutsideRanges(text: string, ranges: [number, number][]) {
235
+ const remaining: string[] = [];
236
+ let cursor = 0;
237
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
238
+ if (start > cursor) remaining.push(text.slice(cursor, start));
239
+ cursor = Math.max(cursor, end);
240
+ }
241
+ remaining.push(text.slice(cursor));
242
+ return remaining;
243
+ }
244
+
245
+ function textsOutsideCompleteBodies(
246
+ texts: string[],
247
+ skills: HookSkill[],
248
+ budget: MatchBudget,
249
+ ) {
250
+ return texts.flatMap((text) =>
251
+ textOutsideRanges(
252
+ text,
253
+ skills.flatMap((skill) => completeBodyRanges(text, skill, budget)),
254
+ ),
255
+ );
256
+ }
257
+
258
+ function invocations(
259
+ event: Record<string, unknown>,
260
+ configuration: SkillHookConfiguration,
261
+ ): {
262
+ invocations: string[];
263
+ ambiguousInvocations: string[];
264
+ bodyRanges?: SkillBodyRanges[];
265
+ } {
266
+ if (event.hook_event_name !== "PostToolUse")
267
+ return { invocations: [], ambiguousInvocations: [] };
268
+ const texts = responseTexts(event.tool_response);
269
+ const matched = configuration.skills.filter((skill) =>
270
+ texts.some((text) => text.includes(skill.body)),
271
+ );
272
+ const ambiguous = matched.filter((skill) =>
273
+ matched.some(
274
+ (other) =>
275
+ other.sha256 === skill.sha256 && other.invocation !== skill.invocation,
276
+ ),
277
+ );
278
+ const ambiguousNames = new Set(ambiguous.map((skill) => skill.invocation));
279
+ const unambiguous = matched.filter(
280
+ (skill) => !ambiguousNames.has(skill.invocation),
281
+ );
282
+ const budget = { comparisons: 4 * MAX_INPUT_BYTES, anchors: 4096 };
283
+ const remainingTexts = textsOutsideCompleteBodies(texts, unambiguous, budget);
284
+ const bodyRanges = configuration.skills
285
+ .filter((skill) => !matched.includes(skill))
286
+ .flatMap((skill) => skillRanges(skill, remainingTexts, budget));
287
+ return {
288
+ invocations: [...new Set(unambiguous.map((skill) => skill.invocation))],
289
+ ambiguousInvocations: [...ambiguousNames],
290
+ ...(bodyRanges.length ? { bodyRanges } : {}),
291
+ };
292
+ }
293
+
294
+ export function skillHookReceipt(
295
+ input: unknown,
296
+ configuration: SkillHookConfiguration,
297
+ ): SkillHookReceipt {
298
+ if (!isRecord(input) || typeof input.hook_event_name !== "string")
299
+ throw new Error("invalid skill hook input");
300
+ const receipt = {
301
+ event: input.hook_event_name,
302
+ sessionId: label(input.session_id),
303
+ turnId: label(input.turn_id),
304
+ toolUseId: label(input.tool_use_id),
305
+ toolName: label(input.tool_name),
306
+ ...invocations(input, configuration),
307
+ partial: false,
308
+ };
309
+ receipt.partial =
310
+ !validReceiptIdentity(receipt) || receipt.ambiguousInvocations.length > 0;
311
+ return receipt;
312
+ }
313
+
314
+ function validReceiptIdentity(receipt: SkillHookReceipt): boolean {
315
+ if (!receipt.sessionId) return false;
316
+ return (
317
+ !receipt.event.includes("ToolUse") ||
318
+ (receipt.toolUseId !== null && receipt.toolName !== null)
319
+ );
320
+ }
321
+
322
+ async function boundedInput(): Promise<unknown> {
323
+ const chunks: Uint8Array[] = [];
324
+ let bytes = 0;
325
+ for await (const chunk of Bun.stdin.stream()) {
326
+ bytes += chunk.byteLength;
327
+ if (bytes > MAX_INPUT_BYTES) throw new Error("skill hook input too large");
328
+ chunks.push(chunk);
329
+ }
330
+ return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
331
+ }
332
+
333
+ async function appendReceipt(root: string, receipt: SkillHookReceipt) {
334
+ const path = join(root, "events.jsonl");
335
+ const size = await stat(path).then(
336
+ (file) => file.size,
337
+ () => 0,
338
+ );
339
+ if (size > MAX_RECEIPT_BYTES) {
340
+ await writeFile(join(root, "partial"), "limit\n", { mode: 0o600 });
341
+ return;
342
+ }
343
+ const file = await open(path, "a", 0o600);
344
+ try {
345
+ await file.write(JSON.stringify(receipt) + "\n");
346
+ } finally {
347
+ await file.close();
348
+ }
349
+ }
350
+
351
+ /** A silent observer: errors affect evidence availability, never tool decisions. */
352
+ export async function runSkillHook(configurationPath: string): Promise<void> {
353
+ const configuration = JSON.parse(
354
+ await readFile(configurationPath, "utf8"),
355
+ ) as SkillHookConfiguration;
356
+ try {
357
+ await appendReceipt(
358
+ configuration.receipts,
359
+ skillHookReceipt(await boundedInput(), configuration),
360
+ );
361
+ } catch {
362
+ await writeFile(join(configuration.receipts, "partial"), randomUUID(), {
363
+ mode: 0o600,
364
+ });
365
+ }
366
+ }
367
+
368
+ if (import.meta.main) {
369
+ try {
370
+ await runSkillHook(process.argv[2] ?? "");
371
+ } catch {
372
+ // Missing receipts remain unavailable; hook failures never alter the task.
373
+ }
374
+ }