@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
@@ -1,5 +1,19 @@
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";
13
+
14
+ export const MAX_STDERR_BYTES = 64 * 1024;
1
15
  export const MAX_EVENT_BYTES = 8 * 1024 * 1024;
2
- export class HostProcessInterruption extends Error {
16
+ class HostProcessInterruption extends Error {
3
17
  constructor(
4
18
  host: string,
5
19
  readonly reason: "timeout" | "cancelled",
@@ -8,61 +22,215 @@ export class HostProcessInterruption extends Error {
8
22
  }
9
23
  }
10
24
 
11
- /** Bound bytes before decoding a host's output with strict UTF-8. */
12
- export async function boundedProcessText(
13
- stream: ReadableStream<Uint8Array>,
14
- limit: number,
15
- exceededMessage: string,
16
- ): Promise<string> {
17
- const reader = stream.getReader();
18
- const chunks: Uint8Array[] = [];
19
- let size = 0;
20
- for (;;) {
21
- const { value, done } = await reader.read();
22
- if (done) break;
23
- size += value.byteLength;
24
- if (size > limit) throw new Error(exceededMessage);
25
- chunks.push(value);
26
- }
27
- return new TextDecoder("utf-8", { fatal: true }).decode(
28
- Buffer.concat(chunks),
29
- );
30
- }
31
-
32
25
  /** Race completion against the host deadline and cancellation, then release listeners. */
33
- export async function awaitHostProcess<T>(
26
+ async function awaitHostProcess<T>(
34
27
  proc: Bun.Subprocess,
35
28
  completed: Promise<T>,
36
- options: {
37
- host: "Codex" | "Claude";
38
- timeoutMs: number;
39
- signal?: AbortSignal;
40
- stop: (proc: Bun.Subprocess) => void;
41
- },
29
+ options: HostProcessOptions,
42
30
  ): Promise<T> {
43
- let timer: ReturnType<typeof setTimeout> | undefined;
44
- let cancel: (() => void) | undefined;
31
+ const deadline = new ProcessDeadline({
32
+ ...options,
33
+ interruption: (reason) => new HostProcessInterruption(options.host, reason),
34
+ });
45
35
  try {
46
- const timeout = new Promise<never>((_resolve, reject) => {
47
- timer = setTimeout(() => {
48
- reject(new HostProcessInterruption(options.host, "timeout"));
49
- }, options.timeoutMs);
50
- });
51
- const aborted = new Promise<never>((_resolve, reject) => {
52
- if (!options.signal) return;
53
- cancel = () => {
54
- reject(new HostProcessInterruption(options.host, "cancelled"));
55
- };
56
- if (options.signal.aborted) cancel();
57
- else options.signal.addEventListener("abort", cancel, { once: true });
58
- });
59
- return await Promise.race([completed, timeout, aborted]);
36
+ return await deadline.wait(completed);
60
37
  } catch (error) {
61
- options.stop(proc);
38
+ terminateOwnedProcessGroup(proc);
62
39
  await proc.exited;
63
40
  throw error;
64
41
  } finally {
65
- if (timer) clearTimeout(timer);
66
- 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
+ );
67
235
  }
68
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
+ }