@davesheffer/hunch 1.8.2 → 1.9.2

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 (59) hide show
  1. package/README.md +96 -1
  2. package/dist/cli/index.js +1238 -396
  3. package/dist/constitution/adapters.js +31 -14
  4. package/dist/constitution/behaviorEvaluator.js +20 -7
  5. package/dist/constitution/behaviorProof.js +3 -2
  6. package/dist/constitution/canonical.js +7 -1
  7. package/dist/constitution/card.js +7 -2
  8. package/dist/constitution/compiler.js +71 -1
  9. package/dist/constitution/correctionPolicyMaterializer.js +496 -0
  10. package/dist/constitution/delta.js +3 -2
  11. package/dist/constitution/evaluator.js +29 -3
  12. package/dist/constitution/experiment.js +96 -5
  13. package/dist/constitution/experimentRunner.js +43 -14
  14. package/dist/constitution/g2BehaviorCandidates.js +49 -26
  15. package/dist/constitution/g2BehaviorDependencies.js +203 -14
  16. package/dist/constitution/g2Candidates.js +1 -1
  17. package/dist/constitution/lifecycle.js +17 -0
  18. package/dist/constitution/plan.js +26 -9
  19. package/dist/constitution/replacementFreeGit.js +67 -0
  20. package/dist/constitution/replay.js +6 -0
  21. package/dist/constitution/replayCache.js +1 -1
  22. package/dist/constitution/replayWorker.js +1 -1
  23. package/dist/constitution/repository.js +141 -5
  24. package/dist/constitution/safeCheckout.js +75 -0
  25. package/dist/constitution/schema.js +30 -5
  26. package/dist/constitution/service.js +74 -14
  27. package/dist/constitution/sourceMutation.js +65 -12
  28. package/dist/constitution/staticGraphBaseline.js +44 -0
  29. package/dist/constitution/structural.js +60 -4
  30. package/dist/core/autoreview.js +1 -1
  31. package/dist/core/canonicalOrder.js +6 -0
  32. package/dist/core/conformance.js +68 -27
  33. package/dist/core/docscan.js +2 -1
  34. package/dist/core/escalations.js +11 -0
  35. package/dist/core/io.js +44 -9
  36. package/dist/core/overlaySafety.js +178 -0
  37. package/dist/core/paths.js +13 -2
  38. package/dist/core/safeRepoFile.js +74 -0
  39. package/dist/extractors/comments.js +6 -8
  40. package/dist/extractors/git.js +1631 -82
  41. package/dist/extractors/indexer.js +86 -47
  42. package/dist/extractors/repoSource.js +390 -0
  43. package/dist/integrations/ciAction.js +10 -2
  44. package/dist/integrations/gitignore.js +44 -5
  45. package/dist/integrations/mergeDriver.js +23 -5
  46. package/dist/integrations/sync.js +61 -5
  47. package/dist/integrations/team.js +666 -23
  48. package/dist/mcp/server.js +261 -34
  49. package/dist/store/db.js +57 -7
  50. package/dist/store/hunchStore.js +92 -11
  51. package/dist/store/jsonStore.js +350 -63
  52. package/dist/store/schema.js +27 -11
  53. package/dist/synthesis/provider.js +13 -4
  54. package/dist/synthesis/synthesize.js +56 -19
  55. package/dist/wiki/graph.js +5 -4
  56. package/dist/wiki/wiki.js +16 -10
  57. package/package.json +15 -3
  58. package/tooling/competitive-watch.mjs +108 -0
  59. package/tooling/md1-benchmark.mjs +628 -0
@@ -1,8 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { writeFileAtomic } from "../core/io.js";
3
+ import { writeFileAtomic, writeFileAtomicIfAbsent } from "../core/io.js";
4
4
  import { shortHash } from "../core/ids.js";
5
- import { policySemanticHash, proofPlanContentHash } from "./canonical.js";
5
+ import { canonicalHash, policySemanticHash, proofPlanContentHash } from "./canonical.js";
6
6
  import { proofCorpusContentHash } from "./corpus.js";
7
7
  import { currentHistoryDispositions, historyDispositionContentHash, historyDispositionJudgmentHash } from "./disposition.js";
8
8
  import { assertCompositionBinding, compositionDescendants, policyProofHash } from "./composition.js";
@@ -218,9 +218,51 @@ export class PolicyRepository {
218
218
  writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
219
219
  return parsed;
220
220
  }
