@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
package/src/prune.ts ADDED
@@ -0,0 +1,300 @@
1
+ import { lstat, readFile, readdir, realpath, rm, stat } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { assertCliResult, assertRunEvidence } from "./schema";
5
+ import type { CliResultData, RunEvidenceData } from "./schema-types";
6
+ import { createReport } from "./report";
7
+ import { atomicWriteJson } from "./storage";
8
+ import { isRecord } from "./value-guards";
9
+ import {
10
+ acquireResultsLock,
11
+ retainedFile,
12
+ withResultsLock,
13
+ } from "./results-lock";
14
+ import { pruneNativeTranscripts } from "./transcript-retention";
15
+
16
+ interface RetainedResult {
17
+ directory: string;
18
+ result: CliResultData;
19
+ evidence: RunEvidenceData;
20
+ time: number;
21
+ }
22
+
23
+ interface RetainedStorage {
24
+ directories: string[];
25
+ roots: string[];
26
+ releases: (() => void)[];
27
+ }
28
+
29
+ type RetentionPolicy =
30
+ { keep: number; cutoff: null } | { keep: null; cutoff: number };
31
+
32
+ const durationUnits: Record<string, number> = {
33
+ ms: 1,
34
+ s: 1000,
35
+ m: 60_000,
36
+ h: 3_600_000,
37
+ d: 86_400_000,
38
+ w: 604_800_000,
39
+ };
40
+
41
+ function duration(value: string): number {
42
+ const match = /^([1-9]\d*)(ms|s|m|h|d|w)$/.exec(value);
43
+ if (!match)
44
+ throw new Error(
45
+ "--older-than requires a positive integer and ms, s, m, h, d or w",
46
+ );
47
+ const amount =
48
+ Number.parseInt(value, 10) *
49
+ Number(durationUnits[value.replace(/^\d+/, "")]);
50
+ if (!Number.isSafeInteger(amount))
51
+ throw new Error("--older-than exceeds the safe duration range");
52
+ return amount;
53
+ }
54
+
55
+ function count(value: string): number {
56
+ const amount = Number(value);
57
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(amount))
58
+ throw new Error("--keep must be a positive safe integer");
59
+ return amount;
60
+ }
61
+
62
+ function parseInvocation(args: string[]) {
63
+ const { values } = parseArgs({
64
+ args,
65
+ strict: true,
66
+ options: {
67
+ "results-root": { type: "string" },
68
+ keep: { type: "string" },
69
+ latest: { type: "boolean" },
70
+ "older-than": { type: "string" },
71
+ "native-transcripts": { type: "boolean" },
72
+ },
73
+ });
74
+ return values;
75
+ }
76
+
77
+ function policy(values: ReturnType<typeof parseInvocation>): RetentionPolicy {
78
+ if (values.latest) return { keep: 1, cutoff: null };
79
+ if (values["older-than"] !== undefined)
80
+ return { keep: null, cutoff: Date.now() - duration(values["older-than"]) };
81
+ if (values.keep !== undefined)
82
+ return { keep: count(values.keep), cutoff: null };
83
+ return { keep: values["native-transcripts"] ? 0 : 10, cutoff: null };
84
+ }
85
+
86
+ function invocation(args: string[]) {
87
+ const values = parseInvocation(args);
88
+ const policies = [values.latest, values.keep, values["older-than"]].filter(
89
+ (value) => value !== undefined,
90
+ );
91
+ if (policies.length > 1) throw new Error("retention policies are exclusive");
92
+ if (values["results-root"] === "")
93
+ throw new Error("--results-root must not be empty");
94
+ return {
95
+ root: resolve(values["results-root"] ?? ".sevro/results"),
96
+ policy: policy(values),
97
+ nativeTranscripts: values["native-transcripts"] === true,
98
+ };
99
+ }
100
+
101
+ async function retainedResults(
102
+ root: string,
103
+ directories: string[],
104
+ ): Promise<RetainedResult[]> {
105
+ const results: RetainedResult[] = [];
106
+ for (const directory of directories) {
107
+ const path = join(directory, "result.json");
108
+ await retainedFile(path);
109
+ const result: unknown = JSON.parse(await readFile(path, "utf8"));
110
+ assertCliResult(result);
111
+ if (!(await finalizedAncestors(root, directory, result.runId))) continue;
112
+ if (result.evidencePath !== join(directory, "run.json"))
113
+ throw new Error(
114
+ `retained result references evidence outside its directory: ${path}`,
115
+ );
116
+ await retainedFile(result.evidencePath);
117
+ const evidence: unknown = JSON.parse(
118
+ await readFile(result.evidencePath, "utf8"),
119
+ );
120
+ assertRunEvidence(evidence);
121
+ await createReport([path]);
122
+ results.push({
123
+ directory,
124
+ result,
125
+ evidence,
126
+ time: (await stat(path)).mtimeMs,
127
+ });
128
+ }
129
+ return results.sort(
130
+ (left, right) =>
131
+ right.time - left.time || left.directory.localeCompare(right.directory),
132
+ );
133
+ }
134
+
135
+ async function resultDirectories(
136
+ parent: string,
137
+ storage: RetainedStorage,
138
+ ): Promise<void> {
139
+ let children = await resultChildren(parent);
140
+ if (
141
+ !storage.roots.includes(parent) &&
142
+ ((await resultsRoot(parent)) || children.results.length > 0)
143
+ ) {
144
+ storage.releases.push(await acquireResultsLock(parent));
145
+ storage.roots.push(parent);
146
+ children = await resultChildren(parent);
147
+ }
148
+ storage.directories.push(...children.results);
149
+ for (const directory of children.containers)
150
+ await resultDirectories(directory, storage);
151
+ }
152
+
153
+ async function resultChildren(parent: string) {
154
+ const results: string[] = [];
155
+ const containers: string[] = [];
156
+ const entries = await readdir(parent, { withFileTypes: true });
157
+ for (const entry of entries.sort((left, right) =>
158
+ left.name.localeCompare(right.name),
159
+ )) {
160
+ if (!entry.isDirectory()) continue;
161
+ const directory = join(parent, entry.name);
162
+ if (await retainedFile(join(directory, "result.json")))
163
+ results.push(directory);
164
+ else containers.push(directory);
165
+ }
166
+ return { results, containers };
167
+ }
168
+
169
+ async function resultsRoot(directory: string): Promise<boolean> {
170
+ const active = await lstat(join(directory, "active")).catch(
171
+ (cause: unknown) => {
172
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
173
+ throw cause;
174
+ },
175
+ );
176
+ if (active && !active.isDirectory())
177
+ throw new Error(`retained ownership must be a directory: ${directory}`);
178
+ return Boolean(
179
+ active || (await retainedFile(join(directory, ".retention-lock.sqlite"))),
180
+ );
181
+ }
182
+
183
+ async function finalized(root: string, runId: string | null): Promise<boolean> {
184
+ if (!runId || basename(runId) !== runId) return false;
185
+ const path = join(root, "active", `${runId}.json`);
186
+ if (!(await retainedFile(path))) return true;
187
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
188
+ if (!isRecord(value)) return false;
189
+ return finalizedRecord(value, runId);
190
+ }
191
+
192
+ async function finalizedAncestors(
193
+ root: string,
194
+ directory: string,
195
+ runId: string | null,
196
+ ): Promise<boolean> {
197
+ let ancestor = dirname(directory);
198
+ while (ancestor !== dirname(root)) {
199
+ if (!(await finalized(ancestor, runId))) return false;
200
+ ancestor = dirname(ancestor);
201
+ }
202
+ return true;
203
+ }
204
+
205
+ function finalizedRecord(
206
+ value: Record<string, unknown>,
207
+ runId: string,
208
+ ): boolean {
209
+ return (
210
+ value.format === "sevro.active-run.v1" &&
211
+ value.runId === runId &&
212
+ ["complete", "interrupted", "diagnostic"].includes(String(value.status))
213
+ );
214
+ }
215
+
216
+ function retainedByPolicy(results: RetainedResult[], policy: RetentionPolicy) {
217
+ const cutoff = policy.cutoff;
218
+ return cutoff === null
219
+ ? results.slice(0, policy.keep)
220
+ : results.filter((result) => result.time >= cutoff);
221
+ }
222
+
223
+ async function pruneResults(
224
+ root: string,
225
+ policy: RetentionPolicy,
226
+ storage: RetainedStorage,
227
+ ) {
228
+ const results = await retainedResults(root, storage.directories);
229
+ const remaining = retainedByPolicy(results, policy);
230
+ for (const resultsRoot of storage.roots) {
231
+ const latest = remaining.find((item) =>
232
+ item.directory.startsWith(`${resultsRoot}/`),
233
+ );
234
+ if (latest)
235
+ await atomicWriteJson(join(resultsRoot, "latest.json"), latest.result);
236
+ else await rm(join(resultsRoot, "latest.json"), { force: true });
237
+ }
238
+ const removed = results.filter((result) => !remaining.includes(result));
239
+ for (const result of removed) await rm(result.directory, { recursive: true });
240
+ return { removed: removed.length, kept: remaining.length };
241
+ }
242
+
243
+ async function pruneTranscripts(
244
+ root: string,
245
+ policy: RetentionPolicy,
246
+ directories: string[],
247
+ ) {
248
+ const results = await retainedResults(root, directories);
249
+ const preserved = retainedByPolicy(results, policy);
250
+ let removed = 0;
251
+ for (const { evidence } of results.filter(
252
+ (item) => !preserved.includes(item),
253
+ )) {
254
+ removed += await pruneNativeTranscripts(evidence);
255
+ }
256
+ return { removed, kept: results.length };
257
+ }
258
+
259
+ async function prune(options: ReturnType<typeof invocation>) {
260
+ const root = await realpath(options.root).catch((cause: unknown) => {
261
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
262
+ throw cause;
263
+ });
264
+ if (root === null) return { removed: 0, kept: 0 };
265
+ return withResultsLock(root, async () => {
266
+ const storage: RetainedStorage = {
267
+ directories: [],
268
+ roots: [root],
269
+ releases: [],
270
+ };
271
+ try {
272
+ await resultDirectories(root, storage);
273
+ return options.nativeTranscripts
274
+ ? await pruneTranscripts(root, options.policy, storage.directories)
275
+ : await pruneResults(root, options.policy, storage);
276
+ } finally {
277
+ for (const release of storage.releases.reverse()) release();
278
+ }
279
+ });
280
+ }
281
+
282
+ /** Explicitly remove old retained runs while keeping report's latest index usable. */
283
+ export async function pruneCommand(args: string[]): Promise<number> {
284
+ try {
285
+ const options = invocation(args);
286
+ const result = await prune(options);
287
+ const unit = options.nativeTranscripts
288
+ ? "native transcript bundle(s)"
289
+ : "result(s)";
290
+ process.stdout.write(
291
+ `Removed ${result.removed} ${unit}; kept ${result.kept}${options.nativeTranscripts ? " result(s)" : ""}.\n`,
292
+ );
293
+ return 0;
294
+ } catch (error) {
295
+ process.stderr.write(
296
+ `sevro prune: ${error instanceof Error ? error.message : "pruning failed"}\n`,
297
+ );
298
+ return 64;
299
+ }
300
+ }
package/src/report.ts CHANGED
@@ -1,38 +1,22 @@
1
- import type { CliResultData, RunEvidenceData } from "./schema-types";
1
+ import type {
2
+ CliResultData,
3
+ ReportData,
4
+ RunEvidenceData,
5
+ } from "./schema-types";
2
6
  import { readFile } from "node:fs/promises";
