@bjoernrochel/sevro 0.1.0-rc.1

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 (71) hide show
  1. package/LICENSE +159 -0
  2. package/README.md +71 -0
  3. package/docs/advisory-fixture.md +23 -0
  4. package/docs/builtin-graders-v1.md +77 -0
  5. package/docs/cache-ownership-validation.md +49 -0
  6. package/docs/claude-continuation-validation.md +47 -0
  7. package/docs/codex-host.md +134 -0
  8. package/docs/concurrency-validation.md +86 -0
  9. package/docs/configuration-root-validation.md +70 -0
  10. package/docs/development-cli.md +327 -0
  11. package/docs/engine-slice.md +114 -0
  12. package/docs/extension-protocol-v1.md +292 -0
  13. package/docs/fixture-cleanup-validation.md +70 -0
  14. package/docs/identity-v1.md +65 -0
  15. package/docs/native-controls.md +29 -0
  16. package/docs/releases.md +88 -0
  17. package/docs/report-v1.md +32 -0
  18. package/docs/results-v1.md +118 -0
  19. package/examples/basic/graded.json +13 -0
  20. package/examples/basic/host.ts +8 -0
  21. package/examples/basic/prompt-only.json +7 -0
  22. package/package.json +45 -0
  23. package/schemas/cli-result-v1.schema.json +184 -0
  24. package/schemas/extension-v1.schema.json +659 -0
  25. package/schemas/report-v1.schema.json +164 -0
  26. package/schemas/run-evidence-v1.schema.json +510 -0
  27. package/src/advisory-fixture.ts +282 -0
  28. package/src/advisory.ts +105 -0
  29. package/src/cli.ts +837 -0
  30. package/src/engine.ts +2341 -0
  31. package/src/extension-client.ts +257 -0
  32. package/src/extension-session.ts +335 -0
  33. package/src/fixture-bin.ts +87 -0
  34. package/src/fixture-cleanup.ts +38 -0
  35. package/src/fixture-setup.ts +163 -0
  36. package/src/generated-fixture.ts +220 -0
  37. package/src/git-hooks.ts +63 -0
  38. package/src/graders/git-head.ts +176 -0
  39. package/src/graders/output.ts +359 -0
  40. package/src/graders/semantic.ts +120 -0
  41. package/src/graders/shell.ts +277 -0
  42. package/src/hosts/claude-continuation.ts +94 -0
  43. package/src/hosts/claude-credential.ts +73 -0
  44. package/src/hosts/claude-events.ts +128 -0
  45. package/src/hosts/claude-nested-skills.ts +218 -0
  46. package/src/hosts/claude-repository-invocation.ts +239 -0
  47. package/src/hosts/claude-settings.ts +50 -0
  48. package/src/hosts/claude-tool-calls.ts +159 -0
  49. package/src/hosts/claude.ts +511 -0
  50. package/src/hosts/codex-configuration.ts +85 -0
  51. package/src/hosts/codex-events.ts +102 -0
  52. package/src/hosts/codex-native-calls.ts +702 -0
  53. package/src/hosts/codex-profile.ts +78 -0
  54. package/src/hosts/codex-skill-reads.ts +345 -0
  55. package/src/hosts/codex.ts +769 -0
  56. package/src/hosts/isolation-roots.ts +60 -0
  57. package/src/hosts/mac-sandbox.ts +108 -0
  58. package/src/hosts/native-controls.ts +124 -0
  59. package/src/hosts/protected-worktrees.ts +56 -0
  60. package/src/hosts/workspace-fingerprint.ts +55 -0
  61. package/src/identity.ts +156 -0
  62. package/src/instrumentation.ts +106 -0
  63. package/src/preparation.ts +211 -0
  64. package/src/provenance.ts +244 -0
  65. package/src/report.ts +351 -0
  66. package/src/repository-fixture.ts +286 -0
  67. package/src/results.ts +160 -0
  68. package/src/run-owner.ts +276 -0
  69. package/src/schema.ts +28 -0
  70. package/src/storage.ts +23 -0
  71. package/src/trial-scheduler.ts +27 -0
