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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CONTRIBUTING.md +170 -0
  2. package/LICENSE +63 -136
  3. package/README.md +83 -66
  4. package/docs/README.md +44 -0
  5. package/docs/acknowledgements.md +14 -0
  6. package/docs/architecture.md +146 -0
  7. package/docs/assets/README.md +27 -0
  8. package/docs/assets/sevro-logo.png +0 -0
  9. package/docs/assets/sevro-symbol.svg +1 -0
  10. package/docs/assets/sevro-wordmark.svg +1 -0
  11. package/docs/builtin-graders-v1.md +41 -13
  12. package/docs/codex-host.md +184 -52
  13. package/docs/contracts.md +19 -0
  14. package/docs/creating-cases.md +139 -0
  15. package/docs/development-cli.md +190 -30
  16. package/docs/documentation-quality.md +61 -0
  17. package/docs/evaluation-workflow.md +110 -0
  18. package/docs/evidence/code-mode-controls-71.md +64 -0
  19. package/docs/evidence/code-mode-programs-85.md +64 -0
  20. package/docs/evidence/codex-state-73/README.md +204 -0
  21. package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
  22. package/docs/evidence/codex-state-73/results.json +661 -0
  23. package/docs/evidence/local-http-74.md +104 -0
  24. package/docs/evidence/runtime-startup-30/README.md +185 -0
  25. package/docs/evidence/runtime-startup-30/after.json +319 -0
  26. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
  27. package/docs/evidence/runtime-startup-30/before.json +403 -0
  28. package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
  29. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
  30. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
  31. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
  32. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
  33. package/docs/evidence/skill-reads-70/results.json +823 -0
  34. package/docs/evidence.md +21 -0
  35. package/docs/extension-protocol-v1.md +50 -16
  36. package/docs/fixture-cleanup-validation.md +3 -0
  37. package/docs/getting-started.md +102 -0
  38. package/docs/guide-evaluation.md +89 -0
  39. package/docs/guide-verification.json +239 -0
  40. package/docs/identity-v1.md +35 -1
  41. package/docs/installing.md +88 -0
  42. package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
  43. package/docs/licensing.md +42 -0
  44. package/docs/native-controls.md +76 -5
  45. package/docs/native-hosts.md +228 -0
  46. package/docs/reading-results.md +141 -0
  47. package/docs/recipes.md +489 -0
  48. package/docs/releases.md +81 -40
  49. package/docs/report-v1.md +29 -1
  50. package/docs/results-v1.md +180 -0
  51. package/docs/run-settings-v1.md +147 -0
  52. package/docs/running-evaluations.md +31 -0
  53. package/docs/runtime-v1.md +614 -0
  54. package/docs/specs/repository-guide.md +58 -0
  55. package/docs/troubleshooting.md +30 -0
  56. package/docs/typescript-quality.md +234 -0
  57. package/docs/usability-study.md +114 -0
  58. package/examples/basic/host.ts +2 -2
  59. package/examples/recipes/candidate.ts +26 -0
  60. package/examples/recipes/cases/coding-change.json +21 -0
  61. package/examples/recipes/cases/host-tools.json +21 -0
  62. package/examples/recipes/cases/mise.json +21 -0
  63. package/examples/recipes/cases/node.json +21 -0
  64. package/examples/recipes/cases/python-uv.json +21 -0
  65. package/examples/recipes/cases/semantic.json +17 -0
  66. package/examples/recipes/coding-change/check.ts +23 -0
  67. package/examples/recipes/extension-artifacts.ts +65 -0
  68. package/examples/recipes/extension-methods.ts +174 -0
  69. package/examples/recipes/extension.ts +37 -0
  70. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  71. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  72. package/examples/recipes/repository-skill/README.md +3 -0
  73. package/examples/recipes/run-inputs.ts +188 -0
  74. package/examples/recipes/run-options.ts +131 -0
  75. package/examples/recipes/run.ts +63 -0
  76. package/examples/recipes/runtime/empty.json +1 -0
  77. package/examples/recipes/runtime/goals.json +1 -0
  78. package/examples/recipes/runtime/mise.json +6 -0
  79. package/examples/recipes/runtime/path.json +1 -0
  80. package/examples/recipes/runtime/plugins.json +4 -0
  81. package/examples/recipes/runtime/python-uv.json +10 -0
  82. package/examples/recipes/semantic-judge.ts +29 -0
  83. package/examples/templates/coding/adapter.ts +17 -0
  84. package/examples/templates/coding/case.json +15 -0
  85. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  86. package/examples/templates/repository-skill/extension-data.ts +64 -0
  87. package/examples/templates/repository-skill/extension.ts +31 -0
  88. package/examples/templates/response/adapter.ts +12 -0
  89. package/examples/templates/response/case.json +13 -0
  90. package/package.json +39 -6
  91. package/schemas/report-v1.schema.json +9 -0
  92. package/schemas/run-evidence-v1.schema.json +2 -1
  93. package/schemas/runtime-v1.schema.json +193 -0
  94. package/src/advisory.ts +10 -5
  95. package/src/candidate-artifacts.ts +208 -0
  96. package/src/candidate-codex-state.ts +185 -0
  97. package/src/case-validation.ts +105 -0
  98. package/src/cli/cancellation.ts +20 -0
  99. package/src/cli/command.ts +81 -0
  100. package/src/cli/completion.ts +15 -0
  101. package/src/cli/demo.ts +59 -0
  102. package/src/cli/evaluation.ts +55 -0
  103. package/src/cli/extension.ts +79 -0
  104. package/src/cli/help.ts +222 -0
  105. package/src/cli/hosts.ts +59 -0
  106. package/src/cli/init-settings.ts +30 -0
  107. package/src/cli/init.ts +111 -0
  108. package/src/cli/invocation.ts +627 -0
  109. package/src/cli/loading.ts +151 -0
  110. package/src/cli/preparation.ts +163 -0
  111. package/src/cli/presentation.ts +91 -0
  112. package/src/cli/progress.ts +6 -0
  113. package/src/cli/run-manifest.ts +133 -0
  114. package/src/cli/run-options.ts +101 -0
  115. package/src/cli/run-overrides.ts +83 -0
  116. package/src/cli/settings.ts +160 -0
  117. package/src/cli/summary.ts +43 -0
  118. package/src/cli.ts +13 -843
  119. package/src/contract-types.ts +94 -0
  120. package/src/engine.ts +19 -2373
  121. package/src/evaluation/advisory.ts +150 -0
  122. package/src/evaluation/check-plan.ts +510 -0
  123. package/src/evaluation/collection.ts +88 -0
  124. package/src/evaluation/context.ts +10 -0
  125. package/src/evaluation/grading-host.ts +39 -0
  126. package/src/evaluation/grading.ts +258 -0
  127. package/src/evaluation/invocation-plan.ts +548 -0
  128. package/src/evaluation/plan.ts +736 -0
  129. package/src/evaluation/progress.ts +19 -0
  130. package/src/evaluation/resources.ts +241 -0
  131. package/src/evaluation/run.ts +206 -0
  132. package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
  133. package/src/evaluation/semantic.ts +312 -0
  134. package/src/evaluation/trial.ts +866 -0
  135. package/src/evaluation/types.ts +207 -0
  136. package/src/evidence/host-results.ts +92 -0
  137. package/src/evidence/retained-artifacts.ts +18 -0
  138. package/src/evidence/trial-writer.ts +203 -0
  139. package/src/evidence/usage.ts +17 -0
  140. package/src/extension-client.ts +228 -148
  141. package/src/extension-session.ts +326 -195
  142. package/src/fixtures/advisory.ts +338 -0
  143. package/src/fixtures/bin.ts +57 -0
  144. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  145. package/src/fixtures/create.ts +187 -0
  146. package/src/fixtures/generated.ts +300 -0
  147. package/src/fixtures/git-hooks.ts +24 -0
  148. package/src/fixtures/preparation.ts +270 -0
  149. package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
  150. package/src/fixtures/setup.ts +224 -0
  151. package/src/fixtures/tools.ts +88 -0
  152. package/src/fixtures/workspace-state.ts +212 -0
  153. package/src/generated/cli-result.cjs +2 -0
  154. package/src/generated/cli-result.d.cts +3 -0
  155. package/src/generated/extension.cjs +2 -0
  156. package/src/generated/extension.d.cts +3 -0
  157. package/src/generated/report.cjs +2 -0
  158. package/src/generated/report.d.cts +3 -0
  159. package/src/generated/run-evidence.cjs +2 -0
  160. package/src/generated/run-evidence.d.cts +3 -0
  161. package/src/generated/runtime.cjs +2 -0
  162. package/src/generated/runtime.d.cts +3 -0
  163. package/src/graders/artifact.ts +56 -23
  164. package/src/graders/git-head.ts +79 -75
  165. package/src/graders/output.ts +222 -140
  166. package/src/graders/semantic.ts +102 -43
  167. package/src/graders/shell.ts +379 -207
  168. package/src/host-diagnostic-redaction.ts +52 -0
  169. package/src/host-diagnostics.ts +319 -0
  170. package/src/hosts/claude-continuation.ts +63 -18
  171. package/src/hosts/claude-credential.ts +43 -62
  172. package/src/hosts/claude-events.ts +86 -51
  173. package/src/hosts/claude-keychain.ts +59 -0
  174. package/src/hosts/claude-native-goal.ts +57 -33
  175. package/src/hosts/claude-process.ts +111 -0
  176. package/src/hosts/claude-repository-invocation.ts +208 -117
  177. package/src/hosts/claude-settings.ts +240 -21
  178. package/src/hosts/claude-skill-activations.ts +166 -0
  179. package/src/hosts/claude-state.ts +403 -0
  180. package/src/hosts/claude-tool-calls.ts +171 -106
  181. package/src/hosts/claude.ts +561 -451
  182. package/src/hosts/codex-app-server.ts +326 -178
  183. package/src/hosts/codex-configuration.ts +44 -18
  184. package/src/hosts/codex-events.ts +97 -60
  185. package/src/hosts/codex-exec-controls.ts +516 -0
  186. package/src/hosts/codex-invocation.ts +70 -0
  187. package/src/hosts/codex-marketplace.ts +283 -0
  188. package/src/hosts/codex-native-calls.ts +546 -500
  189. package/src/hosts/codex-process.ts +202 -0
  190. package/src/hosts/codex-profile.ts +257 -28
  191. package/src/hosts/codex-session-files.ts +106 -0
  192. package/src/hosts/codex-state.ts +492 -0
  193. package/src/hosts/codex.ts +783 -832
  194. package/src/hosts/isolation-common.ts +94 -0
  195. package/src/hosts/isolation-roots.ts +54 -27
  196. package/src/hosts/isolation.ts +10 -0
  197. package/src/hosts/linux-sandbox.ts +159 -0
  198. package/src/hosts/mac-sandbox.ts +187 -61
  199. package/src/hosts/native-controls.ts +152 -62
  200. package/src/hosts/process-lifecycle.ts +37 -0
  201. package/src/hosts/protected-worktrees.ts +51 -19
  202. package/src/hosts/skill-hook-catalog.ts +131 -0
  203. package/src/hosts/skill-hook-handler.ts +374 -0
  204. package/src/hosts/skill-hooks.ts +580 -0
  205. package/src/hosts/workspace-fingerprint.ts +55 -35
  206. package/src/identity.ts +102 -48
  207. package/src/instrumentation.ts +109 -48
  208. package/src/native-transcript-state.ts +131 -0
  209. package/src/native-transcript-view.ts +90 -0
  210. package/src/native-transcripts.ts +286 -0
  211. package/src/process-lifecycle.ts +150 -0
  212. package/src/provenance.ts +161 -81
  213. package/src/prune.ts +215 -0
  214. package/src/report.ts +172 -140
  215. package/src/resolved-case.ts +33 -0
  216. package/src/results-lock.ts +29 -0
  217. package/src/results.ts +133 -86
  218. package/src/run-owner.ts +154 -63
  219. package/src/runtime/config.ts +456 -0
  220. package/src/runtime/discovery-apple.ts +90 -0
  221. package/src/runtime/discovery-providers.ts +63 -0
  222. package/src/runtime/discovery.ts +70 -0
  223. package/src/runtime/hook-processes.ts +97 -0
  224. package/src/runtime/hooks.ts +476 -0
  225. package/src/runtime/paths.ts +313 -0
  226. package/src/runtime/query.ts +85 -0
  227. package/src/runtime/seeds.ts +134 -0
  228. package/src/runtime/shared-cache.ts +143 -0
  229. package/src/runtime/state.ts +143 -0
  230. package/src/schema-types.ts +198 -0
  231. package/src/schema.ts +15 -19
  232. package/src/transcript-retention.ts +146 -0
  233. package/src/value-guards.ts +20 -0
  234. package/src/advisory-fixture.ts +0 -282
  235. package/src/fixture-bin.ts +0 -87
  236. package/src/fixture-setup.ts +0 -163
  237. package/src/generated-fixture.ts +0 -220
  238. package/src/git-hooks.ts +0 -63
  239. package/src/hosts/claude-nested-skills.ts +0 -218
  240. package/src/hosts/codex-command-output.ts +0 -399
  241. package/src/hosts/codex-executor-command.ts +0 -195
  242. package/src/hosts/codex-skill-reads.ts +0 -391
  243. package/src/preparation.ts +0 -211