3
- import { isAbsolute } from "node:path";
7
+ import { isAbsolute, join, resolve } from "node:path";
4
8
  import { parseArgs } from "node:util";
5
9
  import { canonicalJson } from "./identity";
6
10
  import { assertCliResult, assertReport, assertRunEvidence } from "./schema";
11
+ import { canonicalRuntimeRoot } from "./runtime/paths";
12
+ import { nativeTranscriptRetention } from "./transcript-retention";
7
13
 
8
14
  type CliResult = CliResultData;
9
15
  type Evidence = RunEvidenceData;
10
16
  type Case = CliResult["cases"][number];
11
17
  type Trial = Case["trials"][number];
12
18
 
13
- export type ReportRow = {
14
- resultFile: string;
15
- evidencePath: string | null;
16
- runId: string | null;
17
- caseId: string;
18
- execution: string;
19
- grading: string;
20
- task: string;
21
- exitCode: number;
22
- trialCount: number;
23
- taskPassRate: number | null;
24
- candidateDurationMs: number | null;
25
- inputTokens: number | null;
26
- outputTokens: number | null;
27
- costUsd: number | null;
28
- runnerBuildDigest: string | null;
29
- evaluationDigest: string | null;
30
- requestedCondition: string | null;
31
- actualCondition: string | null;
32
- candidateRoute: { host: string; model: string; effort: string } | null;
33
- routes: Array<{ role: string; host: string; model: string; effort: string }>;
34
- domainOutcomes: Array<{ trial: number; id: string; status: string }>;
35
- };
19
+ export type ReportRow = ReportData["rows"][number];
36
20
 
