@davesheffer/hunch 1.7.0 → 1.8.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 (71) hide show
  1. package/README.md +242 -0
  2. package/bench/constitution-exp03-v1.json +70 -0
  3. package/dist/cli/index.js +1630 -107
  4. package/dist/constitution/adapters.js +487 -0
  5. package/dist/constitution/behaviorAttestationBinding.js +17 -0
  6. package/dist/constitution/behaviorEvaluator.js +220 -0
  7. package/dist/constitution/behaviorProof.js +205 -0
  8. package/dist/constitution/behaviorWorkspace.js +124 -0
  9. package/dist/constitution/bootstrap.js +133 -0
  10. package/dist/constitution/canonical.js +51 -0
  11. package/dist/constitution/card.js +133 -0
  12. package/dist/constitution/compiler.js +176 -0
  13. package/dist/constitution/composition.js +101 -0
  14. package/dist/constitution/corpus.js +58 -0
  15. package/dist/constitution/delta.js +154 -0
  16. package/dist/constitution/disposition.js +141 -0
  17. package/dist/constitution/evaluator.js +435 -0
  18. package/dist/constitution/experiment.js +1007 -0
  19. package/dist/constitution/experimentRunner.js +344 -0
  20. package/dist/constitution/g2.js +291 -0
  21. package/dist/constitution/g2BehaviorAttestation.js +209 -0
  22. package/dist/constitution/g2BehaviorCandidates.js +703 -0
  23. package/dist/constitution/g2BehaviorDependencies.js +379 -0
  24. package/dist/constitution/g2BehaviorMaterialization.js +171 -0
  25. package/dist/constitution/g2BehaviorPolicyMaterializer.js +241 -0
  26. package/dist/constitution/g2CandidateAttestation.js +179 -0
  27. package/dist/constitution/g2Candidates.js +195 -0
  28. package/dist/constitution/g2Drills.js +122 -0
  29. package/dist/constitution/g3.js +511 -0
  30. package/dist/constitution/g3Conformance.js +132 -0
  31. package/dist/constitution/lifecycle.js +224 -0
  32. package/dist/constitution/mutation.js +262 -0
  33. package/dist/constitution/nodeTestEvidence.js +47 -0
  34. package/dist/constitution/plan.js +172 -0
  35. package/dist/constitution/policyRuntime.js +8 -0
  36. package/dist/constitution/proof.js +166 -0
  37. package/dist/constitution/repairPolicies.js +78 -0
  38. package/dist/constitution/replay.js +361 -0
  39. package/dist/constitution/replayCache.js +89 -0
  40. package/dist/constitution/replayWorker.js +34 -0
  41. package/dist/constitution/repository.js +533 -0
  42. package/dist/constitution/schema.js +545 -0
  43. package/dist/constitution/scorecard.js +106 -0
  44. package/dist/constitution/service.js +1211 -0
  45. package/dist/constitution/shadow.js +235 -0
  46. package/dist/constitution/sourceMutation.js +316 -0
  47. package/dist/constitution/structural.js +601 -0
  48. package/dist/core/autoreview.js +27 -3
  49. package/dist/core/dupdetect.js +10 -3
  50. package/dist/core/escalations.js +65 -0
  51. package/dist/core/events.js +61 -0
  52. package/dist/core/externalImports.js +24 -0
  53. package/dist/core/hookpolicy.js +3 -0
  54. package/dist/core/memorylog.js +69 -0
  55. package/dist/core/relativeImports.js +33 -0
  56. package/dist/core/repair.js +71 -0
  57. package/dist/core/reviewqueue.js +11 -0
  58. package/dist/core/stats.js +115 -0
  59. package/dist/extractors/git.js +120 -0
  60. package/dist/extractors/indexer.js +39 -38
  61. package/dist/extractors/nativeTreeSitter.js +108 -0
  62. package/dist/extractors/parse.js +5 -15
  63. package/dist/integrations/claudemd.js +8 -1
  64. package/dist/integrations/gitignore.js +8 -0
  65. package/dist/integrations/providers.js +32 -10
  66. package/dist/integrations/sync.js +16 -1
  67. package/dist/mcp/server.js +317 -1
  68. package/dist/synthesis/synthesize.js +8 -1
  69. package/dist/wiki/graph.js +301 -0
  70. package/dist/wiki/wiki.js +31 -3
  71. package/package.json +5 -1