221
- putProof(proof, policyId) {
221
+ /** Publish a new policy lifecycle record without overwriting a concurrent
222
+ * writer. Used by automated proposal materializers so human authority always
223
+ * wins a race. */
224
+ putPolicyIfAbsent(policy, opts = {}) {
225
+ const parsed = PolicySpecSchema.parse(policy);
226
+ if (opts.private && opts.public)
227
+ throw new Error("choose only one policy home");
228
+ const home = opts.private ? "private" : opts.public ? "public" : parsed.data_class !== "public" || this.store.unified ? "private" : "public";
229
+ if (home === "public" && parsed.data_class !== "public") {
230
+ throw new Error(`refusing to write ${parsed.data_class} policy ${parsed.id} into the public home`);
231
+ }
232
+ const homeOpts = home === "public" ? { publicOnly: true } : { privateOnly: true };
233
+ const existing = this.getPolicy(parsed.id, homeOpts);
234
+ const otherHome = this.getPolicy(parsed.id, home === "public" ? { privateOnly: true } : { publicOnly: true });
235
+ if (existing && otherHome)
236
+ throw new Error(`policy ${parsed.id} exists in both public and private homes`);
237
+ if (existing)
238
+ return { policy: existing, created: false };
239
+ if (otherHome)
240
+ throw new Error(`policy ${parsed.id} already exists in the ${home === "public" ? "private" : "public"} home`);
241
+ const dir = this.dir(home, "policies");
242
+ mkdirSync(dir, { recursive: true });
243
+ if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed))) {
244
+ const racedOtherHome = this.getPolicy(parsed.id, home === "public" ? { privateOnly: true } : { publicOnly: true });
245
+ if (racedOtherHome)
246
+ throw new Error(`policy ${parsed.id} was published concurrently in both public and private homes`);
247
+ return { policy: parsed, created: true };
248
+ }
249
+ const winner = this.getPolicy(parsed.id, homeOpts);
250
+ if (!winner)
251
+ throw new Error(`policy ${parsed.id} appeared concurrently but could not be read`);
252
+ return { policy: winner, created: false };
253
+ }
254
+ putProof(proof, policyId, opts = {}) {
222
255
  const parsed = PolicyProofSchema.parse(proof);
223
- const home = this.homeOfPolicy(policyId) ?? (parsed.data_class === "public" && !this.store.unified ? "public" : "private");
256
+ if (opts.private && opts.public)
257
+ throw new Error("choose only one proof home");
258
+ const home = opts.private
259
+ ? "private"
260
+ : opts.public
261
+ ? "public"
262
+ : this.homeOfPolicy(policyId) ?? (parsed.data_class === "public" && !this.store.unified ? "public" : "private");
263
+ if (home === "public" && parsed.data_class !== "public") {
264
+ throw new Error(`refusing to write ${parsed.data_class} proof ${parsed.id} into the public home`);
265
+ }
224
266
  const homeOpts = home === "public" ? { publicOnly: true } : { privateOnly: true };
225
267
  const policy = this.getPolicy(policyId, homeOpts);
226
268
  if (policy) {
@@ -240,6 +282,50 @@ export class PolicyRepository {
240
282
  writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
241
283
  return parsed;
242
284
  }
285
+ /** Publish an immutable proof without replacing a concurrent writer. */
286
+ putProofIfAbsent(proof, policyId, opts = {}) {
287
+ const parsed = PolicyProofSchema.parse(proof);
288
+ if (opts.private && opts.public)
289
+ throw new Error("choose only one proof home");
290
+ const home = opts.private
291
+ ? "private"
292
+ : opts.public
293
+ ? "public"
294
+ : this.homeOfPolicy(policyId) ?? (parsed.data_class === "public" && !this.store.unified ? "public" : "private");
295
+ if (home === "public" && parsed.data_class !== "public") {
296
+ throw new Error(`refusing to write ${parsed.data_class} proof ${parsed.id} into the public home`);
297
+ }
298
+ const homeOpts = home === "public" ? { publicOnly: true } : { privateOnly: true };
299
+ const policy = this.getPolicy(policyId, homeOpts);
300
+ if (policy) {
301
+ const composition = compositionDescendants(policy, this.listPolicies(homeOpts));
302
+ assertCompositionBinding(policy, composition, parsed.composition);
303
+ if (parsed.policy_hash !== policyProofHash(policy, composition))
304
+ throw new Error(`composite proof ${parsed.id} policy hash mismatch`);
305
+ if (composition.length) {
306
+ const plan = this.listPlans(homeOpts).find((candidate) => candidate.content_hash === parsed.plan_hash);
307
+ if (!plan || plan.policy_candidate_hash !== parsed.policy_hash)
308
+ throw new Error(`composite proof ${parsed.id} has no exact bound proof plan`);
309
+ assertCompositionBinding(policy, composition, plan.composition);
310
+ }
311
+ }
312
+ const existing = this.getProof(parsed.id, homeOpts);
313
+ if (existing) {
314
+ if (immutableProofHash(existing) !== immutableProofHash(parsed))
315
+ throw new Error(`proof ${parsed.id} already exists with different immutable content`);
316
+ return { proof: existing, created: false };
317
+ }
318
+ const dir = this.dir(home, "proofs");
319
+ mkdirSync(dir, { recursive: true });
320
+ if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
321
+ return { proof: parsed, created: true };
322
+ const winner = this.getProof(parsed.id, homeOpts);
323
+ if (!winner)
324
+ throw new Error(`proof ${parsed.id} appeared concurrently but could not be read`);
325
+ if (immutableProofHash(winner) !== immutableProofHash(parsed))
326
+ throw new Error(`proof ${parsed.id} appeared concurrently with different immutable content`);
327
+ return { proof: winner, created: false };
328
+ }
243
329
  putPlan(plan, policyId, opts = {}) {
244
330
  const parsed = validatePlan(plan);
245
331
  if (opts.private && opts.public)
@@ -249,6 +335,9 @@ export class PolicyRepository {
249
335
  : opts.public
250
336
  ? "public"
251
337
  : this.homeOfPolicy(policyId) ?? (parsed.data_class === "public" && !this.store.unified ? "public" : "private");
338
+ if (home === "public" && parsed.data_class !== "public") {
339
+ throw new Error(`refusing to write ${parsed.data_class} proof plan ${parsed.id} into the public home`);
340
+ }
252
341
  const homeOpts = home === "public" ? { publicOnly: true } : { privateOnly: true };
253
342
  const policy = this.getPolicy(policyId, homeOpts);
254
343
  if (policy) {
@@ -262,6 +351,44 @@ export class PolicyRepository {
262
351
  writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
263
352
  return parsed;
264
353
  }
354
+ /** Publish an immutable proof plan without replacing a concurrent writer. */
355
+ putPlanIfAbsent(plan, policyId, opts = {}) {
356
+ const parsed = validatePlan(plan);
357
+ if (opts.private && opts.public)
358
+ throw new Error("choose only one proof-plan home");
359
+ const home = opts.private
360
+ ? "private"
361
+ : opts.public
362
+ ? "public"
363
+ : this.homeOfPolicy(policyId) ?? (parsed.data_class === "public" && !this.store.unified ? "public" : "private");
364
+ if (home === "public" && parsed.data_class !== "public") {
365
+ throw new Error(`refusing to write ${parsed.data_class} proof plan ${parsed.id} into the public home`);
366
+ }
367
+ const homeOpts = home === "public" ? { publicOnly: true } : { privateOnly: true };
368
+ const policy = this.getPolicy(policyId, homeOpts);
369
+ if (policy) {
370
+ const composition = compositionDescendants(policy, this.listPolicies(homeOpts));
371
+ assertCompositionBinding(policy, composition, parsed.composition);
372
+ if (parsed.policy_candidate_hash !== policyProofHash(policy, composition))
373
+ throw new Error(`composite plan ${parsed.id} policy hash mismatch`);
374
+ }
375
+ const existing = this.getPlan(parsed.id, homeOpts);
376
+ if (existing) {
377
+ if (existing.content_hash !== parsed.content_hash)
378
+ throw new Error(`proof plan ${parsed.id} already exists with different immutable content`);
379
+ return { plan: existing, created: false };
380
+ }
381
+ const dir = this.dir(home, "plans");
382
+ mkdirSync(dir, { recursive: true });
383
+ if (writeFileAtomicIfAbsent(join(dir, `${parsed.id}.json`), encode(parsed)))
384
+ return { plan: parsed, created: true };
385
+ const winner = this.getPlan(parsed.id, homeOpts);
386
+ if (!winner)
387
+ throw new Error(`proof plan ${parsed.id} appeared concurrently but could not be read`);
388
+ if (winner.content_hash !== parsed.content_hash)
389
+ throw new Error(`proof plan ${parsed.id} appeared concurrently with different immutable content`);
390
+ return { plan: winner, created: false };
391
+ }
265
392
  putCorpus(corpus, policyId) {
266
393
  const parsed = validateCorpus(corpus);
267
394
  if (parsed.policy_id !== policyId)
@@ -280,7 +407,12 @@ export class PolicyRepository {
280
407
  }
281
408
  putEvidence(event, opts = {}) {
282
409
  const parsed = EvidenceEventSchema.parse(event);
283
- const home = opts.private || parsed.data_class !== "public" || this.store.unified ? "private" : "public";
410
+ if (opts.private && opts.public)
411
+ throw new Error("choose only one evidence home");
412
+ const home = opts.private ? "private" : opts.public ? "public" : parsed.data_class !== "public" || this.store.unified ? "private" : "public";
413
+ if (home === "public" && parsed.data_class !== "public") {
414
+ throw new Error(`refusing to write ${parsed.data_class} evidence ${parsed.id} into the public home`);
415
+ }
284
416
  const dir = this.dir(home, "evidence");
285
417
  mkdirSync(dir, { recursive: true });
286
418
  writeFileAtomic(join(dir, `${parsed.id}.json`), encode(parsed));
@@ -395,6 +527,10 @@ export class PolicyRepository {
395
527
  return parsed;
396
528
  }
397
529
  }
530
+ function immutableProofHash(proof) {
531
+ const { generated_at: _generatedAt, ...payload } = proof;
532
+ return canonicalHash(payload);
533
+ }
398
534
  function validatePlan(raw) {
399
535
  const plan = ProofPlanSchema.parse(raw);
400
536
  const hash = proofPlanContentHash(plan);
@@ -0,0 +1,75 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ const CHECKOUT_TRANSFORM_ATTRIBUTES = ["filter", "working-tree-encoding", "ident", "eol", "text", "crlf"];
6
+ const MAX_ATTRIBUTE_BYTES = 64 * 1024 * 1024;
7
+ function nulFields(bytes) {
8
+ const fields = [];
9
+ let start = 0;
10
+ for (let end = bytes.indexOf(0, start); end !== -1; end = bytes.indexOf(0, start)) {
11
+ fields.push(bytes.subarray(start, end));
12
+ start = end + 1;
13
+ }
14
+ if (start < bytes.length)
15
+ fields.push(bytes.subarray(start));
16
+ return fields;
17
+ }
18
+ /** Inspect the exact target tree plus repository-local info attributes without
19
+ * checking it out. Any checkout transform could execute code or make the worktree
20
+ * bytes diverge from the raw blobs bound by receipts, so fail closed. Merge
21
+ * attributes are intentionally excluded: worktree materialization never invokes a
22
+ * merge driver. LFS is allowed only when callers explicitly disable its
23
+ * smudge/process hooks. */
24
+ export function hasUnsafeCheckoutAttributes(root, commit, env, opts = {}) {
25
+ const session = mkdtempSync(join(tmpdir(), "hunch-attr-index-"));
26
+ const index = join(session, "index");
27
+ const exactEnv = { ...env, GIT_INDEX_FILE: index, GIT_NO_REPLACE_OBJECTS: "1", GIT_ATTR_NOSYSTEM: "1" };
28
+ try {
29
+ execFileSync("git", ["-C", root, "read-tree", commit], {
30
+ env: exactEnv,
31
+ timeout: 10_000,
32
+ maxBuffer: MAX_ATTRIBUTE_BYTES,
33
+ stdio: ["ignore", "ignore", "ignore"],
34
+ });
35
+ const paths = execFileSync("git", ["-C", root, "ls-files", "-z"], {
36
+ env: exactEnv,
37
+ timeout: 10_000,
38
+ maxBuffer: MAX_ATTRIBUTE_BYTES,
39
+ encoding: "buffer",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ });
42
+ if (!paths.length)
43
+ return false;
44
+ const raw = execFileSync("git", ["-C", root, "check-attr", "--cached", "-z", "--stdin", ...CHECKOUT_TRANSFORM_ATTRIBUTES], {
45
+ env: exactEnv,
46
+ input: paths,
47
+ timeout: 10_000,
48
+ maxBuffer: MAX_ATTRIBUTE_BYTES,
49
+ encoding: "buffer",
50
+ stdio: ["pipe", "pipe", "ignore"],
51
+ });
52
+ const fields = nulFields(raw);
53
+ if (fields.length % 3 !== 0)
54
+ return true;
55
+ for (let index = 0; index < fields.length; index += 3) {
56
+ const attribute = fields[index + 1].toString("utf8");
57
+ const value = fields[index + 2].toString("utf8");
58
+ if (!CHECKOUT_TRANSFORM_ATTRIBUTES.includes(attribute))
59
+ return true;
60
+ if (value === "unspecified" || value === "unset")
61
+ continue;
62
+ if (opts.allowDisabledLfs && attribute === "filter" && value === "lfs")
63
+ continue;
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ catch {
69
+ return true;
70
+ }
71
+ finally {
72
+ rmSync(session, { recursive: true, force: true });
73
+ }
74
+ }
75
+ //# sourceMappingURL=safeCheckout.js.map
@@ -4,6 +4,10 @@ export const POLICY_IR_VERSION = 1;
4
4
  export const POLICY_EVALUATOR = { name: "hunch-graph-policy", version: "1.3.0" };
5
5
  export const MUTATION_ENGINE = { name: "hunch-static-graph-controls", version: "5" };
6
6
  export const EXECUTABLE_BEHAVIOR_IR_VERSION = 2;
7
+ /** Source-gated correction policies deliberately use an IR unknown to pre-Matrix
8
+ * clients. Those clients then reject the artifact instead of loading it without
9
+ * understanding (and potentially bypassing) its activation gate. */
10
+ export const CORRECTION_POLICY_IR_VERSION = 3;
7
11
  export const BEHAVIOR_POLICY_EVALUATOR = { name: "hunch-executable-behavior", version: "1.0.0" };
8
12
  export const BEHAVIOR_MUTATION_ENGINE = { name: "hunch-behavior-controls", version: "1" };
9
13
  export const DataClassSchema = z.enum(["public", "private", "secret"]);
@@ -195,10 +199,20 @@ export const PolicyAuthoritySchema = z.object({
195
199
  event: z.string().min(1),
196
200
  at: z.string().datetime({ offset: true }),
197
201
  });
202
+ export const PolicyActivationGateSchema = z.object({
203
+ kind: z.literal("source_currentness"),
204
+ status: z.literal("blocked"),
205
+ reason: z.string().min(1),
206
+ }).strict();
198
207
  export const PolicySpecSchema = z.object({
199
208
  id: z.string().regex(/^pol_[a-f0-9]{10}$/),
200
209
  topic: z.string().min(1),
201
- ir_version: z.union([z.literal(POLICY_IR_VERSION), z.literal(EXECUTABLE_BEHAVIOR_IR_VERSION)]),
210
+ origin: z.enum(["generic", "correction_md1a"]).default("generic"),
211
+ ir_version: z.union([
212
+ z.literal(POLICY_IR_VERSION),
213
+ z.literal(EXECUTABLE_BEHAVIOR_IR_VERSION),
214
+ z.literal(CORRECTION_POLICY_IR_VERSION),
215
+ ]),
202
216
  revision: z.number().int().min(1),
203
217
  state: PolicyStateSchema,
204
218
  statement: z.string().min(1),
@@ -208,6 +222,7 @@ export const PolicySpecSchema = z.object({
208
222
  severity: z.enum(["advisory", "warning", "blocking"]).default("warning"),
209
223
  surfaces: z.array(z.enum(["pre_edit", "pre_commit", "ci", "mcp", "cli"])).default(["cli", "mcp"]),
210
224
  authority: PolicyAuthoritySchema.nullable().default(null),
225
+ activation_gate: PolicyActivationGateSchema.nullable().default(null),
211
226
  evidence: z.array(z.string()).default([]),
212
227
  proof: z.string().nullable().default(null),
213
228
  reversal_conditions: z.array(z.string()).default([]),
@@ -225,11 +240,21 @@ export const PolicySpecSchema = z.object({
225
240
  updated_at: z.string().datetime({ offset: true }),
226
241
  provenance: ProvenanceSchema,
227
242
  }).passthrough().superRefine((policy, context) => {
228
- if (policy.assertion.kind === "executable-behavior" && policy.ir_version !== EXECUTABLE_BEHAVIOR_IR_VERSION) {
229
- context.addIssue({ code: "custom", path: ["ir_version"], message: `executable-behavior requires Policy IR v${EXECUTABLE_BEHAVIOR_IR_VERSION}` });
243
+ if (policy.origin === "correction_md1a") {
244
+ if (policy.assertion.kind === "executable-behavior") {
245
+ context.addIssue({ code: "custom", path: ["assertion", "kind"], message: "source-gated correction policies require a graph assertion" });
246
+ }
247
+ if (policy.ir_version !== CORRECTION_POLICY_IR_VERSION) {
248
+ context.addIssue({ code: "custom", path: ["ir_version"], message: `source-gated correction policies require Policy IR v${CORRECTION_POLICY_IR_VERSION}` });
249
+ }
230
250
  }
231
- if (policy.assertion.kind !== "executable-behavior" && policy.ir_version !== POLICY_IR_VERSION) {
232
- context.addIssue({ code: "custom", path: ["ir_version"], message: `graph assertions require Policy IR v${POLICY_IR_VERSION}` });
251
+ else {
252
+ if (policy.assertion.kind === "executable-behavior" && policy.ir_version !== EXECUTABLE_BEHAVIOR_IR_VERSION) {
253
+ context.addIssue({ code: "custom", path: ["ir_version"], message: `executable-behavior requires Policy IR v${EXECUTABLE_BEHAVIOR_IR_VERSION}` });
254
+ }
255
+ if (policy.assertion.kind !== "executable-behavior" && policy.ir_version !== POLICY_IR_VERSION) {
256
+ context.addIssue({ code: "custom", path: ["ir_version"], message: `graph assertions require Policy IR v${POLICY_IR_VERSION}` });
257
+ }
233
258
  }
234
259
  });
235
260
  export const PolicyCompositionMemberSchema = z.object({
@@ -1,10 +1,11 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { headSha } from "../extractors/git.js";
3
3
  import { canonicalHash, canonicalJson } from "./canonical.js";
4
+ import { replacementFreeGitEnvironment } from "./replacementFreeGit.js";
4
5
  import { shortHash } from "../core/ids.js";
5
6
  import { compileDecisionPolicy } from "./compiler.js";
6
- import { evaluatePolicy, policyBlocks, policyIsActive } from "./evaluator.js";
7
- import { approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy, retirePolicy, withdrawPolicy } from "./lifecycle.js";
7
+ import { evaluatePolicy, policyBlocks } from "./evaluator.js";
8
+ import { activationGateError, approvePolicy, blockingProofError, demotePolicy, linkPolicyException, proposeProvedPolicy, retirePolicy, withdrawPolicy } from "./lifecycle.js";
8
9
  import { provePolicy } from "./proof.js";
9
10
  import { PolicyRepository } from "./repository.js";
10
11
  import { bootstrapPolicies } from "./bootstrap.js";
@@ -31,8 +32,19 @@ import { evaluateExecutableBehaviorPolicy } from "./behaviorEvaluator.js";
31
32
  import { executeG2OperationalDrill } from "./g2Drills.js";
32
33
  import { G3_REQUIRED_EXPERIMENTS, G3EvidenceRepository, compileExperimentPreregistration, compileG3Plan, compileProofReviewMeasurement, scoreG3Readiness, } from "./g3.js";
33
34
  import { executeG3AdapterConformance, g3ConformanceSourceHash } from "./g3Conformance.js";
34
- import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, normalizedEditDistance, } from "./experiment.js";
35
+ import { ExperimentRepository, assignmentTreatment, buildExperimentReport, compileExperimentCaseBank, compileExperimentFollowup, compileExperimentOutcome, compileExperimentReviewStart, compileExperimentReviewerQualification, compileExperimentRun, compileExperimentStop, compileExp03ReviewResponse, currentExperimentOutcomes, experimentReviewGuide, normalizedEditDistance, } from "./experiment.js";
35
36
  import { executeExp01Assignment } from "./experimentRunner.js";
37
+ import { materializeCorrectionPolicies, materializeCorrectionPolicy, } from "./correctionPolicyMaterializer.js";
38
+ export function policyEvaluationEnvelope(result) {
39
+ return {
40
+ ...result.evaluation,
41
+ enforcement: {
42
+ blocks: result.blocks,
43
+ strict_error: result.strict_error,
44
+ gate_error: result.gate_error ?? null,
45
+ },
46
+ };
47
+ }
36
48
  function relationSummary(policy) {
37
49
  return {
38
50
  id: policy.id,
@@ -76,6 +88,7 @@ export function shadowCommitEligible(root, policy, commit) {
76
88
  const sourceCommit = policy.assertion.test.source_commit;
77
89
  const check = spawnSync("git", ["-C", root, "merge-base", "--is-ancestor", sourceCommit, commit], {
78
90
  encoding: "utf8",
91
+ env: replacementFreeGitEnvironment(),
79
92
  stdio: ["ignore", "ignore", "pipe"],
80
93
  });
81
94
  if (check.status === 0)
@@ -492,11 +505,22 @@ export class ConstitutionService {
492
505
  const bank = this.experimentRepository.listCaseBanks().find((item) => item.id === run.case_bank_id);
493
506
  if (!bank)
494
507
  throw new Error(`run ${run.id} is missing exact case bank ${run.case_bank_id}`);
508
+ const preregistration = this.g3Repository.listExperiments().find((item) => item.id === run.preregistration_id && item.content_hash === run.preregistration_hash);
509
+ const usesPlainLanguageReview = bank.cases.some((item) => "required_relationship" in item);
510
+ if (usesPlainLanguageReview && !preregistration)
511
+ throw new Error(`run ${run.id} is missing exact preregistration ${run.preregistration_id}`);
512
+ if (preregistration && preregistration.revision >= 2) {
513
+ const qualification = this.experimentRepository.listReviewerQualifications().find((item) => item.preregistration_id === preregistration.id && item.preregistration_hash === preregistration.content_hash && item.reviewer === reviewer);
514
+ if (!qualification)
515
+ throw new Error(`${reviewer} must pass the excluded plain-language comprehension check before a revision-${preregistration.revision} timed review`);
516
+ const targetReviewers = new Set(bank.cases.map((item) => item.strata.target_reviewer).filter(Boolean));
517
+ if (targetReviewers.has(reviewer) || targetReviewers.has(reviewer.replace(/^human:/i, "")))
518
+ throw new Error(`${reviewer} cannot perform timed reviews because the same actor labeled revision-${preregistration.revision} targets`);
519
+ }
495
520
  // Single-operator mitigation (expreg_9c9617cd13, revision >= 3): at least 48 hours
496
521
  // must separate the case-bank lock from the FIRST review start — enforced, not
497
522
  // merely auditable, so a violation is impossible rather than post-hoc visible.
498
- const prereg = this.g3Repository.listExperiments().find((item) => item.id === run.preregistration_id);
499
- if (prereg && prereg.revision >= 3) {
523
+ if (preregistration && preregistration.revision >= 3) {
500
524
  const elapsed = Date.parse(opts.now ?? new Date().toISOString()) - Date.parse(bank.locked_at);
501
525
  const hasStart = this.experimentRepository.listReviewStarts().some((item) => item.run_id === run.id);
502
526
  if (!hasStart && elapsed < 48 * 3_600_000) {
@@ -518,7 +542,13 @@ export class ConstitutionService {
518
542
  if (!assignment)
519
543
  throw new Error(`no unreviewed EXP-03 assignment is available for ${reviewer}`);
520
544
  const start = existing ?? this.experimentRepository.putReviewStart(compileExperimentReviewStart(run, assignment, reviewer, opts));
521
- return { start, assignment, treatment: assignmentTreatment(bank, run, assignment) };
545
+ return { start, assignment, treatment: assignmentTreatment(bank, run, assignment), review_guide: experimentReviewGuide(assignment.arm) };
546
+ }
547
+ qualifyExperimentReviewer(input, opts = {}) {
548
+ const preregistration = this.g3Repository.currentExperiments().find((item) => item.experiment === "EXP-03");
549
+ if (!preregistration)
550
+ throw new Error("no current EXP-03 preregistration");
551
+ return this.experimentRepository.putReviewerQualification(compileExperimentReviewerQualification(input, preregistration, opts));
522
552
  }
523
553
  /** Resolve an EXP-03 run/assignment/case triple (the shared lookup for both
524
554
  * review-submission dialects). */
@@ -676,6 +706,7 @@ export class ConstitutionService {
676
706
  const manifest = this.g2Repository.currentPlan();
677
707
  const commits = manifest ? execFileSync("git", ["-C", this.root, "rev-list", "--first-parent", `--max-count=${maxCommits}`, "HEAD"], {
678
708
  encoding: "utf8",
709
+ env: replacementFreeGitEnvironment(),
679
710
  stdio: ["ignore", "pipe", "ignore"],
680
711
  }).trim().split("\n").filter((commit) => /^[a-f0-9]{40}$/.test(commit)) : [];
681
712
  const attempted = (manifest?.policy_ids.length ?? 0) * commits.length;
@@ -927,6 +958,12 @@ export class ConstitutionService {
927
958
  ingest(opts = {}) {
928
959
  return ingestLocalEvidence(this.store, this.root, this.repository, opts);
929
960
  }
961
+ upgradeCorrection(id, opts = {}) {
962
+ return materializeCorrectionPolicy(this.store, this.root, this.repository, id, opts);
963
+ }
964
+ upgradeCorrections(opts = {}) {
965
+ return materializeCorrectionPolicies(this.store, this.root, this.repository, opts);
966
+ }
930
967
  plan(id, opts = {}) {
931
968
  const policy = this.get(id, opts);
932
969
  const home = opts.publicOnly ? "public" : opts.privateOnly ? "private" : this.repository.homeOfPolicy(id);
@@ -944,7 +981,7 @@ export class ConstitutionService {
944
981
  const publicOnly = policy.data_class === "public" && this.repository.homeOfPolicy(id) === "public";
945
982
  const homeOpts = publicOnly ? { publicOnly: true } : { privateOnly: true };
946
983
  const composition = this.composition(policy, homeOpts);
947
- const plan = this.plan(id, { publicOnly, now: opts.now });
984
+ const plan = this.plan(id, { publicOnly, now: opts.now, repositoryName: opts.repositoryName });
948
985
  const proof = provePolicy(this.store, this.root, policy, { publicOnly, now: opts.now, plan, composition });
949
986
  this.repository.putProof(proof, policy.id);
950
987
  const proposed = proposeProvedPolicy(policy, proof, opts.now ?? proof.generated_at, composition, this.g2BehaviorAttestationRepository.current());
@@ -1020,13 +1057,18 @@ export class ConstitutionService {
1020
1057
  throw new Error(`historical shadow backfill supports executable-behavior policies only; ${policy.id} is ${policy.assertion.kind}`);
1021
1058
  }
1022
1059
  else {
1023
- evaluation = evaluatePolicy(this.store, this.root, policy, { publicOnly: home === "public", composition });
1060
+ evaluation = evaluatePolicy(this.store, this.root, policy, {
1061
+ publicOnly: home === "public",
1062
+ composition,
1063
+ snapshot: opts.snapshot,
1064
+ behavior: opts.behavior,
1065
+ });
1024
1066
  }
1025
1067
  const latencyMs = opts.latencyMs ?? performance.now() - started;
1026
1068
  const sameMatches = canonicalJson(evaluation.matches);
1027
1069
  const alsoDetectedBy = evaluation.result === "violated"
1028
1070
  && (!opts.commit || opts.commit === currentHead)
1029
- ? this.evaluate({ activeOnly: true, publicOnly: home === "public" })
1071
+ ? this.evaluate({ activeOnly: true, publicOnly: home === "public", snapshot: opts.snapshot, behavior: opts.behavior })
1030
1072
  .filter((candidate) => candidate.policy.id !== id
1031
1073
  && candidate.evaluation.result === "violated"
1032
1074
  && canonicalJson(candidate.evaluation.matches) === sameMatches)
@@ -1057,7 +1099,10 @@ export class ConstitutionService {
1057
1099
  const audit = this.repository.listShadowDispositions(opts).filter((record) => record.policy_id === id);
1058
1100
  const current = currentShadowDispositions(audit);
1059
1101
  const history = this.repository.listDispositions(opts).filter((record) => record.policy_id === id && record.proof_id === proof.id);
1060
- const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy, record.evaluation.repository.head));
1102
+ const scoringRecords = records.filter((record) => shadowCommitEligible(this.root, policy,
1103
+ // Workspace receipts retain their content-addressed pseudo-head for
1104
+ // dedupe, but ancestry eligibility is anchored to the real base commit.
1105
+ record.evaluation.repository.base ?? record.evaluation.repository.head));
1061
1106
  const report = scoreShadowPrecision(policy, proof, scoringRecords, audit, history, thresholds);
1062
1107
  return {
1063
1108
  ...report,
@@ -1183,11 +1228,21 @@ export class ConstitutionService {
1183
1228
  }
1184
1229
  evaluate(opts = {}) {
1185
1230
  let policies = opts.id ? [this.get(opts.id, opts)] : this.list(opts);
1186
- if (opts.activeOnly)
1187
- policies = policies.filter(policyIsActive);
1231
+ // Select the persisted active lifecycle states, including invalid/tampered
1232
+ // ones. The evaluator below must surface their gate/proof error so strict CI
1233
+ // fails visibly; filtering through policyIsActive would silently erase the
1234
+ // exact fail-closed configuration errors this seam is responsible for.
1235
+ if (opts.activeOnly) {
1236
+ policies = policies.filter((policy) => policy.state === "active_advisory" || policy.state === "active_blocking");
1237
+ }
1188
1238
  return policies.map((policy) => {
1189
1239
  const composition = this.composition(policy, opts);
1190
- const evaluation = evaluatePolicy(this.store, this.root, policy, { publicOnly: opts.publicOnly, composition, behavior: opts.behavior });
1240
+ const evaluation = evaluatePolicy(this.store, this.root, policy, {
1241
+ publicOnly: opts.publicOnly,
1242
+ composition,
1243
+ behavior: opts.behavior,
1244
+ snapshot: opts.snapshot,
1245
+ });
1191
1246
  let proof;
1192
1247
  let dispositions = [];
1193
1248
  if (policy.state === "active_blocking" && policy.proof) {
@@ -1197,7 +1252,12 @@ export class ConstitutionService {
1197
1252
  dispositions = this.repository.listDispositions(homeOpts).filter((record) => record.policy_id === policy.id && record.proof_id === policy.proof);
1198
1253
  }
1199
1254
  const behaviorBindingError = executableBehaviorAttestationError(policy, this.g2BehaviorAttestationRepository.current());
1200
- const gateError = behaviorBindingError ?? blockingProofError(policy, proof, dispositions, composition, this.g2BehaviorAttestationRepository.current());
1255
+ const activeActivationError = policy.state === "active_advisory" || policy.state === "active_blocking"
1256
+ ? activationGateError(policy)
1257
+ : null;
1258
+ const gateError = behaviorBindingError
1259
+ ?? activeActivationError
1260
+ ?? blockingProofError(policy, proof, dispositions, composition, this.g2BehaviorAttestationRepository.current());
1201
1261
  return {
1202
1262
  policy,
1203
1263
  evaluation,
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join, posix, relative } from "node:path";
2
+ import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync, writeSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
4
4
  import { hunchPathsForDir } from "../core/paths.js";
5
5
  import { symbolId } from "../core/ids.js";
6
6
  import { externalPackage } from "../core/externalImports.js";
@@ -11,6 +11,7 @@ import { parseSource } from "../extractors/parse.js";
11
11
  import { HunchStore } from "../store/hunchStore.js";
12
12
  import { canonicalHash } from "./canonical.js";
13
13
  import { evaluatePolicyOnSnapshot, graphSnapshot } from "./evaluator.js";
14
+ import { hasUnsafeCheckoutAttributes } from "./safeCheckout.js";
14
15
  function safeEnvironment(home, gitConfig) {
15
16
  const env = {};
16
17
  for (const key of ["PATH", "SystemRoot", "WINDIR", "TMPDIR", "TMP", "TEMP"]) {
@@ -22,6 +23,7 @@ function safeEnvironment(home, gitConfig) {
22
23
  HOME: home,
23
24
  GIT_CONFIG_GLOBAL: gitConfig,
24
25
  GIT_CONFIG_NOSYSTEM: "1",
26
+ GIT_NO_REPLACE_OBJECTS: "1",
25
27
  GIT_TERMINAL_PROMPT: "0",
26
28
  GIT_LFS_SKIP_SMUDGE: "1",
27
29
  HUNCH_PRIVATE_DIR: "",
@@ -232,6 +234,37 @@ function removeWorktree(root, hooks, env, checkout) {
232
234
  }
233
235
  }
234
236
  }
237
+ function openRegularSourceNoFollow(checkout, sourceFile) {
238
+ const file = join(checkout, sourceFile);
239
+ const before = lstatSync(file);
240
+ if (before.isSymbolicLink())
241
+ throw new Error("mutation-source-symlink-unsupported");
242
+ if (!before.isFile())
243
+ throw new Error("mutation-source-not-regular");
244
+ const canonicalCheckout = realpathSync(checkout);
245
+ const canonicalFile = realpathSync(file);
246
+ const fromCheckout = relative(canonicalCheckout, canonicalFile);
247
+ if (!fromCheckout || fromCheckout === ".." || fromCheckout.startsWith(`..${sep}`) || isAbsolute(fromCheckout)
248
+ || canonicalFile !== resolve(canonicalCheckout, sourceFile)) {
249
+ throw new Error("mutation-source-outside-checkout");
250
+ }
251
+ let descriptor;
252
+ try {
253
+ descriptor = openSync(file, constants.O_RDWR | constants.O_NOFOLLOW);
254
+ const opened = fstatSync(descriptor);
255
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) {
256
+ throw new Error("mutation-source-changed-before-open");
257
+ }
258
+ return descriptor;
259
+ }
260
+ catch (error) {
261
+ if (descriptor !== undefined)
262
+ closeSync(descriptor);
263
+ if (error.code === "ELOOP")
264
+ throw new Error("mutation-source-symlink-unsupported");
265
+ throw error;
266
+ }
267
+ }
235
268
  /** Apply one primary mutation to an immutable disposable source checkout. No
236
269
  * project script, build, test, provider, model, or repository hook executes. */
237
270
  export function runSourceMutation(root, policy, base) {
@@ -255,6 +288,9 @@ export function runSourceMutation(root, policy, base) {
255
288
  try {
256
289
  if (unsafeLocalFilter(root, env))
257
290
  throw new Error("unsafe-local-filter-config");
291
+ if (hasUnsafeCheckoutAttributes(root, base.head, env, { allowDisabledLfs: true })) {
292
+ throw new Error("unsafe-checkout-attributes");
293
+ }
258
294
  execFileSync("git", gitArgs(root, hooks, ["worktree", "add", "--detach", "--force", checkout, base.head]), {
259
295
  env,
260
296
  timeout: 30_000,
@@ -266,15 +302,32 @@ export function runSourceMutation(root, policy, base) {
266
302
  const sourceFile = subject?.file ?? (subjectComponent ? componentFiles(base, subjectComponent)[0] : undefined);
267
303
  if (!sourceFile)
268
304
  throw new Error("mutation-subject-unresolved");
269
- const file = join(checkout, sourceFile);
270
- const mutation = mutateSource(policy, base, sourceFile, readFileSync(file, "utf8"));
271
- if ("error" in mutation)
272
- throw new Error(mutation.error);
273
- const parsed = parseSource(mutation.file, mutation.source);
274
- if (!parsed?.parseable)
275
- throw new Error("mutation-source-unparseable");
276
- writeFileSync(file, mutation.source);
277
- const diff = execFileSync("git", gitArgs(checkout, hooks, ["diff", "--no-ext-diff", "--", mutation.file]), {
305
+ const descriptor = openRegularSourceNoFollow(checkout, sourceFile);
306
+ let mutation;
307
+ try {
308
+ const attempted = mutateSource(policy, base, sourceFile, readFileSync(descriptor, "utf8"));
309
+ if ("error" in attempted)
310
+ throw new Error(attempted.error);
311
+ mutation = attempted;
312
+ if (mutation.file !== sourceFile)
313
+ throw new Error("mutation-source-target-changed");
314
+ const parsed = parseSource(mutation.file, mutation.source);
315
+ if (!parsed?.parseable)
316
+ throw new Error("mutation-source-unparseable");
317
+ ftruncateSync(descriptor, 0);
318
+ const bytes = Buffer.from(mutation.source, "utf8");
319
+ let written = 0;
320
+ while (written < bytes.length) {
321
+ const count = writeSync(descriptor, bytes, written, bytes.length - written, written);
322
+ if (!count)
323
+ throw new Error("mutation-source-write-incomplete");
324
+ written += count;
325
+ }
326
+ }
327
+ finally {
328
+ closeSync(descriptor);
329
+ }
330
+ const diff = execFileSync("git", gitArgs(checkout, hooks, ["diff", "--no-ext-diff", "--no-textconv", "--", mutation.file]), {
278
331
  env,
279
332
  encoding: "utf8",
280
333
  timeout: 10_000,
@@ -286,7 +339,7 @@ export function runSourceMutation(root, policy, base) {
286
339
  throw new Error("mutation-source-diff-too-large");
287
340
  store = new HunchStore(hunchPathsForDir(graph));
288
341
  store.json.ensureDirs();
289
- indexRepo(store, checkout, { churn: false });
342
+ indexRepo(store, checkout, { churn: false, requireComplete: true });
290
343
  const snapshot = graphSnapshot(store, root, { publicOnly: true, head: base.head });
291
344
  outcome = {
292
345
  snapshot,