37
21
  function sumKnown(values: Array<number | null | undefined>): number | null {
38
22
  return values.length && values.every((value) => value != null)
@@ -113,6 +97,7 @@ function reportRow(
113
97
  result: CliResult,
114
98
  selected: Case,
115
99
  evidence: Evidence | null,
100
+ transcripts: Awaited<ReturnType<typeof nativeTranscriptRetention>>,
116
101
  ): ReportRow {
117
102
  const retained = trialEvidence(selected.caseId, selected.trials, evidence);
118
103
  const candidateRoute = retainedCandidateRoute(evidence);
@@ -134,6 +119,14 @@ function reportRow(
134
119
  ...usage,
135
120
  ...retainedIdentity(evidence),
136
121
  candidateRoute,
122
+ nativeTranscripts: {
123
+ retained: transcripts.filter(
124
+ (item) => item.caseId === selected.caseId && !item.pruned,
125
+ ).length,
126
+ pruned: transcripts.filter(
127
+ (item) => item.caseId === selected.caseId && item.pruned,
128
+ ).length,
129
+ },
137
130
  domainOutcomes: selected.trials.flatMap((trial) =>
138
131
  (trial.domainOutcomes ?? []).map((outcome) => ({
139
132
  trial: trial.trial,
@@ -191,17 +184,11 @@ export async function createReport(paths: string[]) {
191
184
  if (!paths.length || new Set(paths).size !== paths.length)
192
185
  throw new Error("report needs distinct result files");
193
186
  const rows: ReportRow[] = [];
194
- const inputs: Array<{
195
- resultFile: string;
196
- execution: string;
197
- grading: string;
198
- task: string;
199
- exitCode: number;
200
- caseCount: number;
201
- }> = [];
187
+ const inputs: ReportData["inputs"] = [];
202
188
  for (const path of paths) {
203
189
  const result = await loadResult(path);
204
190
  const evidence = await loadEvidence(result);
191
+ const transcripts = await nativeTranscriptRetention(evidence);
205
192
  inputs.push({
206
193
  resultFile: path,
207
194
  execution: result.execution.status,
@@ -211,7 +198,7 @@ export async function createReport(paths: string[]) {
211
198
  caseCount: result.cases.length,
212
199
  });
213
200
  for (const selected of result.cases)
214
- rows.push(reportRow(path, result, selected, evidence));
201
+ rows.push(reportRow(path, result, selected, evidence, transcripts));
215
202
  }
216
203
  const report = {
217
204
  format: "sevro.report.v1",
@@ -268,7 +255,8 @@ export function renderReport(
268
255
  "## Retained evidence",
269
256
  "",
270
257
  ...report.rows.map(
271
- (row) => `- ${cell(row.caseId)}: ${row.evidencePath ?? "unavailable"}`,
258
+ (row) =>
259
+ `- ${cell(row.caseId)}: ${row.evidencePath ?? "unavailable"}${transcriptSummary(row)}`,
272
260
  ),
273
261
  "",
274
262
  ];
@@ -305,6 +293,11 @@ export function renderReport(
305
293
  return lines.join("\n");
306
294
  }
307
295
 
296
+ function transcriptSummary(row: ReportRow): string {
297
+ if (!row.nativeTranscripts) return "";
298
+ return `; native transcripts: ${row.nativeTranscripts.retained} retained, ${row.nativeTranscripts.pruned} pruned`;
299
+ }
300
+
308
301
  function passRateCell(rate: number | null): string {
309
302
  return rate === null ? "unknown" : `${(rate * 100).toFixed(0)}%`;
310
303
  }
@@ -326,7 +319,7 @@ function caseOutcomeLine(row: ReportRow): string {
326
319
  export async function reportCommand(argv: string[]): Promise<number> {
327
320
  try {
328
321
  const values = reportInvocation(argv);
329
- const report = await createReport(values["result-file"] ?? []);
322
+ const report = await createReport(await reportPaths(values));
330
323
  process.stdout.write(
331
324
  values.json ? `${JSON.stringify(report)}\n` : renderReport(report),
332
325
  );
@@ -345,12 +338,46 @@ function reportInvocation(argv: string[]) {
345
338
  options: {
346
339
  json: { type: "boolean" },
347
340
  "result-file": { type: "string", multiple: true },
341
+ latest: { type: "boolean" },
342
+ "results-root": { type: "string" },
348
343
  },
349
344
  });
350
345
  if (positionals.length !== 1 || positionals[0] !== "report")
351
346
  throw new Error("expected the report command");
352
347
  return values;
353
348
  }
349
+
350
+ async function reportPaths(
351
+ values: ReturnType<typeof reportInvocation>,
352
+ ): Promise<string[]> {
353
+ validateReportSource(values);
354
+ const paths = selectedReportPaths(values);
355
+ if (paths.some((path) => !path.trim()))
356
+ throw new Error("--result-file must name a readable retained result file");
357
+ return Promise.all(paths.map((path) => canonicalRuntimeRoot(resolve(path))));
358
+ }
359
+
360
+ function validateReportSource(
361
+ values: ReturnType<typeof reportInvocation>,
362
+ ): void {
363
+ if (values.latest && values["result-file"] !== undefined)
364
+ throw new Error("--latest and --result-file are exclusive");
365
+ if (values["results-root"] !== undefined && !values.latest)
366
+ throw new Error("--results-root requires --latest");
367
+ }
368
+
369
+ function selectedReportPaths(
370
+ values: ReturnType<typeof reportInvocation>,
371
+ ): string[] {
372
+ return values.latest
373
+ ? [
374
+ join(
375
+ values["results-root"] ?? join(process.cwd(), ".sevro", "results"),
376
+ "latest.json",
377
+ ),
378
+ ]
379
+ : (values["result-file"] ?? []);
380
+ }
354
381
  function reportErrorMessage(error: unknown): string {
355
382
  return error instanceof Error ? error.message : "invalid report input";
356
383
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCase } from "./extension-session";
2
- import type { RepositoryFixture } from "./repository-fixture";
2
+ import type { RepositoryFixture } from "./fixtures/repository";
3
3
 
4
4
  function repositoryOverlay(fixture: RepositoryFixture) {
5
5
  return {
@@ -0,0 +1,42 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { join } from "node:path";
3
+ import { lstat } from "node:fs/promises";
4
+
5
+ export async function retainedFile(path: string) {
6
+ const info = await lstat(path).catch((cause: unknown) => {
7
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
8
+ throw cause;
9
+ });
10
+ if (info && !info.isFile())
11
+ throw new Error(`retained storage must be a regular file: ${path}`);
12
+ return info;
13
+ }
14
+
15
+ /** Serialize result publication and pruning across CLI processes. Closing rolls back the lock transaction. */
16
+ export async function withResultsLock<T>(
17
+ root: string,
18
+ action: () => Promise<T>,
19
+ ): Promise<T> {
20
+ const release = await acquireResultsLock(root);
21
+ try {
22
+ return await action();
23
+ } finally {
24
+ release();
25
+ }
26
+ }
27
+
28
+ /** Hold a root lock while discovering and validating additional nested roots. */
29
+ export async function acquireResultsLock(root: string): Promise<() => void> {
30
+ await retainedFile(join(root, ".retention-lock.sqlite"));
31
+ const database = new Database(join(root, ".retention-lock.sqlite"));
32
+ try {
33
+ database.run("PRAGMA busy_timeout = 5000");
34
+ database.run("BEGIN IMMEDIATE");
35
+ return () => {
36
+ database.close();
37
+ };
38
+ } catch (cause) {
39
+ database.close();
40
+ throw cause;
41
+ }
42
+ }
package/src/results.ts CHANGED
@@ -1,21 +1,17 @@
1
- export type ExecutionStatus = "completed" | "failed" | "cancelled" | "not_run";
2
- export type GradingStatus =
3
- "completed" | "error" | "unavailable" | "not_requested";
4
- export type TaskVerdict = "passed" | "failed" | "not_assessed";
5
-
6
- export interface CheckOutcome {
7
- id: string;
8
- grader: string;
9
- status: "passed" | "failed" | "unavailable";
10
- detail?: string;
11
- evidenceRefs?: string[];
12
- }
13
-
14
- export interface Assessment {
15
- execution: { status: ExecutionStatus };
16
- grading: { status: GradingStatus };
17
- task: { verdict: TaskVerdict };
18
- }
1
+ import type {
2
+ Assessment,
3
+ CheckOutcome,
4
+ ExecutionStatus,
5
+ GradingStatus,
6
+ TaskVerdict,
7
+ } from "./contract-types";
8
+ export type {
9
+ Assessment,
10
+ CheckOutcome,
11
+ ExecutionStatus,
12
+ GradingStatus,
13
+ TaskVerdict,
14
+ } from "./contract-types";
19
15
 
20
16
  export interface TrialInput {
21
17
  execution: ExecutionStatus;
package/src/run-owner.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  import { hostname } from "node:os";
12
12
  import { basename, dirname, join } from "node:path";
13
13
 
14
- interface ProcessIdentity {
14
+ export interface ProcessIdentity {
15
15
  pid: number;
16
16
  startedAt: string;
17
17
  host: string;
@@ -163,7 +163,7 @@ function processStartTime(result: ReturnType<typeof Bun.spawnSync>): string {
163
163
  return result.stdout?.toString().trim() ?? "";
164
164
  }
165
165
 
166
- function processIdentity(pid: number): ProcessIdentity | null {
166
+ export function processIdentity(pid: number): ProcessIdentity | null {
167
167
  const windows = process.platform === "win32";
168
168
  const argv = identityCommand(pid, windows);
169
169
  let result: ReturnType<typeof Bun.spawnSync>;
@@ -186,7 +186,7 @@ function validProcessId(value: unknown): value is number {
186
186
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
187
187
  }
188
188
 
189
- function validProcessIdentity(value: unknown): value is ProcessIdentity {
189
+ export function validProcessIdentity(value: unknown): value is ProcessIdentity {
190
190
  return (
191
191
  isRecord(value) &&
192
192
  value.host === hostname() &&
@@ -204,7 +204,7 @@ function processLiveness(pid: number): "live" | "dead" | "unknown" {
204
204
  }
205
205
  }
206
206
 
207
- function ownerStatus(owner: unknown): "live" | "dead" | "unknown" {
207
+ export function ownerStatus(owner: unknown): "live" | "dead" | "unknown" {
208
208
  if (!validProcessIdentity(owner)) return "unknown";
209
209
  const liveness = processLiveness(owner.pid);
210
210
  if (liveness !== "live") return liveness;