@@ -0,0 +1,344 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { isAbsolute, join, relative, resolve } from "node:path";
6
+ import { z } from "zod";
7
+ import { canonicalHash } from "./canonical.js";
8
+ import { Exp01CaseSchema, assignmentTreatment, compileExperimentOutcome, } from "./experiment.js";
9
+ const EvaluatorOutputSchema = z.object({
10
+ valid_completion: z.boolean(),
11
+ policy_violation: z.boolean().nullable(),
12
+ task_success: z.boolean(),
13
+ build_success: z.boolean(),
14
+ unknown_or_error: z.boolean(),
15
+ refusal: z.boolean(),
16
+ confirmed_private_leak: z.boolean(),
17
+ data_loss_or_corruption: z.boolean(),
18
+ unsafe_evaluator_behavior: z.boolean(),
19
+ }).strict().superRefine((value, ctx) => {
20
+ if (value.valid_completion !== (value.policy_violation !== null)) {
21
+ ctx.addIssue({ code: "custom", path: ["policy_violation"], message: "policy_violation is required exactly for valid completions" });
22
+ }
23
+ });
24
+ function evaluatorIncidents(scored) {
25
+ return {
26
+ confirmed_private_leak: scored.confirmed_private_leak,
27
+ data_loss_or_corruption: scored.data_loss_or_corruption,
28
+ unsafe_evaluator_behavior: scored.unsafe_evaluator_behavior,
29
+ };
30
+ }
31
+ function childEnv(provider) {
32
+ const env = { ...process.env };
33
+ for (const key of [
34
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL",
35
+ "OPENAI_API_KEY", "OPENAI_BASE_URL", "AZURE_OPENAI_API_KEY",
36
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
37
+ "GOOGLE_APPLICATION_CREDENTIALS", "HUNCH_PRIVATE_DIR", "HUNCH_SYNTH_PROVIDER",
38
+ ])
39
+ delete env[key];
40
+ env.CI = "1";
41
+ env.NO_COLOR = "1";
42
+ env.FORCE_COLOR = "0";
43
+ if (provider === "claude-cli")
44
+ env.CLAUDE_CODE_SAFE_MODE = "1";
45
+ return env;
46
+ }
47
+ export function subscriptionCliVersion(provider) {
48
+ const bin = provider === "claude-cli" ? "claude" : "codex";
49
+ const run = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 8000, env: childEnv(provider), shell: false });
50
+ if (run.error || run.status !== 0)
51
+ throw new Error(`${provider} is unavailable; select an installed subscription CLI explicitly`);
52
+ const version = (run.stdout || run.stderr).trim().split(/\r?\n/)[0]?.trim();
53
+ if (!version)
54
+ throw new Error(`${provider} returned no version`);
55
+ return version;
56
+ }
57
+ function controlledPrompt(treatment) {
58
+ const value = treatment;
59
+ const context = !value.context ? "" : [
60
+ "\nThe following repository-specific context is part of this assigned condition:",
61
+ `Decision: ${value.context.decision}`,
62
+ `Rationale: ${value.context.rationale}`,
63
+ ...(value.context.executable_policy ? [`Executable policy: ${value.context.executable_policy}`] : []),
64
+ ...(value.context.causal_incident ? [`Causal incident: ${value.context.causal_incident}`] : []),
65
+ ].join("\n");
66
+ return [
67
+ "Work only in the current disposable repository. Complete the implementation task, run appropriate checks, and leave the working tree with your solution. Do not ask for interactive input. Do not inspect files outside this repository.",
68
+ value.prompt,
69
+ context,
70
+ ].filter(Boolean).join("\n\n");
71
+ }
72
+ function parseClaude(stdout, latencyMs, status, errorCode) {
73
+ try {
74
+ const parsed = JSON.parse(stdout);
75
+ const input = typeof parsed.usage?.input_tokens === "number" ? parsed.usage.input_tokens : 0;
76
+ const output = typeof parsed.usage?.output_tokens === "number" ? parsed.usage.output_tokens : 0;
77
+ return {
78
+ stdout,
79
+ exitCode: status,
80
+ errorCode,
81
+ turns: typeof parsed.num_turns === "number" ? parsed.num_turns : null,
82
+ tokens: input || output ? input + output : null,
83
+ latencyMs,
84
+ };
85
+ }
86
+ catch {
87
+ return { stdout, exitCode: status, errorCode, turns: null, tokens: null, latencyMs };
88
+ }
89
+ }
90
+ function parseCodex(stdout, latencyMs, status, errorCode) {
91
+ let turns = 0;
92
+ let tokens = 0;
93
+ for (const line of stdout.split(/\r?\n/)) {
94
+ try {
95
+ const event = JSON.parse(line);
96
+ if (event.type === "turn.completed")
97
+ turns++;
98
+ if (typeof event.usage?.input_tokens === "number")
99
+ tokens += event.usage.input_tokens;
100
+ if (typeof event.usage?.output_tokens === "number")
101
+ tokens += event.usage.output_tokens;
102
+ }
103
+ catch {
104
+ // Non-event lines stay bound into stdout's content hash.
105
+ }
106
+ }
107
+ return { stdout, exitCode: status, errorCode, turns: turns || null, tokens: tokens || null, latencyMs };
108
+ }
109
+ function invokeAgent(run, cwd, prompt, timeoutMs, dependencyRoot) {
110
+ const provider = run.runner.provider;
111
+ const model = run.runner.model_version;
112
+ const maxTurns = run.runner.max_turns;
113
+ if (!provider || !model || !maxTurns)
114
+ throw new Error("EXP-01 run has no exact provider/model binding");
115
+ const started = Date.now();
116
+ const bin = provider === "claude-cli" ? "claude" : "codex";
117
+ const claudeSettings = JSON.stringify({
118
+ sandbox: {
119
+ enabled: true,
120
+ failIfUnavailable: true,
121
+ autoAllowBashIfSandboxed: true,
122
+ allowUnsandboxedCommands: false,
123
+ filesystem: {
124
+ denyRead: ["~/"],
125
+ denyWrite: ["~/"],
126
+ allowRead: [".", dependencyRoot],
127
+ },
128
+ network: { allowedDomains: [] },
129
+ },
130
+ permissions: { deny: ["WebFetch", "WebSearch"] },
131
+ });
132
+ const args = provider === "claude-cli"
133
+ ? ["-p", "--safe-mode", "--disable-slash-commands", "--no-session-persistence", "--settings", claudeSettings, "--allowedTools", "Bash,Read,Edit,Write,Glob,Grep", "--output-format", "json", "--permission-mode", "acceptEdits", "--model", model, "--max-turns", String(maxTurns)]
134
+ : ["exec", "--json", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--sandbox", "workspace-write", "-C", cwd, "-m", model, "-"];
135
+ const result = spawnSync(bin, args, {
136
+ cwd,
137
+ env: childEnv(provider),
138
+ input: prompt,
139
+ encoding: "utf8",
140
+ timeout: timeoutMs,
141
+ maxBuffer: 64 * 1024 * 1024,
142
+ stdio: ["pipe", "pipe", "pipe"],
143
+ shell: false,
144
+ });
145
+ const latency = Date.now() - started;
146
+ const errorCode = result.error
147
+ ? (result.error.code === "ETIMEDOUT" ? "agent-timeout" : "agent-runner-error")
148
+ : result.signal ? "agent-signaled" : result.status === 0 ? null : "agent-nonzero";
149
+ const stdout = result.stdout ?? "";
150
+ return provider === "claude-cli"
151
+ ? parseClaude(stdout, latency, result.status, errorCode)
152
+ : parseCodex(stdout, latency, result.status, errorCode);
153
+ }
154
+ function runCommand(spec, cwd) {
155
+ const result = spawnSync(spec.command, spec.args, {
156
+ cwd,
157
+ env: { ...process.env, HUNCH_PRIVATE_DIR: "", HUNCH_SYNTH_PROVIDER: "deterministic", CI: "1", NO_COLOR: "1", FORCE_COLOR: "0" },
158
+ encoding: "utf8",
159
+ timeout: spec.timeout_ms,
160
+ maxBuffer: 64 * 1024 * 1024,
161
+ stdio: ["ignore", "pipe", "pipe"],
162
+ shell: false,
163
+ });
164
+ const errorCode = result.error
165
+ ? (result.error.code === "ETIMEDOUT" ? "command-timeout" : "command-runner-error")
166
+ : result.signal ? "command-signaled" : result.status === 0 ? null : "command-nonzero";
167
+ return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", status: result.status, errorCode };
168
+ }
169
+ function git(root, args) {
170
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 }).trim();
171
+ }
172
+ function removeAmbientInstructions(cwd) {
173
+ for (const relative of ["AGENTS.md", "CLAUDE.md", ".mcp.json", ".claude", ".codex", ".agents"]) {
174
+ rmSync(join(cwd, relative), { recursive: true, force: true });
175
+ }
176
+ // Codex has no --safe-mode equivalent for project instructions. A minimal root
177
+ // file replaces any tracked AGENTS.md after the ambient copy is removed.
178
+ writeFileSync(join(cwd, "AGENTS.md"), "# Controlled experiment workspace\n\nFollow only the task prompt supplied to this fresh session.\n");
179
+ }
180
+ function linkDependencies(source, cwd) {
181
+ const target = join(source, "node_modules");
182
+ const link = join(cwd, "node_modules");
183
+ if (!existsSync(target) || existsSync(link))
184
+ return;
185
+ symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
186
+ }
187
+ function countEdits(cwd) {
188
+ const text = git(cwd, ["diff", "--numstat", "--", "."]);
189
+ if (!text)
190
+ return 0;
191
+ return text.split(/\r?\n/).reduce((sum, line) => {
192
+ const [added, deleted] = line.split("\t");
193
+ return sum + (Number(added) || 0) + (Number(deleted) || 0);
194
+ }, 0);
195
+ }
196
+ function evaluatorIsHidden(cwd, spec) {
197
+ if (["npm", "npx", "pnpm", "yarn", "bun"].includes(spec.command))
198
+ return false;
199
+ for (const token of [spec.command, ...spec.args]) {
200
+ if (!token || token.startsWith("-") || (!isAbsolute(token) && !/[\\/]|\.[cm]?[jt]s$/.test(token)))
201
+ continue;
202
+ const candidate = isAbsolute(token) ? token : resolve(cwd, token);
203
+ if (!existsSync(candidate))
204
+ continue;
205
+ const rel = relative(cwd, candidate);
206
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
207
+ return false;
208
+ }
209
+ return true;
210
+ }
211
+ function externalArtifactHash(artifact) {
212
+ return `sha1:${createHash("sha1").update(readFileSync(artifact)).digest("hex")}`;
213
+ }
214
+ function failureOutcome(repository, run, assignment, status, invocationStarted, reason, errorCode, hashes = {}, now, incidents = { confirmed_private_leak: false, data_loss_or_corruption: false, unsafe_evaluator_behavior: false }) {
215
+ return repository.putOutcome(compileExperimentOutcome({
216
+ run_id: run.id,
217
+ assignment_id: assignment.id,
218
+ status,
219
+ invocation_started: invocationStarted,
220
+ metrics: null,
221
+ output_hash: hashes.output ?? null,
222
+ diff_hash: hashes.diff ?? null,
223
+ evaluator_hash: hashes.evaluator ?? null,
224
+ error_code: errorCode,
225
+ incidents,
226
+ recorder: "runner:g3-exp01",
227
+ reason,
228
+ supersedes: null,
229
+ }, run, { now }));
230
+ }
231
+ export function executeExp01Assignment(repository, run, bank, assignment, opts = {}) {
232
+ if (run.experiment !== "EXP-01" || bank.experiment !== "EXP-01")
233
+ throw new Error("automated execution is available only for EXP-01");
234
+ if (!run.assignments.some((item) => item.id === assignment.id))
235
+ throw new Error("assignment does not belong to run");
236
+ const existing = repository.listOutcomes().find((item) => item.run_id === run.id && item.assignment_id === assignment.id && !item.supersedes);
237
+ if (existing)
238
+ return existing;
239
+ const item = Exp01CaseSchema.parse(bank.cases.find((candidate) => candidate.id === assignment.case_id));
240
+ const sourceHead = git(bank.repository_root, ["rev-parse", bank.base_commit]);
241
+ if (sourceHead !== bank.base_commit)
242
+ throw new Error("case bank base_commit is not an exact commit in repository_root");
243
+ const session = mkdtempSync(join(tmpdir(), "hunch-exp01-"));
244
+ const cwd = join(session, "worktree");
245
+ let added = false;
246
+ let invocationStarted = false;
247
+ try {
248
+ const currentProviderVersion = subscriptionCliVersion(run.runner.provider);
249
+ if (currentProviderVersion !== run.runner.provider_version) {
250
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Selected subscription CLI version drifted after assignment; assignment was excluded before model invocation.", "provider-version-drift", { evaluator: canonicalHash({ expected: run.runner.provider_version, actual: currentProviderVersion }) }, opts.now);
251
+ }
252
+ execFileSync("git", ["worktree", "add", "--detach", cwd, bank.base_commit], { cwd: bank.repository_root, stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 });
253
+ added = true;
254
+ linkDependencies(bank.repository_root, cwd);
255
+ removeAmbientInstructions(cwd);
256
+ if (item.setup) {
257
+ if (!isAbsolute(item.setup.artifact) || !existsSync(item.setup.artifact) || externalArtifactHash(item.setup.artifact) !== item.setup.artifact_hash) {
258
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Case setup artifact is missing, non-external, or changed after case-bank lock.", "setup-artifact-drift", { evaluator: canonicalHash(item.setup) }, opts.now);
259
+ }
260
+ const setup = runCommand(item.setup, cwd);
261
+ if (setup.errorCode) {
262
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Case setup failed before model invocation; retained as an excluded assignment.", setup.errorCode, { evaluator: canonicalHash(setup) }, opts.now);
263
+ }
264
+ removeAmbientInstructions(cwd);
265
+ }
266
+ if (!evaluatorIsHidden(cwd, item.evaluator)) {
267
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Evaluator is visible inside the task workspace; assignment was excluded before model invocation.", "evaluator-not-hidden", { evaluator: canonicalHash(item.evaluator) }, opts.now);
268
+ }
269
+ if (!isAbsolute(item.evaluator.artifact) || !existsSync(item.evaluator.artifact) || externalArtifactHash(item.evaluator.artifact) !== item.evaluator.artifact_hash) {
270
+ return failureOutcome(repository, run, assignment, "infrastructure_failure", false, "Hidden evaluator artifact is missing, non-external, or changed after case-bank lock.", "evaluator-artifact-drift", { evaluator: canonicalHash(item.evaluator) }, opts.now);
271
+ }
272
+ const treatment = assignmentTreatment(bank, run, assignment);
273
+ invocationStarted = true;
274
+ const agent = invokeAgent(run, cwd, controlledPrompt(treatment), opts.timeoutMs ?? 30 * 60 * 1000, join(bank.repository_root, "node_modules"));
275
+ const outputHash = canonicalHash(agent.stdout);
276
+ const diff = git(cwd, ["diff", "--binary", "--", "."]);
277
+ const diffHash = canonicalHash(diff);
278
+ if (agent.errorCode) {
279
+ return failureOutcome(repository, run, assignment, "invalid_completion", true, "Subscription CLI did not produce a successful terminal run; outcome remains visible and unscored.", agent.errorCode, { output: outputHash, diff: diffHash }, opts.now);
280
+ }
281
+ const evaluator = runCommand(item.evaluator, cwd);
282
+ const evaluatorHash = canonicalHash({ artifact_hash: item.evaluator.artifact_hash, stdout: evaluator.stdout, stderr: evaluator.stderr, status: evaluator.status });
283
+ if (evaluator.errorCode) {
284
+ return failureOutcome(repository, run, assignment, "invalid_completion", true, "Hidden deterministic evaluator failed after generation; no compliant result was imputed.", evaluator.errorCode, { output: outputHash, diff: diffHash, evaluator: evaluatorHash }, opts.now);
285
+ }
286
+ let scored;
287
+ try {
288
+ scored = EvaluatorOutputSchema.parse(JSON.parse(evaluator.stdout));
289
+ }
290
+ catch {
291
+ return failureOutcome(repository, run, assignment, "invalid_completion", true, "Hidden evaluator output did not satisfy the locked protocol.", "invalid-evaluator-output", { output: outputHash, diff: diffHash, evaluator: evaluatorHash }, opts.now);
292
+ }
293
+ if (scored.refusal) {
294
+ return failureOutcome(repository, run, assignment, "refused", true, "Model refusal reported by the hidden evaluator.", "model-refusal", { output: outputHash, diff: diffHash, evaluator: evaluatorHash }, opts.now, evaluatorIncidents(scored));
295
+ }
296
+ if (!scored.valid_completion) {
297
+ return failureOutcome(repository, run, assignment, "invalid_completion", true, "Hidden evaluator classified the generated task as an invalid completion.", "invalid-completion", { output: outputHash, diff: diffHash, evaluator: evaluatorHash }, opts.now, evaluatorIncidents(scored));
298
+ }
299
+ return repository.putOutcome(compileExperimentOutcome({
300
+ run_id: run.id,
301
+ assignment_id: assignment.id,
302
+ status: "completed",
303
+ invocation_started: true,
304
+ metrics: {
305
+ valid_completion: scored.valid_completion,
306
+ policy_violation: scored.policy_violation,
307
+ task_success: scored.task_success,
308
+ build_success: scored.build_success,
309
+ unknown_or_error: scored.unknown_or_error,
310
+ refusal: scored.refusal,
311
+ turns: agent.turns,
312
+ edits: countEdits(cwd),
313
+ tokens: agent.tokens,
314
+ latency_ms: agent.latencyMs,
315
+ },
316
+ output_hash: outputHash,
317
+ diff_hash: diffHash,
318
+ evaluator_hash: evaluatorHash,
319
+ error_code: null,
320
+ incidents: {
321
+ confirmed_private_leak: scored.confirmed_private_leak,
322
+ data_loss_or_corruption: scored.data_loss_or_corruption,
323
+ unsafe_evaluator_behavior: scored.unsafe_evaluator_behavior,
324
+ },
325
+ recorder: "runner:g3-exp01",
326
+ reason: "Fresh isolated assignment completed and was scored only after generation by the locked deterministic evaluator.",
327
+ supersedes: null,
328
+ }, run, { now: opts.now }));
329
+ }
330
+ catch (error) {
331
+ return failureOutcome(repository, run, assignment, invocationStarted ? "invalid_completion" : "infrastructure_failure", invocationStarted, invocationStarted ? "Execution failed after model invocation; no compliant result was imputed." : "Infrastructure failed before a valid model invocation could be recorded.", invocationStarted ? "post-invocation-runner-failed" : "workspace-preparation-failed", { evaluator: canonicalHash(error.message) }, opts.now);
332
+ }
333
+ finally {
334
+ try {
335
+ if (added)
336
+ execFileSync("git", ["worktree", "remove", "--force", cwd], { cwd: bank.repository_root, stdio: "ignore", timeout: 120_000 });
337
+ }
338
+ catch {
339
+ // The assignment outcome already records any execution failure; prune is best effort.
340
+ }
341
+ rmSync(session, { recursive: true, force: true });
342
+ }
343
+ }
344
+ //# sourceMappingURL=experimentRunner.js.map
@@ -0,0 +1,291 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "../core/io.js";
5
+ import { shortHash } from "../core/ids.js";
6
+ import { canonicalHash } from "./canonical.js";
7
+ const HASH = /^sha1:[a-f0-9]{40}$/;
8
+ const HUMAN_ACTOR = /^(human|github|git):[^\s]+$/i;
9
+ const encode = (value) => `${JSON.stringify(value, null, 2)}\n`;
10
+ export const G2_RUNBOOK_CATEGORIES = [
11
+ "evaluator_error",
12
+ "false_positive",
13
+ "private_leak",
14
+ "stale_policy",
15
+ "provider_outage",
16
+ "corrupt_graph",
17
+ "adapter_break",
18
+ ];
19
+ const G2RunbookSelectionSchema = z.object(Object.fromEntries(G2_RUNBOOK_CATEGORIES.map((category) => [category, z.string().regex(/^rb_[A-Za-z0-9_-]+$/)]))).strict();
20
+ export const G2PlanSchema = z.object({
21
+ id: z.string().regex(/^g2plan_[a-f0-9]{10}$/),
22
+ content_hash: z.string().regex(HASH),
23
+ gate: z.literal("G2"),
24
+ policy_ids: z.array(z.string().regex(/^pol_[a-f0-9]{10}$/)).min(10, "G2 requires at least 10 selected policies"),
25
+ runbooks: G2RunbookSelectionSchema,
26
+ min_shadow_applicable: z.number().int().min(1).max(10000),
27
+ actor: z.string().regex(HUMAN_ACTOR, "G2 plan requires an explicit human actor (human:, github:, or git:)"),
28
+ reason: z.string().trim().min(1).max(2000),
29
+ supersedes: z.string().regex(/^g2plan_[a-f0-9]{10}$/).nullable(),
30
+ data_class: z.literal("private"),
31
+ authority: z.literal("none"),
32
+ created_at: z.string().datetime({ offset: true }),
33
+ }).strict().superRefine((plan, ctx) => {
34
+ if (new Set(plan.policy_ids).size !== plan.policy_ids.length) {
35
+ ctx.addIssue({ code: "custom", path: ["policy_ids"], message: "G2 policy ids must be unique" });
36
+ }
37
+ const runbookIds = Object.values(plan.runbooks);
38
+ if (new Set(runbookIds).size !== runbookIds.length) {
39
+ ctx.addIssue({ code: "custom", path: ["runbooks"], message: "G2 requires a unique runbook for every operational category" });
40
+ }
41
+ });
42
+ export function g2PlanContentHash(plan) {
43
+ const { id: _id, content_hash: _contentHash, ...body } = plan;
44
+ return canonicalHash(body);
45
+ }
46
+ export function compileG2Plan(input, opts = {}) {
47
+ const body = {
48
+ gate: "G2",
49
+ policy_ids: [...input.policy_ids].sort(),
50
+ runbooks: Object.fromEntries(G2_RUNBOOK_CATEGORIES.map((category) => [category, input.runbooks[category]])),
51
+ min_shadow_applicable: input.min_shadow_applicable ?? 20,
52
+ actor: input.actor,
53
+ reason: input.reason.trim(),
54
+ supersedes: input.supersedes ?? null,
55
+ data_class: "private",
56
+ authority: "none",
57
+ created_at: opts.now ?? new Date().toISOString(),
58
+ };
59
+ const contentHash = canonicalHash(body);
60
+ return G2PlanSchema.parse({ id: `g2plan_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
61
+ }
62
+ export const RunbookRehearsalSchema = z.object({
63
+ id: z.string().regex(/^rehearsal_[a-f0-9]{10}$/),
64
+ content_hash: z.string().regex(HASH),
65
+ runbook_id: z.string().regex(/^rb_[A-Za-z0-9_-]+$/),
66
+ runbook_hash: z.string().regex(HASH),
67
+ result: z.enum(["passed", "failed"]),
68
+ actor: z.string().regex(HUMAN_ACTOR, "runbook rehearsal requires an explicit human actor (human:, github:, or git:)"),
69
+ evidence_hashes: z.array(z.string().regex(HASH)).min(1),
70
+ notes: z.string().trim().min(1).max(4000),
71
+ supersedes: z.string().regex(/^rehearsal_[a-f0-9]{10}$/).nullable(),
72
+ data_class: z.literal("private"),
73
+ authority: z.literal("none"),
74
+ created_at: z.string().datetime({ offset: true }),
75
+ }).strict().superRefine((receipt, ctx) => {
76
+ if (new Set(receipt.evidence_hashes).size !== receipt.evidence_hashes.length) {
77
+ ctx.addIssue({ code: "custom", path: ["evidence_hashes"], message: "rehearsal evidence hashes must be unique" });
78
+ }
79
+ });
80
+ export function runbookRehearsalContentHash(receipt) {
81
+ const { id: _id, content_hash: _contentHash, ...body } = receipt;
82
+ return canonicalHash(body);
83
+ }
84
+ export function compileRunbookRehearsal(input, opts = {}) {
85
+ const body = {
86
+ runbook_id: input.runbook_id,
87
+ runbook_hash: input.runbook_hash,
88
+ result: input.result,
89
+ actor: input.actor,
90
+ evidence_hashes: [...new Set(input.evidence_hashes)].sort(),
91
+ notes: input.notes.trim(),
92
+ supersedes: input.supersedes ?? null,
93
+ data_class: "private",
94
+ authority: "none",
95
+ created_at: opts.now ?? new Date().toISOString(),
96
+ };
97
+ const contentHash = canonicalHash(body);
98
+ return RunbookRehearsalSchema.parse({ id: `rehearsal_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
99
+ }
100
+ export function currentAppendOnly(records, label, identity, singleCurrent = false) {
101
+ const byId = new Map(records.map((record) => [record.id, record]));
102
+ if (byId.size !== records.length)
103
+ throw new Error(`duplicate ${label} id`);
104
+ const childCount = new Map();
105
+ for (const record of records) {
106
+ if (!record.supersedes)
107
+ continue;
108
+ const parent = byId.get(record.supersedes);
109
+ if (!parent)
110
+ throw new Error(`${label} ${record.id} supersedes missing ${record.supersedes}`);
111
+ if (identity(parent) !== identity(record))
112
+ throw new Error(`${label} ${record.id} supersedes a different evidence target`);
113
+ childCount.set(parent.id, (childCount.get(parent.id) ?? 0) + 1);
114
+ if (childCount.get(parent.id) > 1)
115
+ throw new Error(`${label} ${parent.id} has a branched supersession chain`);
116
+ }
117
+ for (const record of records) {
118
+ const visited = new Set();
119
+ let cursor = record;
120
+ while (cursor?.supersedes) {
121
+ if (visited.has(cursor.id))
122
+ throw new Error(`${label} chain contains a cycle at ${cursor.id}`);
123
+ visited.add(cursor.id);
124
+ cursor = byId.get(cursor.supersedes);
125
+ }
126
+ }
127
+ const current = records.filter((record) => !childCount.has(record.id)).sort((a, b) => a.id.localeCompare(b.id));
128
+ const identities = new Set();
129
+ for (const record of current) {
130
+ const key = identity(record);
131
+ if (identities.has(key))
132
+ throw new Error(`${label} target ${key} has multiple current records`);
133
+ identities.add(key);
134
+ }
135
+ if (singleCurrent && current.length > 1)
136
+ throw new Error(`${label} has multiple current records`);
137
+ return current;
138
+ }
139
+ export function currentG2Plans(records) {
140
+ const parsed = records.map((record) => G2PlanSchema.parse(record));
141
+ return currentAppendOnly(parsed, "G2 plan", () => "G2", true);
142
+ }
143
+ export function currentRunbookRehearsals(records) {
144
+ const parsed = records.map((record) => RunbookRehearsalSchema.parse(record));
145
+ return currentAppendOnly(parsed, "runbook rehearsal", (record) => `${record.runbook_id}:${record.runbook_hash}`);
146
+ }
147
+ export function scoreG2Readiness(input) {
148
+ const blockers = [];
149
+ if (!input.manifest) {
150
+ blockers.push("No current private G2 evidence plan exists; a human must select the exact dogfood policies and operational runbooks.");
151
+ if (input.inventory) {
152
+ if (input.inventory.private_policies < 10)
153
+ blockers.push(`Private dogfood policy inventory is ${input.inventory.private_policies}/10 minimum.`);
154
+ if (input.inventory.private_proofs < 10)
155
+ blockers.push(`Private proof inventory is ${input.inventory.private_proofs}/10 minimum; every selected policy needs an exact current P3+ proof.`);
156
+ if (input.inventory.private_corpora < 10)
157
+ blockers.push(`Private imported-corpus inventory is ${input.inventory.private_corpora}/10 minimum; every selected policy needs known-bad and known-good fixtures.`);
158
+ if (input.inventory.private_shadow_evaluations < 200)
159
+ blockers.push(`Private shadow inventory is ${input.inventory.private_shadow_evaluations}/200 baseline observations (default 20 across 10 policies); the human plan may set a stricter bound.`);
160
+ if (input.inventory.private_runbooks < G2_RUNBOOK_CATEGORIES.length)
161
+ blockers.push(`Private operational runbook inventory is ${input.inventory.private_runbooks}/${G2_RUNBOOK_CATEGORIES.length} minimum unique category mappings.`);
162
+ if (input.inventory.private_rehearsals < G2_RUNBOOK_CATEGORIES.length)
163
+ blockers.push(`Private runbook rehearsal inventory is ${input.inventory.private_rehearsals}/${G2_RUNBOOK_CATEGORIES.length} minimum exact-content receipts.`);
164
+ }
165
+ }
166
+ if (input.manifest && input.policy_evidence.length !== input.manifest.policy_ids.length) {
167
+ blockers.push(`Selected policy evidence is incomplete (${input.policy_evidence.length}/${input.manifest.policy_ids.length}).`);
168
+ }
169
+ for (const evidence of input.policy_evidence) {
170
+ for (const reason of evidence.reasons)
171
+ blockers.push(`${evidence.policy_id}: ${reason}`);
172
+ }
173
+ if (input.manifest && input.runbook_evidence.length !== G2_RUNBOOK_CATEGORIES.length) {
174
+ blockers.push(`Operational runbook evidence is incomplete (${input.runbook_evidence.length}/${G2_RUNBOOK_CATEGORIES.length}).`);
175
+ }
176
+ for (const evidence of input.runbook_evidence) {
177
+ for (const reason of evidence.reasons)
178
+ blockers.push(`${evidence.category}/${evidence.runbook_id}: ${reason}`);
179
+ }
180
+ const activeBlocking = [...new Set(input.active_blocking_policy_ids)].sort();
181
+ if (activeBlocking.length)
182
+ blockers.push(`Blocking behavior must remain disabled for G2; active blocking policies: ${activeBlocking.join(", ")}.`);
183
+ const uniqueBlockers = [...new Set(blockers)];
184
+ const body = {
185
+ gate: "G2",
186
+ manifest: input.manifest,
187
+ policy_evidence: [...input.policy_evidence].sort((a, b) => a.policy_id.localeCompare(b.policy_id)),
188
+ runbook_evidence: [...input.runbook_evidence].sort((a, b) => a.category.localeCompare(b.category)),
189
+ active_blocking_policy_ids: activeBlocking,
190
+ blockers: uniqueBlockers,
191
+ ...(input.inventory ? { inventory: input.inventory } : {}),
192
+ recommendation: uniqueBlockers.length ? "not_ready" : "eligible_for_human_g2_signoff",
193
+ authority: "none",
194
+ g2_passed: false,
195
+ };
196
+ const contentHash = canonicalHash(body);
197
+ return { id: `g2readiness_${shortHash(contentHash)}`, content_hash: contentHash, ...body };
198
+ }
199
+ export function loadPrivateRecords(dir, prefix, parse, label) {
200
+ if (!dir || !existsSync(dir))
201
+ return [];
202
+ const records = [];
203
+ for (const name of readdirSync(dir).filter((entry) => entry.startsWith(prefix) && entry.endsWith(".json")).sort()) {
204
+ try {
205
+ records.push(parse(JSON.parse(readFileSync(join(dir, name), "utf8"))));
206
+ }
207
+ catch (error) {
208
+ throw new Error(`invalid ${label}/${name}: ${error.message}`);
209
+ }
210
+ }
211
+ return records;
212
+ }
213
+ /** Private-only append-only storage for the human-selected G2 packet and runbook drills. */
214
+ export class G2EvidenceRepository {
215
+ store;
216
+ constructor(store) {
217
+ this.store = store;
218
+ }
219
+ listPlans() {
220
+ const records = loadPrivateRecords(this.store.privateDir ? join(this.store.privateDir, "gates") : undefined, "g2plan_", (raw) => {
221
+ const parsed = G2PlanSchema.parse(raw);
222
+ if (parsed.content_hash !== g2PlanContentHash(parsed) || parsed.id !== `g2plan_${shortHash(parsed.content_hash)}`) {
223
+ throw new Error(`G2 plan ${parsed.id} content hash mismatch`);
224
+ }
225
+ return parsed;
226
+ }, "gates");
227
+ currentG2Plans(records);
228
+ return records.sort((a, b) => a.id.localeCompare(b.id));
229
+ }
230
+ currentPlan() {
231
+ return currentG2Plans(this.listPlans())[0] ?? null;
232
+ }
233
+ putPlan(plan) {
234
+ if (!this.store.privateDir)
235
+ throw new Error("No private Hunch overlay is configured; refusing to write G2 evidence.");
236
+ const parsed = G2PlanSchema.parse(plan);
237
+ if (parsed.content_hash !== g2PlanContentHash(parsed) || parsed.id !== `g2plan_${shortHash(parsed.content_hash)}`)
238
+ throw new Error(`G2 plan ${parsed.id} content hash mismatch`);
239
+ const records = this.listPlans();
240
+ const existing = records.find((record) => record.id === parsed.id);
241
+ if (existing)
242
+ return existing;
243
+ const current = currentG2Plans(records)[0];
244
+ if (current && parsed.supersedes !== current.id)
245
+ throw new Error(`G2 plan ${current.id} is current; pass supersedes:${current.id} to append a correction`);
246
+ if (!current && parsed.supersedes)
247
+ throw new Error(`G2 plan ${parsed.id} supersedes no current plan`);
248
+ currentG2Plans([...records, parsed]);
249
+ const dir = join(this.store.privateDir, "gates");
250
+ mkdirSync(dir, { recursive: true });
251
+ writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
252
+ return parsed;
253
+ }
254
+ listRehearsals() {
255
+ const records = loadPrivateRecords(this.store.privateDir ? join(this.store.privateDir, "rehearsals") : undefined, "rehearsal_", (raw) => {
256
+ const parsed = RunbookRehearsalSchema.parse(raw);
257
+ if (parsed.content_hash !== runbookRehearsalContentHash(parsed) || parsed.id !== `rehearsal_${shortHash(parsed.content_hash)}`) {
258
+ throw new Error(`runbook rehearsal ${parsed.id} content hash mismatch`);
259
+ }
260
+ return parsed;
261
+ }, "rehearsals");
262
+ currentRunbookRehearsals(records);
263
+ return records.sort((a, b) => a.id.localeCompare(b.id));
264
+ }
265
+ putRehearsal(receipt) {
266
+ if (!this.store.privateDir)
267
+ throw new Error("No private Hunch overlay is configured; refusing to write runbook rehearsal evidence.");
268
+ const parsed = RunbookRehearsalSchema.parse(receipt);
269
+ if (parsed.content_hash !== runbookRehearsalContentHash(parsed) || parsed.id !== `rehearsal_${shortHash(parsed.content_hash)}`)
270
+ throw new Error(`runbook rehearsal ${parsed.id} content hash mismatch`);
271
+ const records = this.listRehearsals();
272
+ const existing = records.find((record) => record.id === parsed.id);
273
+ if (existing)
274
+ return existing;
275
+ const sameTarget = currentRunbookRehearsals(records).find((record) => record.runbook_id === parsed.runbook_id && record.runbook_hash === parsed.runbook_hash);
276
+ if (sameTarget && parsed.supersedes !== sameTarget.id) {
277
+ throw new Error(`runbook ${parsed.runbook_id} rehearsal ${sameTarget.id} is current for this exact content; pass supersedes:${sameTarget.id} to append a correction`);
278
+ }
279
+ if (!sameTarget && parsed.supersedes)
280
+ throw new Error(`runbook rehearsal ${parsed.id} supersedes no current exact-content rehearsal`);
281
+ currentRunbookRehearsals([...records, parsed]);
282
+ const dir = join(this.store.privateDir, "rehearsals");
283
+ mkdirSync(dir, { recursive: true });
284
+ writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
285
+ return parsed;
286
+ }
287
+ }
288
+ export function runbookContentHash(runbook) {
289
+ return canonicalHash(runbook);
290
+ }
291
+ //# sourceMappingURL=g2.js.map