@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
@@ -1,60 +1,236 @@
1
- export const MAX_EVENT_BYTES = 8 * 1024 * 1024;
1
+ import { validHostTimeout } from "./native-request";
2
+ import { ProcessDeadline } from "../process-lifecycle";
3
+ import {
4
+ captureBoundedBytes,
5
+ captureBoundedText,
6
+ terminateOwnedProcessGroup,
7
+ } from "../process-lifecycle";
8
+ import { HostExecutionFailure, type HostResult } from "../evaluation/types";
9
+ import {
10
+ HostProcessDiagnostics,
11
+ type HostDiagnostics,
12
+ } from "../host-diagnostics";
2
13
 
3
- /** Bound bytes before decoding a host's output with strict UTF-8. */
4
- export async function boundedProcessText(
5
- stream: ReadableStream<Uint8Array>,
6
- limit: number,
7
- exceededMessage: string,
8
- ): Promise<string> {
9
- const reader = stream.getReader();
10
- const chunks: Uint8Array[] = [];
11
- let size = 0;
12
- for (;;) {
13
- const { value, done } = await reader.read();
14
- if (done) break;
15
- size += value.byteLength;
16
- if (size > limit) throw new Error(exceededMessage);
17
- chunks.push(value);
14
+ export const MAX_STDERR_BYTES = 64 * 1024;
15
+ export const MAX_EVENT_BYTES = 8 * 1024 * 1024;
16
+ class HostProcessInterruption extends Error {
17
+ constructor(
18
+ host: string,
19
+ readonly reason: "timeout" | "cancelled",
20
+ ) {
21
+ super(`${host} run ${reason === "timeout" ? "timed out" : "cancelled"}`);
18
22
  }
19
- return new TextDecoder("utf-8", { fatal: true }).decode(
20
- Buffer.concat(chunks),
21
- );
22
23
  }
23
24
 
24
25
  /** Race completion against the host deadline and cancellation, then release listeners. */
25
- export async function awaitHostProcess<T>(
26
+ async function awaitHostProcess<T>(
26
27
  proc: Bun.Subprocess,
27
28
  completed: Promise<T>,
28
- options: {
29
- host: "Codex" | "Claude";
30
- timeoutMs: number;
31
- signal?: AbortSignal;
32
- stop: (proc: Bun.Subprocess) => void;
33
- },
29
+ options: HostProcessOptions,
34
30
  ): Promise<T> {
35
- let timer: ReturnType<typeof setTimeout> | undefined;
36
- let cancel: (() => void) | undefined;
31
+ const deadline = new ProcessDeadline({
32
+ ...options,
33
+ interruption: (reason) => new HostProcessInterruption(options.host, reason),
34
+ });
37
35
  try {
38
- const timeout = new Promise<never>((_resolve, reject) => {
39
- timer = setTimeout(() => {
40
- reject(new Error(`${options.host} run timed out`));
41
- }, options.timeoutMs);
42
- });
43
- const aborted = new Promise<never>((_resolve, reject) => {
44
- if (!options.signal) return;
45
- cancel = () => {
46
- reject(new Error(`${options.host} run cancelled`));
47
- };
48
- if (options.signal.aborted) cancel();
49
- else options.signal.addEventListener("abort", cancel, { once: true });
50
- });
51
- return await Promise.race([completed, timeout, aborted]);
36
+ return await deadline.wait(completed);
52
37
  } catch (error) {
53
- options.stop(proc);
38
+ terminateOwnedProcessGroup(proc);
54
39
  await proc.exited;
55
40
  throw error;
56
41
  } finally {
57
- if (timer) clearTimeout(timer);
58
- if (cancel) options.signal?.removeEventListener("abort", cancel);
42
+ deadline.dispose();
43
+ }
44
+ }
45
+
46
+ export type Phase = NonNullable<HostResult["processFailure"]>["phase"];
47
+ export type HostProcessResult = {
48
+ code: number;
49
+ out: string;
50
+ stderr: Uint8Array;
51
+ stderrTruncated: boolean;
52
+ signal: string | null;
53
+ durationMs: number;
54
+ };
55
+
56
+ export function hostFailureMessage(
57
+ result: HostResult,
58
+ host: string,
59
+ ): string | undefined {
60
+ const failure = result.processFailure;
61
+ if (!failure) return undefined;
62
+ const labels = {
63
+ "isolation-preflight": `${host} isolation preflight`,
64
+ "executable-preflight": `${host} executable preflight`,
65
+ execution: `${host} run`,
66
+ };
67
+ if (!Object.hasOwn(labels, failure.phase)) return undefined;
68
+ return `${labels[failure.phase]} ${failureSuffix(failure)}`;
69
+ }
70
+
71
+ function failureSuffix(
72
+ failure: NonNullable<HostResult["processFailure"]>,
73
+ ): string {
74
+ if (failure.reason === "timeout" && validHostTimeout(failure.timeoutMs))
75
+ return `timed out after ${failure.timeoutMs / 1000} s`;
76
+ return failure.reason === "cancelled" ? "cancelled" : "failed";
77
+ }
78
+
79
+ export class HostProcessFailure extends HostExecutionFailure {
80
+ constructor(
81
+ host: "Codex" | "Claude",
82
+ phase: Phase,
83
+ process: HostProcessResult,
84
+ reason: NonNullable<HostResult["processFailure"]>["reason"] = "exit",
85
+ timeoutMs = 60_000,
86
+ ) {
87
+ const failure = { phase, reason, timeoutMs };
88
+ const result: HostResult = {
89
+ finalMessage: null,
90
+ complete: false,
91
+ executionFailed: true,
92
+ actualCondition: "passive",
93
+ processFailure: failure,
94
+ artifacts: [
95
+ {
96
+ id: `${host.toLowerCase()}.process`,
97
+ bytes: Buffer.from(
98
+ JSON.stringify({
99
+ ...failure,
100
+ code: process.code,
101
+ signal: process.signal,
102
+ durationMs: process.durationMs,
103
+ stderrTruncated: process.stderrTruncated,
104
+ }),
105
+ ),
106
+ },
107
+ { id: `${host.toLowerCase()}.stderr`, bytes: process.stderr },
108
+ ],
109
+ };
110
+ super(hostFailureMessage(result, host), result);
111
+ }
112
+ }
113
+
114
+ export interface HostProcessOptions {
115
+ host: "Codex" | "Claude";
116
+ diagnostics: HostDiagnostics;
117
+ argv: string[];
118
+ cwd: string;
119
+ env: Record<string, string>;
120
+ input?: string;
121
+ timeoutMs: number;
122
+ signal?: AbortSignal;
123
+ phase?: Phase;
124
+ }
125
+
126
+ async function writeHostInput(
127
+ proc: Bun.Subprocess,
128
+ input?: string,
129
+ ): Promise<void> {
130
+ if (input === undefined) return;
131
+ if (!proc.stdin || typeof proc.stdin === "number")
132
+ throw new Error("Codex input pipe unavailable");
133
+ await proc.stdin.write(input);
134
+ await proc.stdin.end();
135
+ }
136
+
137
+ function spawnHostProcess(
138
+ options: HostProcessOptions,
139
+ capture: HostProcessDiagnostics,
140
+ ) {
141
+ try {
142
+ return Bun.spawn(options.argv, {
143
+ cwd: options.cwd,
144
+ env: options.env,
145
+ detached: process.platform !== "win32",
146
+ stdin: options.input === undefined ? "ignore" : "pipe",
147
+ stdout: "pipe",
148
+ stderr: "pipe",
149
+ });
150
+ } catch (cause) {
151
+ capture.recordUnstarted();
152
+ throw cause;
153
+ }
154
+ }
155
+
156
+ function processPhase(options: HostProcessOptions): Phase {
157
+ return options.phase ?? "execution";
158
+ }
159
+
160
+ function requireHostProcess(options: HostProcessOptions): void {
161
+ if (options.signal?.aborted) throw new Error(`${options.host} run cancelled`);
162
+ }
163
+
164
+ function hostCompletion(
165
+ proc: ReturnType<typeof spawnHostProcess>,
166
+ options: HostProcessOptions,
167
+ capture: HostProcessDiagnostics,
168
+ ) {
169
+ const started = performance.now();
170
+ const stdout = captureBoundedText(proc.stdout, {
171
+ limit: MAX_EVENT_BYTES,
172
+ overflow: "reject",
173
+ exceeded: () =>
174
+ new Error(`${options.host} event stream exceeds the size limit`),
175
+ observe: (bytes) => {
176
+ capture.stdout.add(bytes);
177
+ },
178
+ });
179
+ const stderr = captureBoundedBytes(proc.stderr, {
180
+ limit: MAX_STDERR_BYTES,
181
+ overflow: "truncate",
182
+ cancelOnError: true,
183
+ observe: (bytes) => {
184
+ capture.stderr.add(bytes);
185
+ },
186
+ });
187
+ const input = writeHostInput(proc, options.input);
188
+ const completed = Promise.all([stdout, proc.exited, stderr, input]).then(
189
+ ([out, code, err]) => ({
190
+ out,
191
+ code,
192
+ stderr: err.bytes,
193
+ stderrTruncated: err.truncated,
194
+ signal: proc.signalCode ?? null,
195
+ durationMs: performance.now() - started,
196
+ }),
197
+ );
198
+ return { completed, drains: [stdout, stderr, input], started };
199
+ }
200
+
201
+ export async function runHostProcess(
202
+ options: HostProcessOptions,
203
+ ): Promise<HostProcessResult> {
204
+ requireHostProcess(options);
205
+ const capture = new HostProcessDiagnostics(options.diagnostics);
206
+ const proc = spawnHostProcess(options, capture);
207
+ const { completed, drains, started } = hostCompletion(proc, options, capture);
208
+ try {
209
+ const result = await awaitHostProcess(proc, completed, {
210
+ ...options,
211
+ });
212
+ await capture.record(proc);
213
+ return result;
214
+ } catch (cause) {
215
+ await Promise.allSettled(drains);
216
+ if (!(cause instanceof HostProcessInterruption)) {
217
+ await capture.record(proc, "error");
218
+ throw cause;
219
+ }
220
+ await capture.record(proc, cause.reason);
221
+ throw new HostProcessFailure(
222
+ options.host,
223
+ processPhase(options),
224
+ {
225
+ code: await proc.exited,
226
+ out: capture.stdout.bytes().toString("utf8"),
227
+ stderr: capture.stderr.bytes(),
228
+ stderrTruncated: capture.stderr.truncated,
229
+ signal: proc.signalCode ?? null,
230
+ durationMs: performance.now() - started,
231
+ },
232
+ cause.reason,
233
+ options.timeoutMs,
234
+ );
59
235
  }
60
236
  }
@@ -1,4 +1,4 @@
1
- import { isMissingFile } from "../fixture-tools";
1
+ import { isMissingFile } from "../fixtures/tools";
2
2
  import { lstat, readdir, realpath, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, isAbsolute, join } from "node:path";
@@ -0,0 +1,131 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import type { Dirent } from "node:fs";
5
+ import type { HookSkill } from "./skill-hook-handler";
6
+ import { isRecord } from "../value-guards";
7
+
8
+ const MAX_SKILLS = 128;
9
+ const MAX_SKILL_BYTES = 1024 * 1024;
10
+ const MAX_CATALOG_BYTES = 8 * 1024 * 1024;
11
+ interface Catalog {
12
+ skills: HookSkill[];
13
+ complete: boolean;
14
+ bytes: number;
15
+ }
16
+ export interface SkillCatalogRoot {
17
+ directory: string;
18
+ boundary: string;
19
+ namespace?: string;
20
+ }
21
+
22
+ export async function codexSkillCatalogRoots(
23
+ workspace: string,
24
+ pluginRoots: string[],
25
+ nativeSkillRoots: SkillCatalogRoot[],
26
+ ): Promise<SkillCatalogRoot[]> {
27
+ const roots: SkillCatalogRoot[] = [
28
+ { directory: join(workspace, ".agents/skills"), boundary: workspace },
29
+ ...nativeSkillRoots,
30
+ ];
31
+ for (const root of pluginRoots) {
32
+ const manifest: unknown = JSON.parse(
33
+ await readFile(join(root, ".claude-plugin/plugin.json"), "utf8"),
34
+ );
35
+ if (!isRecord(manifest) || typeof manifest.name !== "string")
36
+ throw new Error("invalid skill catalog plugin manifest");
37
+ roots.push({
38
+ directory: join(root, "skills"),
39
+ boundary: root,
40
+ namespace: manifest.name,
41
+ });
42
+ }
43
+ return roots;
44
+ }
45
+
46
+ /** Snapshot only known skill mounts; shell syntax does not enter discovery. */
47
+ export async function skillHookCatalog(roots: SkillCatalogRoot[]) {
48
+ const catalog: Catalog = { skills: [], complete: true, bytes: 0 };
49
+ for (const root of roots) await readRoot(root, catalog);
50
+ return { skills: catalog.skills, catalogComplete: catalog.complete };
51
+ }
52
+
53
+ async function readRoot(root: SkillCatalogRoot, catalog: Catalog) {
54
+ try {
55
+ await requireCatalogRoot(root);
56
+ const entries = await readdir(root.directory, { withFileTypes: true });
57
+ const directories = catalogDirectories(entries, catalog);
58
+ for (const entry of directories.sort((a, b) =>
59
+ a.name.localeCompare(b.name),
60
+ )) {
61
+ await readSkill(root, entry.name, catalog);
62
+ }
63
+ } catch (cause) {
64
+ if (!missing(cause)) catalog.complete = false;
65
+ }
66
+ }
67
+
68
+ function catalogDirectories(entries: Dirent[], catalog: Catalog): Dirent[] {
69
+ const directories: Dirent[] = [];
70
+ for (const entry of entries) {
71
+ if (entry.isSymbolicLink()) catalog.complete = false;
72
+ if (entry.isDirectory() && /^[A-Za-z0-9._-]+$/.test(entry.name))
73
+ directories.push(entry);
74
+ }
75
+ return directories;
76
+ }
77
+
78
+ async function requireCatalogRoot(root: SkillCatalogRoot) {
79
+ const expected = resolve(
80
+ await realpath(root.boundary),
81
+ relative(root.boundary, root.directory),
82
+ );
83
+ if ((await realpath(root.directory)) !== expected)
84
+ throw new Error("redirected skill catalog root");
85
+ }
86
+
87
+ function missing(cause: unknown): boolean {
88
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT";
89
+ }
90
+
91
+ async function readSkill(
92
+ root: SkillCatalogRoot,
93
+ name: string,
94
+ catalog: Catalog,
95
+ ) {
96
+ try {
97
+ const path = join(root.directory, name, "SKILL.md");
98
+ const file = await lstat(path);
99
+ if (!readableSkill(file)) {
100
+ catalog.complete = false;
101
+ return;
102
+ }
103
+ const bytes = await readFile(path);
104
+ if (bytes.length > MAX_SKILL_BYTES)
105
+ throw new Error("skill changed during catalog read");
106
+ addSkill(bytes, [root.namespace, name].filter(Boolean).join(":"), catalog);
107
+ } catch (cause) {
108
+ if (!missing(cause)) catalog.complete = false;
109
+ }
110
+ }
111
+
112
+ function readableSkill(file: Awaited<ReturnType<typeof lstat>>): boolean {
113
+ return file.isFile() && file.size <= MAX_SKILL_BYTES;
114
+ }
115
+
116
+ function addSkill(bytes: Buffer, invocation: string, catalog: Catalog) {
117
+ if (!bytes.length) return;
118
+ catalog.bytes += bytes.length;
119
+ if (
120
+ catalog.skills.length >= MAX_SKILLS ||
121
+ catalog.bytes > MAX_CATALOG_BYTES
122
+ ) {
123
+ catalog.complete = false;
124
+ return;
125
+ }
126
+ catalog.skills.push({
127
+ invocation,
128
+ sha256: createHash("sha256").update(bytes).digest("hex"),
129
+ body: bytes.toString("utf8"),
130
+ });
131
+ }