package/src/provenance.ts CHANGED
@@ -1,42 +1,57 @@
1
- import { createHash } from "node:crypto";
1
+ import type { Stats } from "node:fs";
2
2
  import { lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
3
4
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import packageJson from "../package.json";
6
- import { hashJson } from "./identity";
7
+ import { hashJson, sha256 } from "./identity";
8
+ import { insideRuntimeRoot } from "./runtime/paths";
7
9
 
8
10
  const MAX_DIRTY_BYTES = 32 * 1024 * 1024;
9
11
  const MAX_BUILD_BYTES = 64 * 1024 * 1024;
10
12
  const MAX_BUILD_FILES = 4096;
11
13
 
12
- function sha256(bytes: Uint8Array): string {
13
- return createHash("sha256").update(bytes).digest("hex");
14
+ type BuildFile = { path: string; sha256: string };
15
+ type ProjectFile = BuildFile & { executable: boolean };
16
+ function fileOrder(left: BuildFile, right: BuildFile): number {
17
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
14
18
  }
15
19
 
16
- /** Digest the packaged runtime and public contract files in path order. */
17
- export async function packageBuildDigest(
18
- root = join(import.meta.dir, ".."),
19
- ): Promise<string> {
20
- const canonicalRoot = await realpath(root);
21
- const files: { path: string; sha256: string }[] = [];
22
- let totalBytes = 0;
23
- async function collect(path: string): Promise<void> {
24
- const absolute = join(canonicalRoot, path);
25
- const info = await lstat(absolute);
20
+ class BuildSnapshot {
21
+ readonly files: BuildFile[] = [];
22
+ private bytes = 0;
23
+ constructor(private root: string) {}
24
+ async collect(path: string): Promise<void> {
25
+ const absolute = join(this.root, path),
26
+ info = await lstat(absolute);
26
27
  if (info.isSymbolicLink())
27
28
  throw new Error("package build contains a symbolic link");
28
29
  if (info.isDirectory()) {
29
30
  for (const entry of (await readdir(absolute)).sort())
30
- await collect(join(path, entry));
31
+ await this.collect(join(path, entry));
31
32
  return;
32
33
  }
34
+ await this.collectFile(path, absolute, info);
35
+ }
36
+ private async collectFile(
37
+ path: string,
38
+ absolute: string,
39
+ info: Stats,
40
+ ): Promise<void> {
33
41
  if (!info.isFile()) throw new Error("package build contains a non-file");
34
42
  const bytes = await readFile(absolute);
35
- totalBytes += bytes.byteLength;
36
- if (files.length >= MAX_BUILD_FILES || totalBytes > MAX_BUILD_BYTES)
43
+ this.bytes += bytes.byteLength;
44
+ if (this.files.length >= MAX_BUILD_FILES || this.bytes > MAX_BUILD_BYTES)
37
45
  throw new Error("package build exceeds the size limit");
38
- files.push({ path: path.split(sep).join("/"), sha256: sha256(bytes) });
46
+ this.files.push({ path: path.split(sep).join("/"), sha256: sha256(bytes) });
39
47
  }
48
+ }
49
+
50
+ /** Digest the packaged runtime and public contract files in path order. */
51
+ export async function packageBuildDigest(
52
+ root = join(import.meta.dir, ".."),
53
+ ): Promise<string> {
54
+ const snapshot = new BuildSnapshot(await realpath(root));
40
55
  for (const path of [
41
56
  "package.json",
42
57
  "README.md",
@@ -45,57 +60,80 @@ export async function packageBuildDigest(
45
60
  "schemas",
46
61
  "src",
47
62
  ])
48
- await collect(path);
49
- files.sort((left, right) =>
50
- left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
51
- );
52
- return hashJson({ format: "sevro.build.v1", files });
63
+ await snapshot.collect(path);
64
+ return hashJson({
65
+ format: "sevro.build.v1",
66
+ files: snapshot.files.sort(fileOrder),
67
+ });
53
68
  }
54
69
 
55
- export async function projectIdentityDigest(
56
- root: string,
57
- provenance: { revision: string | null; dirtyPatchDigest: string | null },
58
- excludedRoots: string[] = [],
59
- ): Promise<string> {
60
- if (provenance.revision)
61
- return hashJson({
62
- format: "sevro.project.v1",
63
- revision: provenance.revision,
64
- dirtyPatchDigest: provenance.dirtyPatchDigest,
65
- });
66
- const projectRoot = resolve(root);
67
- const excluded = new Set(
68
- excludedRoots.map((path) => relative(projectRoot, resolve(path))),
69
- );
70
- const files: { path: string; sha256: string; executable: boolean }[] = [];
71
- let totalBytes = 0;
72
- async function collect(path: string): Promise<void> {
73
- if (path === ".git" || excluded.has(path)) return;
74
- const absolute = join(projectRoot, path);
75
- const info = await lstat(absolute);
70
+ class ProjectSnapshot {
71
+ readonly files: ProjectFile[] = [];
72
+ private bytes = 0;
73
+ constructor(
74
+ private root: string,
75
+ private excluded: Set<string>,
76
+ ) {}
77
+ private isExcluded(path: string): boolean {
78
+ return path === ".git" || this.excluded.has(path);
79
+ }
80
+ async collect(path: string): Promise<void> {
81
+ if (this.isExcluded(path)) return;
82
+ const absolute = join(this.root, path),
83
+ info = await lstat(absolute);
76
84
  if (info.isSymbolicLink())
77
85
  throw new Error("project snapshot contains a symbolic link");
78
86
  if (info.isDirectory()) {
79
- for (const entry of (await readdir(absolute)).sort())
80
- await collect(path === "" ? entry : join(path, entry));
87
+ await this.collectDirectory(path, absolute);
81
88
  return;
82
89
  }
90
+ await this.collectFile(path, absolute, info);
91
+ }
92
+ private async collectDirectory(
93
+ path: string,
94
+ absolute: string,
95
+ ): Promise<void> {
96
+ for (const entry of (await readdir(absolute)).sort())
97
+ await this.collect(path === "" ? entry : join(path, entry));
98
+ }
99
+ private async collectFile(
100
+ path: string,
101
+ absolute: string,
102
+ info: Stats,
103
+ ): Promise<void> {
83
104
  if (!info.isFile()) throw new Error("project snapshot contains a non-file");
84
105
  const bytes = await readFile(absolute);
85
- totalBytes += bytes.byteLength;
86
- if (files.length >= MAX_BUILD_FILES || totalBytes > MAX_BUILD_BYTES)
106
+ this.bytes += bytes.byteLength;
107
+ if (this.files.length >= MAX_BUILD_FILES || this.bytes > MAX_BUILD_BYTES)
87
108
  throw new Error("project snapshot exceeds the size limit");
88
- files.push({
109
+ this.files.push({
89
110
  path: path.split(sep).join("/"),
90
111
  sha256: sha256(bytes),
91
112
  executable: Boolean(info.mode & 0o111),
92
113
  });
93
114
  }
94
- await collect("");
95
- files.sort((left, right) =>
96
- left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
97
- );
98
- return hashJson({ format: "sevro.project.v1", files });
115
+ }
116
+ export async function projectIdentityDigest(
117
+ root: string,
118
+ provenance: { revision: string | null; dirtyPatchDigest: string | null },
119
+ excludedRoots: string[] = [],
120
+ ): Promise<string> {
121
+ if (provenance.revision)
122
+ return hashJson({
123
+ format: "sevro.project.v1",
124
+ revision: provenance.revision,
125
+ dirtyPatchDigest: provenance.dirtyPatchDigest,
126
+ });
127
+ const projectRoot = resolve(root),
128
+ excluded = new Set(
129
+ excludedRoots.map((path) => relative(projectRoot, resolve(path))),
130
+ );
131
+ const snapshot = new ProjectSnapshot(projectRoot, excluded);
132
+ await snapshot.collect("");
133
+ return hashJson({
134
+ format: "sevro.project.v1",
135
+ files: snapshot.files.sort(fileOrder),
136
+ });
99
137
  }
100
138
 
101
139
  function git(root: string, ...arguments_: string[]): Buffer {
@@ -110,7 +148,10 @@ function git(root: string, ...arguments_: string[]): Buffer {
110
148
  return Buffer.from(result.stdout);
111
149
  }
112
150
 
113
- async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
151
+ async function dirtyPatchDigest(
152
+ gitRoot: string,
153
+ excludedRoots: string[],
154
+ ): Promise<string | null> {
114
155
  const trackedPatch = git(gitRoot, "diff", "--binary", "HEAD", "--");
115
156
  const untrackedPaths = git(
116
157
  gitRoot,
@@ -122,26 +163,17 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
122
163
  .toString("utf8")
123
164
  .split("\0")
124
165
  .filter(Boolean)
166
+ .filter(
167
+ (path) =>
168
+ !excludedRoots.some((root) =>
169
+ insideRuntimeRoot(root, resolve(gitRoot, path)),
170
+ ),
171
+ )
125
172
  .sort();
126
173
  let dirtyBytes = trackedPatch.byteLength;
127
174
  const untracked: { path: string; sha256: string }[] = [];
128
175
  for (const path of untrackedPaths) {
129
- const absolute = resolve(gitRoot, path);
130
- const relativePath = relative(gitRoot, absolute);
131
- if (
132
- isAbsolute(relativePath) ||
133
- relativePath === ".." ||
134
- relativePath.startsWith(`..${sep}`)
135
- )
136
- throw new Error("Git checkout has an unsafe untracked path");
137
- const info = await lstat(absolute);
138
- const bytes = info.isSymbolicLink()
139
- ? Buffer.from(await readlink(absolute), "utf8")
140
- : info.isFile()
141
- ? await readFile(absolute)
142
- : null;
143
- if (!bytes)
144
- throw new Error("Git checkout has an unsupported untracked entry");
176
+ const bytes = await untrackedIdentityBytes(gitRoot, path);
145
177
  dirtyBytes += bytes.byteLength;
146
178
  if (dirtyBytes > MAX_DIRTY_BYTES)
147
179
  throw new Error("Git identity exceeds the size limit");
@@ -152,6 +184,24 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
152
184
  : null;
153
185
  }
154
186
 
187
+ function requireSafeUntrackedPath(root: string, absolute: string): void {
188
+ const path = relative(root, absolute);
189
+ if (isAbsolute(path) || path === ".." || path.startsWith(`..${sep}`))
190
+ throw new Error("Git checkout has an unsafe untracked path");
191
+ }
192
+ async function untrackedIdentityBytes(
193
+ root: string,
194
+ path: string,
195
+ ): Promise<Buffer> {
196
+ const absolute = resolve(root, path);
197
+ requireSafeUntrackedPath(root, absolute);
198
+ const info = await lstat(absolute);
199
+ if (info.isSymbolicLink())
200
+ return Buffer.from(await readlink(absolute), "utf8");
201
+ if (info.isFile()) return readFile(absolute);
202
+ throw new Error("Git checkout has an unsupported untracked entry");
203
+ }
204
+
155
205
  /** Local development identity; packaged use never calls Git. */
156
206
  export async function checkoutProvenance(root: string, buildDigest: string) {
157
207
  const canonicalRoot = await realpath(root);
@@ -169,27 +219,50 @@ export async function checkoutProvenance(root: string, buildDigest: string) {
169
219
  source: "checkout" as const,
170
220
  root: pathToFileURL(canonicalRoot).href,
171
221
  revision,
172
- dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot),
222
+ dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot, []),
173
223
  buildDigest,
174
224
  };
175
225
  }
176
226
 
177
227
  async function enclosingGitRoot(root: string): Promise<string | null> {
178
228
  let path = root;
179
- while (true) {
180
- try {
181
- await lstat(join(path, ".git"));
182
- return path;
183
- } catch (error) {
184
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
185
- }
229
+ for (;;) {
230
+ if (await gitMetadataAt(path)) return path;
186
231
  const parent = dirname(path);
187
232
  if (parent === path) return null;
188
233
  path = parent;
189
234
  }
190
235
  }
191
236
 
192
- export async function projectProvenance(root: string) {
237
+ async function gitMetadataAt(path: string): Promise<boolean> {
238
+ try {
239
+ const metadataPath = join(path, ".git");
240
+ const metadata = await lstat(metadataPath);
241
+ return !(await emptyTemporaryGitPlaceholder(path, metadataPath, metadata));
242
+ } catch (error) {
243
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
244
+ throw error;
245
+ }
246
+ }
247
+
248
+ async function emptyTemporaryGitPlaceholder(
249
+ directory: string,
250
+ metadataPath: string,
251
+ metadata: Stats,
252
+ ): Promise<boolean> {
253
+ // Codex's Linux sandbox can leave an empty mount placeholder at /tmp/.git.
254
+ return (
255
+ process.platform === "linux" &&
256
+ (directory === "/tmp" || directory === tmpdir()) &&
257
+ metadata.isDirectory() &&
258
+ (await readdir(metadataPath)).length === 0
259
+ );
260
+ }
261
+
262
+ export async function projectProvenance(
263
+ root: string,
264
+ excludedRoots?: string[],
265
+ ) {
193
266
  const canonicalRoot = await realpath(root);
194
267
  const gitEntryRoot = await enclosingGitRoot(canonicalRoot);
195
268
  if (!gitEntryRoot)
@@ -219,10 +292,17 @@ export async function projectProvenance(root: string) {
219
292
  return {
220
293
  root: pathToFileURL(canonicalRoot).href,
221
294
  revision,
222
- dirtyPatchDigest: await dirtyPatchDigest(gitRoot),
295
+ dirtyPatchDigest: await projectDirtyDigest(gitRoot, excludedRoots),
223
296
  };
224
297
  }
225
298
 
299
+ async function projectDirtyDigest(
300
+ root: string,
301
+ excludedRoots: string[] | undefined,
302
+ ) {
303
+ return dirtyPatchDigest(root, excludedRoots ?? []);
304
+ }
305
+
226
306
  export async function runnerProvenance(
227
307
  buildDigest: string,
228
308
  checkoutRoot?: string,
package/src/prune.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { readFile, readdir, realpath, rm, stat } from "node:fs/promises";
2
+ import { basename, 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 { retainedFile, withResultsLock } from "./results-lock";
10
+ import { pruneNativeTranscripts } from "./transcript-retention";
11
+
12
+ interface RetainedResult {
13
+ directory: string;
14
+ result: CliResultData;
15
+ evidence: RunEvidenceData;
16
+ time: number;
17
+ }
18
+
19
+ type RetentionPolicy =
20
+ { keep: number; cutoff: null } | { keep: null; cutoff: number };
21
+
22
+ const durationUnits: Record<string, number> = {
23
+ ms: 1,
24
+ s: 1000,
25
+ m: 60_000,
26
+ h: 3_600_000,
27
+ d: 86_400_000,
28
+ w: 604_800_000,
29
+ };
30
+
31
+ function duration(value: string): number {
32
+ const match = /^([1-9]\d*)(ms|s|m|h|d|w)$/.exec(value);
33
+ if (!match)
34
+ throw new Error(
35
+ "--older-than requires a positive integer and ms, s, m, h, d or w",
36
+ );
37
+ const amount =
38
+ Number.parseInt(value, 10) *
39
+ Number(durationUnits[value.replace(/^\d+/, "")]);
40
+ if (!Number.isSafeInteger(amount))
41
+ throw new Error("--older-than exceeds the safe duration range");
42
+ return amount;
43
+ }
44
+
45
+ function count(value: string): number {
46
+ const amount = Number(value);
47
+ if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(amount))
48
+ throw new Error("--keep must be a positive safe integer");
49
+ return amount;
50
+ }
51
+
52
+ function parseInvocation(args: string[]) {
53
+ const { values } = parseArgs({
54
+ args,
55
+ strict: true,
56
+ options: {
57
+ "results-root": { type: "string" },
58
+ keep: { type: "string" },
59
+ latest: { type: "boolean" },
60
+ "older-than": { type: "string" },
61
+ "native-transcripts": { type: "boolean" },
62
+ },
63
+ });
64
+ return values;
65
+ }
66
+
67
+ function policy(values: ReturnType<typeof parseInvocation>): RetentionPolicy {
68
+ if (values.latest) return { keep: 1, cutoff: null };
69
+ if (values["older-than"] !== undefined)
70
+ return { keep: null, cutoff: Date.now() - duration(values["older-than"]) };
71
+ if (values.keep !== undefined)
72
+ return { keep: count(values.keep), cutoff: null };
73
+ return { keep: values["native-transcripts"] ? 0 : 10, cutoff: null };
74
+ }
75
+
76
+ function invocation(args: string[]) {
77
+ const values = parseInvocation(args);
78
+ const policies = [values.latest, values.keep, values["older-than"]].filter(
79
+ (value) => value !== undefined,
80
+ );
81
+ if (policies.length > 1) throw new Error("retention policies are exclusive");
82
+ if (values["results-root"] === "")
83
+ throw new Error("--results-root must not be empty");
84
+ return {
85
+ root: resolve(values["results-root"] ?? ".sevro/results"),
86
+ policy: policy(values),
87
+ nativeTranscripts: values["native-transcripts"] === true,
88
+ };
89
+ }
90
+
91
+ async function retainedResults(root: string): Promise<RetainedResult[]> {
92
+ const results: RetainedResult[] = [];
93
+ for (const directory of await resultDirectories(root)) {
94
+ const path = join(directory, "result.json");
95
+ await retainedFile(path);
96
+ const result: unknown = JSON.parse(await readFile(path, "utf8"));
97
+ assertCliResult(result);
98
+ if (!(await finalized(root, result.runId))) continue;
99
+ if (result.evidencePath !== join(directory, "run.json"))
100
+ throw new Error(
101
+ `retained result references evidence outside its directory: ${path}`,
102
+ );
103
+ await retainedFile(result.evidencePath);
104
+ const evidence: unknown = JSON.parse(
105
+ await readFile(result.evidencePath, "utf8"),
106
+ );
107
+ assertRunEvidence(evidence);
108
+ await createReport([path]);
109
+ results.push({
110
+ directory,
111
+ result,
112
+ evidence,
113
+ time: (await stat(path)).mtimeMs,
114
+ });
115
+ }
116
+ return results.sort(
117
+ (left, right) =>
118
+ right.time - left.time || left.directory.localeCompare(right.directory),
119
+ );
120
+ }
121
+
122
+ async function resultDirectories(parent: string): Promise<string[]> {
123
+ const paths: string[] = [];
124
+ for (const entry of await readdir(parent, { withFileTypes: true })) {
125
+ if (!entry.isDirectory()) continue;
126
+ const directory = join(parent, entry.name);
127
+ if (await retainedFile(join(directory, "result.json")))
128
+ paths.push(directory);
129
+ else paths.push(...(await resultDirectories(directory)));
130
+ }
131
+ return paths;
132
+ }
133
+
134
+ async function finalized(root: string, runId: string | null): Promise<boolean> {
135
+ if (!runId || basename(runId) !== runId) return false;
136
+ const path = join(root, "active", `${runId}.json`);
137
+ if (!(await retainedFile(path))) return true;
138
+ const value: unknown = JSON.parse(await readFile(path, "utf8"));
139
+ if (!isRecord(value)) return false;
140
+ return finalizedRecord(value, runId);
141
+ }
142
+
143
+ function finalizedRecord(
144
+ value: Record<string, unknown>,
145
+ runId: string,
146
+ ): boolean {
147
+ return (
148
+ value.format === "sevro.active-run.v1" &&
149
+ value.runId === runId &&
150
+ ["complete", "interrupted", "diagnostic"].includes(String(value.status))
151
+ );
152
+ }
153
+
154
+ function retainedByPolicy(results: RetainedResult[], policy: RetentionPolicy) {
155
+ const cutoff = policy.cutoff;
156
+ return cutoff === null
157
+ ? results.slice(0, policy.keep)
158
+ : results.filter((result) => result.time >= cutoff);
159
+ }
160
+
161
+ async function pruneResults(root: string, policy: RetentionPolicy) {
162
+ const results = await retainedResults(root);
163
+ const remaining = retainedByPolicy(results, policy);
164
+ const latest = remaining[0];
165
+ if (latest) await atomicWriteJson(join(root, "latest.json"), latest.result);
166
+ else await rm(join(root, "latest.json"), { force: true });
167
+ const removed = results.filter((result) => !remaining.includes(result));
168
+ for (const result of removed) await rm(result.directory, { recursive: true });
169
+ return { removed: removed.length, kept: remaining.length };
170
+ }
171
+
172
+ async function pruneTranscripts(root: string, policy: RetentionPolicy) {
173
+ const results = await retainedResults(root);
174
+ const preserved = retainedByPolicy(results, policy);
175
+ let removed = 0;
176
+ for (const { evidence } of results.filter(
177
+ (item) => !preserved.includes(item),
178
+ )) {
179
+ removed += await pruneNativeTranscripts(evidence);
180
+ }
181
+ return { removed, kept: results.length };
182
+ }
183
+
184
+ async function prune(options: ReturnType<typeof invocation>) {
185
+ const root = await realpath(options.root).catch((cause: unknown) => {
186
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
187
+ throw cause;
188
+ });
189
+ if (root === null) return { removed: 0, kept: 0 };
190
+ return withResultsLock(root, () =>
191
+ options.nativeTranscripts
192
+ ? pruneTranscripts(root, options.policy)
193
+ : pruneResults(root, options.policy),
194
+ );
195
+ }
196
+
197
+ /** Explicitly remove old retained runs while keeping report's latest index usable. */
198
+ export async function pruneCommand(args: string[]): Promise<number> {
199
+ try {
200
+ const options = invocation(args);
201
+ const result = await prune(options);
202
+ const unit = options.nativeTranscripts
203
+ ? "native transcript bundle(s)"
204
+ : "result(s)";
205
+ process.stdout.write(
206
+ `Removed ${result.removed} ${unit}; kept ${result.kept}${options.nativeTranscripts ? " result(s)" : ""}.\n`,
207
+ );
208
+ return 0;
209
+ } catch (error) {
210
+ process.stderr.write(
211
+ `sevro prune: ${error instanceof Error ? error.message : "pruning failed"}\n`,
212
+ );
213
+ return 64;
214
+ }
215
+ }