package/src/results.ts ADDED
@@ -0,0 +1,160 @@
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
+ }
19
+
20
+ export interface TrialInput {
21
+ execution: ExecutionStatus;
22
+ declaredChecks: string[];
23
+ checks: CheckOutcome[];
24
+ graderError?: boolean;
25
+ requiredEvidenceUnavailable?: boolean;
26
+ }
27
+
28
+ /** Reduce one trial without treating process completion as task success. */
29
+ export function assessTrial(input: TrialInput): Assessment {
30
+ const declared = new Set(input.declaredChecks);
31
+ if (declared.size !== input.declaredChecks.length || declared.has(""))
32
+ throw new Error("check declarations must have unique nonempty IDs");
33
+ const seen = new Set<string>();
34
+ for (const check of input.checks) {
35
+ if (!declared.has(check.id) || seen.has(check.id))
36
+ throw new Error(`unexpected or duplicate check result: ${check.id}`);
37
+ seen.add(check.id);
38
+ }
39
+
40
+ const grading: GradingStatus = input.graderError
41
+ ? "error"
42
+ : input.execution !== "completed"
43
+ ? "not_requested"
44
+ : input.requiredEvidenceUnavailable ||
45
+ input.checks.some((check) => check.status === "unavailable") ||
46
+ seen.size !== declared.size
47
+ ? "unavailable"
48
+ : declared.size === 0
49
+ ? "not_requested"
50
+ : "completed";
51
+ const task: TaskVerdict =
52
+ input.execution === "completed" &&
53
+ input.checks.some((check) => check.status === "failed")
54
+ ? "failed"
55
+ : input.execution === "completed" && grading === "completed"
56
+ ? "passed"
57
+ : "not_assessed";
58
+ return {
59
+ execution: { status: input.execution },
60
+ grading: { status: grading },
61
+ task: { verdict: task },
62
+ };
63
+ }
64
+
65
+ /** Apply an explicitly selected policy only to a fully assessed trial. */
66
+ export function applyTaskVerdictPolicy(
67
+ assessment: Assessment,
68
+ recommendation: TaskVerdict | null,
69
+ selected: boolean,
70
+ ): Assessment {
71
+ if (!selected) return assessment;
72
+ if (
73
+ recommendation === null ||
74
+ assessment.execution.status !== "completed" ||
75
+ assessment.grading.status !== "completed"
76
+ )
77
+ return { ...assessment, task: { verdict: "not_assessed" } };
78
+ return { ...assessment, task: { verdict: recommendation } };
79
+ }
80
+
81
+ /** Apply a case's pass threshold after every required trial has an assessment. */
82
+ export function summarizeAssessments(
83
+ assessments: Assessment[],
84
+ passThreshold = 1,
85
+ ): Assessment {
86
+ if (!assessments.length)
87
+ throw new Error("at least one assessment is required");
88
+ if (
89
+ !Number.isFinite(passThreshold) ||
90
+ passThreshold <= 0 ||
91
+ passThreshold > 1
92
+ )
93
+ throw new Error("pass threshold must be greater than zero and at most one");
94
+ const execution: ExecutionStatus = assessments.some(
95
+ (item) => item.execution.status === "failed",
96
+ )
97
+ ? "failed"
98
+ : assessments.some((item) => item.execution.status === "cancelled")
99
+ ? "cancelled"
100
+ : assessments.some((item) => item.execution.status === "not_run")
101
+ ? "not_run"
102
+ : "completed";
103
+ const grading: GradingStatus = assessments.some(
104
+ (item) => item.grading.status === "error",
105
+ )
106
+ ? "error"
107
+ : assessments.some((item) => item.grading.status === "unavailable")
108
+ ? "unavailable"
109
+ : assessments.some((item) => item.grading.status === "completed")
110
+ ? "completed"
111
+ : "not_requested";
112
+ const task: TaskVerdict = assessments.some(
113
+ (item) => item.task.verdict === "not_assessed",
114
+ )
115
+ ? "not_assessed"
116
+ : assessments.filter((item) => item.task.verdict === "passed").length /
117
+ assessments.length >=
118
+ passThreshold
119
+ ? "passed"
120
+ : "failed";
121
+ return {
122
+ execution: { status: execution },
123
+ grading: { status: grading },
124
+ task: { verdict: task },
125
+ };
126
+ }
127
+
128
+ /** Combine case verdicts after each case has applied its own threshold. */
129
+ export function summarizeCases(cases: Assessment[]): Assessment {
130
+ const summary = summarizeAssessments(cases);
131
+ if (cases.some((item) => item.task.verdict === "failed"))
132
+ return { ...summary, task: { verdict: "failed" } };
133
+ return summary;
134
+ }
135
+
136
+ export interface ExitContext {
137
+ signal?: "SIGINT" | "SIGTERM";
138
+ internalFailure?: boolean;
139
+ dryRun?: boolean;
140
+ }
141
+
142
+ /** Resolve the documented CLI exit category without discarding detailed states. */
143
+ export function exitCodeFor(
144
+ assessment: Assessment,
145
+ context: ExitContext = {},
146
+ ): number {
147
+ if (context.signal === "SIGINT") return 130;
148
+ if (context.signal === "SIGTERM") return 143;
149
+ if (context.internalFailure) return 70;
150
+ if (
151
+ assessment.execution.status === "failed" ||
152
+ assessment.execution.status === "cancelled" ||
153
+ (assessment.execution.status === "not_run" && !context.dryRun)
154
+ )
155
+ return 2;
156
+ if (assessment.grading.status === "error") return 3;
157
+ if (assessment.grading.status === "unavailable") return 4;
158
+ if (assessment.task.verdict === "failed") return 1;
159
+ return 0;
160
+ }
@@ -0,0 +1,276 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { randomUUID } from "node:crypto";
3
+ import {
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { hostname } from "node:os";
11
+ import { basename, dirname, join } from "node:path";
12
+
13
+ interface ProcessIdentity {
14
+ pid: number;
15
+ startedAt: string;
16
+ host: string;
17
+ }
18
+
19
+ interface Claim {
20
+ format: "sevro.run-owner.v1";
21
+ evaluationDigest: string;
22
+ attemptId: string;
23
+ status: "active" | "complete" | "diagnostic" | "interrupted";
24
+ owner: ProcessIdentity;
25
+ activeRunPath: string;
26
+ evidenceDirectory: string;
27
+ artifactPath: string;
28
+ startedAt: string;
29
+ finalizedAt?: string;
30
+ }
31
+
32
+ export interface RunOwner {
33
+ claimPath: string;
34
+ checkpointPath: string;
35
+ claim: Claim;
36
+ }
37
+
38
+ function atomicJson(path: string, value: unknown): void {
39
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
40
+ const temporary = join(
41
+ dirname(path),
42
+ `.${basename(path)}.${randomUUID()}.tmp`,
43
+ );
44
+ try {
45
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
46
+ flag: "wx",
47
+ mode: 0o600,
48
+ });
49
+ renameSync(temporary, path);
50
+ } catch (error) {
51
+ try {
52
+ unlinkSync(temporary);
53
+ } catch {
54
+ // The temporary file may not have been created.
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ function withClaimLock<T>(claimPath: string, action: () => T): T {
61
+ const locks = join(dirname(dirname(claimPath)), "locks");
62
+ mkdirSync(locks, { recursive: true, mode: 0o700 });
63
+ const database = new Database(join(locks, `${basename(claimPath)}.sqlite`));
64
+ try {
65
+ database.exec("PRAGMA busy_timeout = 5000");
66
+ database.exec("CREATE TABLE IF NOT EXISTS mutex (id INTEGER PRIMARY KEY)");
67
+ return database.transaction(action).immediate();
68
+ } finally {
69
+ database.close();
70
+ }
71
+ }
72
+
73
+ function readClaim(path: string): Claim | null {
74
+ let bytes: string;
75
+ try {
76
+ bytes = readFileSync(path, "utf8");
77
+ } catch (error) {
78
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
79
+ throw error;
80
+ }
81
+ const value = JSON.parse(bytes) as Claim;
82
+ if (
83
+ value?.format !== "sevro.run-owner.v1" ||
84
+ !/^[a-f0-9]{64}$/.test(value.evaluationDigest) ||
85
+ !value.attemptId ||
86
+ !["active", "complete", "diagnostic", "interrupted"].includes(
87
+ value.status,
88
+ ) ||
89
+ !value.activeRunPath ||
90
+ !value.evidenceDirectory ||
91
+ !value.artifactPath
92
+ )
93
+ throw new Error(`unrecognized Sevro ownership record: ${path}`);
94
+ return value;
95
+ }
96
+
97
+ function processIdentity(pid: number): ProcessIdentity | null {
98
+ const windows = process.platform === "win32";
99
+ const argv = windows
100
+ ? [
101
+ "powershell.exe",
102
+ "-NoProfile",
103
+ "-NonInteractive",
104
+ "-Command",
105
+ `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`,
106
+ ]
107
+ : ["/bin/ps", "-p", String(pid), "-o", "lstart="];
108
+ let result: ReturnType<typeof Bun.spawnSync>;
109
+ try {
110
+ result = Bun.spawnSync(argv, {
111
+ env: windows
112
+ ? {
113
+ PATH: process.env.PATH ?? "",
114
+ SystemRoot: process.env.SystemRoot ?? "C:\\Windows",
115
+ }
116
+ : { PATH: "/usr/bin:/bin", LC_ALL: "C" },
117
+ stdout: "pipe",
118
+ stderr: "ignore",
119
+ });
120
+ } catch {
121
+ return null;
122
+ }
123
+ const startedAt = result.stdout?.toString().trim() ?? "";
124
+ return result.exitCode === 0 && startedAt
125
+ ? { pid, startedAt, host: hostname() }
126
+ : null;
127
+ }
128
+
129
+ function ownerStatus(owner: ProcessIdentity): "live" | "dead" | "unknown" {
130
+ if (
131
+ !owner ||
132
+ owner.host !== hostname() ||
133
+ !Number.isSafeInteger(owner.pid) ||
134
+ owner.pid <= 0 ||
135
+ !owner.startedAt
136
+ )
137
+ return "unknown";
138
+ try {
139
+ process.kill(owner.pid, 0);
140
+ } catch (error) {
141
+ return (error as NodeJS.ErrnoException).code === "ESRCH"
142
+ ? "dead"
143
+ : "unknown";
144
+ }
145
+ const current = processIdentity(owner.pid);
146
+ if (!current) return "unknown";
147
+ return current.startedAt === owner.startedAt ? "live" : "dead";
148
+ }
149
+
150
+ function preserveAbandoned(claim: Claim): void {
151
+ const interrupted = {
152
+ ...claim,
153
+ status: "interrupted" as const,
154
+ finalizedAt: new Date().toISOString(),
155
+ };
156
+ atomicJson(join(claim.evidenceDirectory, "run-owner.json"), interrupted);
157
+ let active: Record<string, unknown>;
158
+ try {
159
+ active = JSON.parse(readFileSync(claim.activeRunPath, "utf8"));
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
162
+ active = {
163
+ format: "sevro.active-run.v1",
164
+ runId: claim.attemptId,
165
+ artifactPath: claim.artifactPath,
166
+ evidenceDirectory: claim.evidenceDirectory,
167
+ completedTrials: [],
168
+ };
169
+ }
170
+ atomicJson(claim.activeRunPath, {
171
+ ...active,
172
+ status: "interrupted",
173
+ finalizedAt: interrupted.finalizedAt,
174
+ });
175
+ }
176
+
177
+ /** Claim one evaluation identity before candidate execution. */
178
+ export function startRunOwner(options: {
179
+ stateRoot: string;
180
+ evaluationDigest: string;
181
+ attemptId: string;
182
+ activeRunPath: string;
183
+ checkpointPath: string;
184
+ evidenceDirectory: string;
185
+ artifactPath: string;
186
+ }): RunOwner {
187
+ if (!/^[a-f0-9]{64}$/.test(options.evaluationDigest))
188
+ throw new Error("invalid evaluation identity for run ownership");
189
+ const claimPath = join(
190
+ options.stateRoot,
191
+ "owners",
192
+ `${options.evaluationDigest}.json`,
193
+ );
194
+ return withClaimLock(claimPath, () => {
195
+ const prior = readClaim(claimPath);
196
+ if (prior && prior.evaluationDigest !== options.evaluationDigest)
197
+ throw new Error("Sevro ownership record has a different identity");
198
+ if (prior?.status === "active") {
199
+ const status = ownerStatus(prior.owner);
200
+ if (status === "live")
201
+ throw new Error(`equivalent Sevro run is active: ${claimPath}`);
202
+ if (status === "unknown")
203
+ throw new Error(`Sevro run ownership is unverifiable: ${claimPath}`);
204
+ preserveAbandoned(prior);
205
+ }
206
+ const owner = processIdentity(process.pid);
207
+ if (!owner) throw new Error("cannot verify Sevro runner process identity");
208
+ const claim: Claim = {
209
+ format: "sevro.run-owner.v1",
210
+ evaluationDigest: options.evaluationDigest,
211
+ attemptId: options.attemptId,
212
+ status: "active",
213
+ owner,
214
+ activeRunPath: options.activeRunPath,
215
+ evidenceDirectory: options.evidenceDirectory,
216
+ artifactPath: options.artifactPath,
217
+ startedAt: new Date().toISOString(),
218
+ };
219
+ const context = {
220
+ claimPath,
221
+ checkpointPath: options.checkpointPath,
222
+ claim,
223
+ };
224
+ checkpointRunOwner(context, [], "active", true);
225
+ return context;
226
+ });
227
+ }
228
+
229
+ /** Persist a checkpoint only while this attempt owns the identity. */
230
+ export function checkpointRunOwner(
231
+ context: RunOwner,
232
+ completedTrials: { trial: number; artifactPath: string }[],
233
+ status: "active" | "complete" | "diagnostic" | "interrupted",
234
+ alreadyLocked = false,
235
+ ): void {
236
+ const write = () => {
237
+ if (!alreadyLocked) {
238
+ const current = readClaim(context.claimPath);
239
+ if (!current || current.attemptId !== context.claim.attemptId)
240
+ throw new Error("Sevro run ownership changed before checkpoint");
241
+ if (current.status !== "active")
242
+ throw new Error("Sevro run owner is already finalized");
243
+ if (ownerStatus(current.owner) !== "live")
244
+ throw new Error("Sevro run owner identity is no longer verifiable");
245
+ }
246
+ const finalizedAt =
247
+ status === "active" ? undefined : new Date().toISOString();
248
+ const updatedClaim = {
249
+ ...context.claim,
250
+ status,
251
+ ...(finalizedAt ? { finalizedAt } : {}),
252
+ };
253
+ atomicJson(context.checkpointPath, {
254
+ format: "sevro.run-checkpoint.v1",
255
+ runId: context.claim.attemptId,
256
+ completedTrials,
257
+ });
258
+ atomicJson(context.claim.activeRunPath, {
259
+ format: "sevro.active-run.v1",
260
+ runId: context.claim.attemptId,
261
+ evaluationDigest: context.claim.evaluationDigest,
262
+ attemptId: context.claim.attemptId,
263
+ status,
264
+ owner: context.claim.owner,
265
+ artifactPath: context.claim.artifactPath,
266
+ evidenceDirectory: context.claim.evidenceDirectory,
267
+ checkpointPath: context.checkpointPath,
268
+ completedTrials,
269
+ ...(finalizedAt ? { finalizedAt } : {}),
270
+ });
271
+ atomicJson(context.claimPath, updatedClaim);
272
+ context.claim = updatedClaim;
273
+ };
274
+ if (alreadyLocked) write();
275
+ else withClaimLock(context.claimPath, write);
276
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,28 @@
1
+ import Ajv2020 from "ajv/dist/2020.js";
2
+ import cliSchema from "../schemas/cli-result-v1.schema.json";
3
+ import runSchema from "../schemas/run-evidence-v1.schema.json";
4
+ import reportSchema from "../schemas/report-v1.schema.json";
5
+
6
+ const ajv = new Ajv2020({
7
+ strict: true,
8
+ strictRequired: false,
9
+ strictTypes: false,
10
+ });
11
+ ajv.addSchema(cliSchema);
12
+ ajv.addSchema(runSchema);
13
+ ajv.addSchema(reportSchema);
14
+ const cliValidator = ajv.getSchema("urn:sevro:schema:cli-result:v1")!;
15
+ const runValidator = ajv.getSchema("urn:sevro:schema:run-evidence:v1")!;
16
+ const reportValidator = ajv.getSchema("urn:sevro:schema:report:v1")!;
17
+
18
+ export function assertCliResult(value: unknown): void {
19
+ if (!cliValidator(value)) throw new Error("invalid Sevro CLI result");
20
+ }
21
+
22
+ export function assertRunEvidence(value: unknown): void {
23
+ if (!runValidator(value)) throw new Error("invalid Sevro retained evidence");
24
+ }
25
+
26
+ export function assertReport(value: unknown): void {
27
+ if (!reportValidator(value)) throw new Error("invalid Sevro report");
28
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+
5
+ /** Write a private JSON artifact and publish it with one atomic rename. */
6
+ export async function atomicWriteJson(
7
+ path: string,
8
+ value: unknown,
9
+ ): Promise<void> {
10
+ const parent = dirname(path);
11
+ await mkdir(parent, { recursive: true, mode: 0o700 });
12
+ const temporary = join(parent, `.${basename(path)}.${randomUUID()}.tmp`);
13
+ try {
14
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
15
+ flag: "wx",
16
+ mode: 0o600,
17
+ });
18
+ await rename(temporary, path);
19
+ } catch (error) {
20
+ await unlink(temporary).catch(() => {});
21
+ throw error;
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ /** Bound trial admission, stop on errors/cancellation, and drain active work. */
2
+ export async function scheduleTrials(
3
+ options: { count: number; jobs: number; signal?: AbortSignal },
4
+ run: (trial: number, stopAdmission: () => void) => Promise<boolean>,
5
+ ): Promise<void> {
6
+ let nextTrial = 1;
7
+ let stopped = false;
8
+ const stopAdmission = () => {
9
+ stopped = true;
10
+ };
11
+ let failure: { error: unknown } | undefined;
12
+ const worker = async () => {
13
+ while (!stopped && !options.signal?.aborted && nextTrial <= options.count) {
14
+ const trial = nextTrial++;
15
+ try {
16
+ if (!(await run(trial, stopAdmission))) stopAdmission();
17
+ } catch (error) {
18
+ stopped = true;
19
+ failure ??= { error };
20
+ }
21
+ }
22
+ };
23
+ await Promise.all(
24
+ Array.from({ length: Math.min(options.jobs, options.count) }, worker),
25
+ );
26
+ if (failure) throw failure.error;
27
+ }