@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
package/dist/cli/index.js CHANGED
@@ -17,6 +17,7 @@ import "./preflight.js"; // MUST stay the first import — Node-version gate bef
17
17
  import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
18
18
  import { execFileSync, spawnSync } from "node:child_process";
19
19
  import { join, relative, dirname, basename, resolve, isAbsolute } from "node:path";
20
+ import { fileURLToPath } from "node:url";
20
21
  import { Command } from "commander";
21
22
  import { hunchPaths, hunchPathsForDir, findRoot, toPosixTarget } from "../core/paths.js";
22
23
  import { writeFileAtomic } from "../core/io.js";
@@ -29,13 +30,16 @@ import { indexRepo } from "../extractors/indexer.js";
29
30
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
30
31
  import { parseTestReport } from "../extractors/testreport.js";
31
32
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.js";
32
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
33
+ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, workingFiles, commitFiles, asOfDate, stagedDiff, workingDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, revParse, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot, gitMemoryLog, memoryMoveDiff, revertMemoryMove, pushCurrentBranch, commitChanges } from "../extractors/git.js";
34
+ import { parseMemoryLog } from "../core/memorylog.js";
35
+ import { renamesOf, planRepair, repairDecision, repairConstraint } from "../core/repair.js";
36
+ import { planPolicyRepair, repairPolicySpec } from "../constitution/repairPolicies.js";
33
37
  import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
34
38
  import { runbookId, decisionId } from "../core/ids.js";
35
39
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
36
40
  import { extractInlineIntent } from "../extractors/comments.js";
37
41
  import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
38
- import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
42
+ import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
39
43
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
40
44
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
41
45
  import { flushCapture } from "../integrations/sync.js";
@@ -44,25 +48,35 @@ import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integr
44
48
  import { writeCiWorkflow } from "../integrations/ciAction.js";
45
49
  import { updateClaudeMd } from "../integrations/claudemd.js";
46
50
  import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
47
- import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding } from "../integrations/providers.js";
51
+ import { scaffoldProviders, regenerateGrounding, refreshExistingGrounding, refreshCommittableGrounding } from "../integrations/providers.js";
48
52
  import { healClaudeConfigCaseSplit } from "../integrations/claudeConfig.js";
49
53
  import { formatContext, formatStructure } from "../core/format.js";
50
54
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
51
55
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
56
+ import { appendEvent, readEvents } from "../core/events.js";
57
+ import { computeStats, formatStats } from "../core/stats.js";
52
58
  import { injectionMode } from "../core/hookcache.js";
53
59
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
54
60
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
55
- import { draftDuplicateOf } from "../core/dupdetect.js";
61
+ import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
56
62
  import { planAutoReview, planMutations } from "../core/autoreview.js";
57
63
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
58
64
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
59
65
  import { computeDrift } from "../core/drift.js";
66
+ import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/scorecard.js";
60
67
  import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
61
68
  import { adoptProsePrompt } from "../wiki/adopt.js";
62
69
  import { topicCollisions, renderGrounding } from "../core/topics.js";
70
+ import { pendingEscalations, policyEscalations } from "../core/escalations.js";
63
71
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
64
72
  import { compareCandidates } from "../core/compare.js";
65
73
  import { checkConformance } from "../core/conformance.js";
74
+ import { ConstitutionService } from "../constitution/service.js";
75
+ import { renderProofCard } from "../constitution/card.js";
76
+ import { movePolicyArtifactsToPrivate } from "../constitution/repository.js";
77
+ import { HistoryDispositionClassificationSchema } from "../constitution/schema.js";
78
+ import { G2_RUNBOOK_CATEGORIES } from "../constitution/g2.js";
79
+ import { subscriptionCliVersion } from "../constitution/experimentRunner.js";
66
80
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
67
81
  import { constraintId } from "../core/ids.js";
68
82
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
@@ -323,6 +337,15 @@ program
323
337
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
324
338
  }
325
339
  store.json.ensureDirs();
340
+ // Self-repair rides every sync (Phase 5, §59.5): a commit's renames heal the
341
+ // exact-path bindings they break, silently, as a revertable `repair` move.
342
+ // Fail open — a repair error must never take the capture path down.
343
+ try {
344
+ const repaired = runRepair(store, root, sha ?? headSha(root), true);
345
+ if (repaired?.applied && !opts.quiet)
346
+ console.log(` ↳ repaired ${repaired.plan.rewrites.length + repaired.policyRewrites.length} memory binding(s) after rename`);
347
+ }
348
+ catch { /* repair is best-effort; drift still surfaces anything left behind */ }
326
349
  const r = await syncCommit(store, root, sha ?? headSha(root), {
327
350
  force: opts.force,
328
351
  private: toOverlay,
@@ -333,15 +356,20 @@ program
333
356
  verify: opts.verify,
334
357
  samples: parseSamples(opts.samples),
335
358
  });
359
+ const doCommit = opts.commit ?? store.autoCommit;
336
360
  if (r.status === "written") {
337
361
  store.reindex();
338
- // Don't rewrite grounding from the hook it would dirty the working tree on
339
- // every commit. Off the hook (manual `hunch sync`), self-heal ALL existing
340
- // grounding docs (param-name fixes + fresh counts), not just CLAUDE.md.
362
+ // Refresh grounding so committed counts track the store. Git-CLEAN docs are
363
+ // refreshed and folded into the capture commit below (kills the refresh-counts
364
+ // treadmill: every capture bumped the count and re-staled the docs for the
365
+ // release gate's clean-tree check). A user-dirty doc is never touched from the
366
+ // hook; manual `hunch sync` still self-heals ALL existing grounding docs.
367
+ const groundingToStage = toOverlay ? [] : refreshCommittableGrounding(root, store);
341
368
  if (!opts.fromHook) {
342
369
  const healed = refreshExistingGrounding(root, store);
343
- if (healed.length && !opts.quiet)
344
- console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
370
+ const refreshed = [...new Set([...groundingToStage.map((file) => relative(root, file)), ...healed])];
371
+ if (refreshed.length && !opts.quiet)
372
+ console.log(` ↳ grounding refreshed: ${refreshed.join(", ")}`);
345
373
  }
346
374
  // Persist the captured decision in the repo it landed in (private store under
347
375
  // --private, else this repo). ON by default (follows auto-commit; --no-commit or
@@ -351,10 +379,9 @@ program
351
379
  // hook (no recursion). The overlay is pushed; the public .hunch/ is committed
352
380
  // WITHOUT pushing — auto-pushing the user's code branch would publish their
353
381
  // unpushed commits (bug_overlay_clobber lineage).
354
- const doCommit = opts.commit ?? store.autoCommit;
355
382
  const commitTarget = doCommit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
356
383
  if (commitTarget) {
357
- commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay });
384
+ commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay, alsoStage: groundingToStage });
358
385
  if (!opts.quiet)
359
386
  console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
360
387
  }
@@ -364,6 +391,26 @@ program
364
391
  else if (!opts.quiet) {
365
392
  console.log(`· skipped: ${r.reason}`);
366
393
  }
394
+ try {
395
+ const constitution = new ConstitutionService(store, root);
396
+ if (constitution.g2Repository.currentPlan()) {
397
+ indexRepo(store, root, { churn: false });
398
+ store.reindex();
399
+ const sweep = constitution.g2ShadowSweep();
400
+ if (sweep.recorded.length && doCommit && store.privateDir) {
401
+ commitAndPushHunch(store.privateDir, `hunch: record ${sweep.recorded.length} G2 shadow observation(s)`);
402
+ }
403
+ if (!opts.quiet) {
404
+ console.log(` ↳ G2 shadow: ${sweep.recorded.length} recorded · ${sweep.existing.length} existing · ${sweep.failures.length} failed; authority none`);
405
+ }
406
+ }
407
+ }
408
+ catch (e) {
409
+ // Post-commit learning is background/best-effort. Shadow operation must
410
+ // never make a source commit, decision capture, warning, or block fail.
411
+ if (!opts.quiet)
412
+ console.log(` ↳ G2 shadow skipped safely: ${e.message}`);
413
+ }
367
414
  store.close();
368
415
  });
369
416
  function configureOverlay(dir, opts, mode) {
@@ -491,6 +538,7 @@ function configureOverlay(dir, opts, mode) {
491
538
  const pub = new JsonStore(paths);
492
539
  const priv = new JsonStore(hunchPathsForDir(hunchDir));
493
540
  const res = movePublicMemoryToPrivate(pub, priv);
541
+ const constitutionMoved = movePolicyArtifactsToPrivate(paths.hunch, hunchDir);
494
542
  for (const kind of ENTITY_KINDS)
495
543
  pub.dropAll(kind); // public store now empty on disk
496
544
  if (isGitRepo(root))
@@ -500,7 +548,22 @@ function configureOverlay(dir, opts, mode) {
500
548
  const grounding = regenerateGrounding(root, gstore);
501
549
  gstore.close();
502
550
  commitAndPushHunch(hunchDir, "hunch: absorb public memory into private overlay"); // durable
503
- const breakdown = Object.entries(res.moved).map(([k, n]) => `${n} ${k}`).join(", ") || "0 records";
551
+ const breakdownParts = Object.entries(res.moved).map(([k, n]) => `${n} ${k}`);
552
+ if (constitutionMoved.policies)
553
+ breakdownParts.push(`${constitutionMoved.policies} policies`);
554
+ if (constitutionMoved.proofs)
555
+ breakdownParts.push(`${constitutionMoved.proofs} proofs`);
556
+ if (constitutionMoved.plans)
557
+ breakdownParts.push(`${constitutionMoved.plans} proof plans`);
558
+ if (constitutionMoved.evidence)
559
+ breakdownParts.push(`${constitutionMoved.evidence} evidence events`);
560
+ if (constitutionMoved.corpora)
561
+ breakdownParts.push(`${constitutionMoved.corpora} proof corpora`);
562
+ if (constitutionMoved.dispositions)
563
+ breakdownParts.push(`${constitutionMoved.dispositions} history dispositions`);
564
+ if (constitutionMoved.shadow)
565
+ breakdownParts.push(`${constitutionMoved.shadow} shadow records`);
566
+ const breakdown = breakdownParts.join(", ") || "0 records";
504
567
  migrateNote =
505
568
  ` ✓ migrated public memory → overlay (${breakdown}); public store emptied\n` +
506
569
  ` ✓ untracked + gitignored the .hunch memory tree — this repo is now CODE-ONLY\n` +
@@ -849,92 +912,1179 @@ program
849
912
  con++;
850
913
  }
851
914
  }
852
- store.reindex();
853
- if (dec || con)
854
- flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
855
- if (!intents.length)
856
- console.log("No `hunch-why:` / `hunch-rule:` comments found.");
857
- else
858
- console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
859
- store.close();
915
+ store.reindex();
916
+ if (dec || con)
917
+ flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
918
+ if (!intents.length)
919
+ console.log("No `hunch-why:` / `hunch-rule:` comments found.");
920
+ else
921
+ console.log(`✓ captured ${dec} decision(s) + ${con} constraint(s) from inline comments${opts.private ? " [private overlay]" : ""}`);
922
+ store.close();
923
+ });
924
+ // ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
925
+ program
926
+ .command("conform")
927
+ .description("Architectural Conformance: prove the code still SATISFIES each recorded architectural invariant (deterministic, over the graph) — the semantic rules pattern-SAST can't express: layering, must-reach, dependency direction. Catches AI changes that pass a linter but break the architecture.")
928
+ .option("--strict", "exit non-zero if any invariant is violated (use as a CI gate)")
929
+ .option("--add <title>", "record an architectural invariant instead of checking, e.g. --add \"controllers never touch the DB directly\"")
930
+ .option("--assert <kind>", "calls | not-calls | imports | not-imports | exists (with --add)")
931
+ .option("--subject <sym>", "the symbol/file:name the invariant is about (with --add)")
932
+ .option("--object <sym>", "the symbol it must reach (calls/imports) or must NOT reach (not-calls/not-imports) (with --add)")
933
+ .option("--transitive", "evaluate reachability transitively, not just direct edges (with --add)")
934
+ .option("--why <text>", "why it holds — the rationale, surfaced in the block receipt (with --add)")
935
+ .option("--bug <id>", "the bug id this invariant prevents recurring — surfaced in the receipt (with --add)")
936
+ .action((opts) => {
937
+ const { store, root } = storeFor();
938
+ if (opts.add) {
939
+ const ASSERTS = ["calls", "not-calls", "imports", "not-imports", "exists"];
940
+ if (!opts.assert || !ASSERTS.includes(opts.assert))
941
+ return fail(`--assert must be one of: ${ASSERTS.join(", ")}`);
942
+ if (!opts.subject)
943
+ return fail("--subject is required with --add");
944
+ if (opts.assert !== "exists" && !opts.object)
945
+ return fail(`--object is required for --assert ${opts.assert}`);
946
+ store.json.ensureDirs();
947
+ const now = new Date().toISOString();
948
+ const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
949
+ const d = store.putCapture("decisions", {
950
+ id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
951
+ title: opts.add,
952
+ status: "accepted",
953
+ context: opts.why ?? "",
954
+ decision: `Architectural invariant: ${opts.subject} ${opts.assert}${opts.object ? ` ${opts.object}` : ""}.`,
955
+ caused_by_bug: opts.bug ?? null,
956
+ conformance: [{ assert: opts.assert, subject: opts.subject, object: opts.assert === "exists" ? undefined : opts.object, transitive: !!opts.transitive }],
957
+ provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
958
+ date: now,
959
+ valid_from: now,
960
+ });
961
+ store.reindex();
962
+ refreshExistingGrounding(root, store); // the invariant reaches every assistant's grounding
963
+ console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
964
+ console.log(` ${opts.subject} ${arrow} ${opts.object ?? ""}${opts.transitive ? " (transitive)" : ""} [${opts.assert}]`);
965
+ console.log(` enforce on every change: hunch conform --strict (wire into CI alongside hunch ci)`);
966
+ store.close();
967
+ return;
968
+ }
969
+ store.reindex();
970
+ const results = checkConformance(store);
971
+ if (!results.length) {
972
+ console.log("No architectural invariants recorded yet.");
973
+ console.log(dim(' Record one: hunch conform --add "controllers never touch the DB directly" --assert not-calls --subject OrdersController --object dbQuery'));
974
+ store.close();
975
+ return;
976
+ }
977
+ const violations = results.filter((r) => !r.satisfied);
978
+ console.log(`Architectural conformance: ${results.length - violations.length}/${results.length} invariants satisfied\n`);
979
+ for (const r of results) {
980
+ console.log(` ${r.satisfied ? "✅" : "⛔"} ${r.decision} — "${r.title}"`);
981
+ console.log(` ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
982
+ if (!r.satisfied) {
983
+ // The receipt — WHY this invariant exists, which pattern-SAST can't tell you.
984
+ const dec = store.json.get("decisions", r.decision);
985
+ if (dec?.context)
986
+ console.log(` ↳ why: ${dec.context}`);
987
+ if (dec?.caused_by_bug)
988
+ console.log(` ↳ prevents recurrence of: ${dec.caused_by_bug}`);
989
+ }
990
+ }
991
+ if (violations.length) {
992
+ console.log(`\n⛔ ${violations.length} architectural invariant(s) the code no longer satisfies — an AI change drifted from the recorded architecture.`);
993
+ if (opts.strict)
994
+ process.exitCode = 1;
995
+ }
996
+ else {
997
+ console.log(`\n✅ the code satisfies every recorded architectural invariant.`);
998
+ }
999
+ store.close();
1000
+ });
1001
+ // ---- policy (Hunch Constitution — versioned policy/proof lifecycle) -------
1002
+ const policyCmd = program
1003
+ .command("policy")
1004
+ .description("Hunch Constitution: compile, prove, inspect, activate, and evaluate deterministic engineering policy.");
1005
+ policyCmd
1006
+ .command("list")
1007
+ .description("List Policy IR records from the public store plus the local private overlay.")
1008
+ .option("--state <state>", "filter by lifecycle state")
1009
+ .option("--public-only", "exclude private-overlay policy records")
1010
+ .option("--json", "emit id/state/severity/statement/authority/proof/data_class as JSON (the VS Code panel's data source)")
1011
+ .action((opts) => {
1012
+ const { store, root } = storeFor();
1013
+ try {
1014
+ const policies = new ConstitutionService(store, root).list({ state: opts.state, publicOnly: opts.publicOnly });
1015
+ if (opts.json) {
1016
+ console.log(JSON.stringify(policies.map((p) => ({
1017
+ id: p.id, state: p.state, severity: p.severity, statement: p.statement,
1018
+ authority: p.authority, proof: p.proof, data_class: p.data_class, topic: p.topic,
1019
+ }))));
1020
+ return;
1021
+ }
1022
+ if (!policies.length) {
1023
+ console.log("No Constitution policies found.");
1024
+ return;
1025
+ }
1026
+ for (const p of policies) {
1027
+ console.log(`${p.id} [${p.state}] [${p.severity}] ${p.statement}${p.data_class === "public" ? "" : ` [${p.data_class}]`}`);
1028
+ }
1029
+ }
1030
+ catch (e) {
1031
+ fail(e.message);
1032
+ }
1033
+ finally {
1034
+ store.close();
1035
+ }
1036
+ });
1037
+ policyCmd
1038
+ .command("show")
1039
+ .description("Show canonical Policy IR and, optionally, its proof artifact.")
1040
+ .argument("<id>", "policy id")
1041
+ .option("--proof", "include the linked proof artifact")
1042
+ .option("--public-only", "exclude private-overlay policy records")
1043
+ .action((id, opts) => {
1044
+ const { store, root } = storeFor();
1045
+ try {
1046
+ const service = new ConstitutionService(store, root);
1047
+ const policy = service.get(id, { publicOnly: opts.publicOnly });
1048
+ const output = { policy };
1049
+ if (opts.proof && policy.proof)
1050
+ output.proof = service.proof(policy.proof, { publicOnly: opts.publicOnly });
1051
+ console.log(JSON.stringify(output, null, 2));
1052
+ }
1053
+ catch (e) {
1054
+ fail(e.message);
1055
+ }
1056
+ finally {
1057
+ store.close();
1058
+ }
1059
+ });
1060
+ policyCmd
1061
+ .command("corpus")
1062
+ .description("Import or inspect a policy-bound known-good/known-bad commit corpus. Known-good fixtures may carry a human attestation; imported refs resolve to immutable SHAs.")
1063
+ .argument("<id>", "policy id")
1064
+ .option("--import <file>", "JSON file with known_bad/known_good ref and label arrays; known_good may include attestation { actor, reason }")
1065
+ .option("--public-only", "exclude private-overlay policy/corpus records when inspecting")
1066
+ .action((id, opts) => {
1067
+ const { store, root } = storeFor();
1068
+ try {
1069
+ const service = new ConstitutionService(store, root);
1070
+ if (opts.import) {
1071
+ if (opts.publicOnly)
1072
+ throw new Error("--public-only cannot be combined with --import");
1073
+ const file = resolve(root, opts.import);
1074
+ const corpus = service.importCorpus(id, JSON.parse(readFileSync(file, "utf8")));
1075
+ const attestedGood = corpus.known_good.filter((fixture) => !!fixture.attestation).length;
1076
+ console.log(`✓ imported ${corpus.id} for ${corpus.policy_id}: ${corpus.known_bad.length} known bad, ${corpus.known_good.length} known good (${attestedGood} human-attested)`);
1077
+ console.log(` hash: ${corpus.content_hash} · home follows policy data class (${corpus.data_class})`);
1078
+ }
1079
+ else {
1080
+ console.log(JSON.stringify(service.corpus(id, { publicOnly: opts.publicOnly }), null, 2));
1081
+ }
1082
+ }
1083
+ catch (e) {
1084
+ fail(e.message);
1085
+ }
1086
+ finally {
1087
+ store.close();
1088
+ }
1089
+ });
1090
+ policyCmd
1091
+ .command("card")
1092
+ .description("Render the deterministic proof card: exact policy, evidence vector, uncertainty, authority, and next actions.")
1093
+ .argument("<id>", "policy id")
1094
+ .option("--json", "emit the canonical proof-card object")
1095
+ .option("--public-only", "exclude private-overlay policy/proof records")
1096
+ .action((id, opts) => {
1097
+ const { store, root } = storeFor();
1098
+ try {
1099
+ const card = new ConstitutionService(store, root).card(id, { publicOnly: opts.publicOnly });
1100
+ console.log(opts.json ? JSON.stringify(card, null, 2) : renderProofCard(card));
1101
+ }
1102
+ catch (e) {
1103
+ fail(e.message);
1104
+ }
1105
+ finally {
1106
+ store.close();
1107
+ }
1108
+ });
1109
+ policyCmd
1110
+ .command("compile")
1111
+ .description("Compile one structured Decision.conformance predicate into a non-active Policy IR candidate.")
1112
+ .argument("<decision-id>", "source decision id")
1113
+ .option("--through <selector>", "compile a negative boundary as must-pass-through via this symbol selector")
1114
+ .option("--private", "write the policy/proof only to the configured private overlay")
1115
+ .action((decisionId, opts) => {
1116
+ const { store, root } = storeFor();
1117
+ try {
1118
+ const policy = new ConstitutionService(store, root).compile(decisionId, opts);
1119
+ console.log(`✓ compiled ${policy.id} [${policy.state}] — ${policy.statement}`);
1120
+ console.log(` ${policy.assertion.kind}: ${JSON.stringify(policy.assertion)}`);
1121
+ console.log(" authority: none; this candidate cannot block until proved and explicitly accepted by a human.");
1122
+ }
1123
+ catch (e) {
1124
+ fail(e.message);
1125
+ }
1126
+ finally {
1127
+ store.close();
1128
+ }
1129
+ });
1130
+ policyCmd
1131
+ .command("plan")
1132
+ .description("Generate or inspect the canonical, non-executing ProofPlan for a Policy IR candidate.")
1133
+ .argument("<id>", "policy id")
1134
+ .option("--history <n>", "maximum accepted-history commits", "20")
1135
+ .option("--mutations <n>", "maximum deterministic mutations", "3")
1136
+ .option("--minutes <n>", "total future replay budget in minutes", "5")
1137
+ .option("--public-only", "exclude private-overlay policy/evidence records")
1138
+ .action((id, opts) => {
1139
+ const { store, root } = storeFor();
1140
+ try {
1141
+ const values = [opts.history, opts.mutations, opts.minutes].map(Number);
1142
+ if (values.slice(0, 2).some((n) => !Number.isFinite(n) || n < 0) || !Number.isFinite(values[2]) || values[2] <= 0) {
1143
+ throw new Error("history/mutation budgets must be non-negative and minutes must be positive");
1144
+ }
1145
+ const plan = new ConstitutionService(store, root).plan(id, {
1146
+ maxCommits: values[0],
1147
+ maxMutations: values[1],
1148
+ maxMinutes: values[2],
1149
+ publicOnly: opts.publicOnly,
1150
+ });
1151
+ console.log(JSON.stringify(plan, null, 2));
1152
+ }
1153
+ catch (e) {
1154
+ fail(e.message);
1155
+ }
1156
+ finally {
1157
+ store.close();
1158
+ }
1159
+ });
1160
+ policyCmd
1161
+ .command("prove")
1162
+ .description("Execute the canonical ProofPlan: isolated current/history/control replay plus deterministic mutation; grants no authority.")
1163
+ .argument("<id>", "policy id")
1164
+ .action((id) => {
1165
+ const { store, root } = storeFor();
1166
+ try {
1167
+ indexRepo(store, root, { churn: false });
1168
+ store.reindex();
1169
+ const { policy, proof } = new ConstitutionService(store, root).prove(id);
1170
+ console.log(`POLICY PROOF ${policy.id}`);
1171
+ console.log(` state: ${policy.state} · class: ${proof.proof_class} · proof: ${proof.id}`);
1172
+ console.log(` current: ${proof.current.satisfied} satisfied · ${proof.current.violated} violated · ${proof.current.unknown} unknown · ${proof.current.error} error`);
1173
+ console.log(` known bad: ${proof.known_bad.violated}/${proof.known_bad.total} caught · known good: ${proof.known_good.satisfied}/${proof.known_good.total} satisfied`);
1174
+ console.log(` accepted history: ${proof.accepted_history.total} evaluated · ${proof.accepted_history.violated} unclassified hit · ${proof.accepted_history.unknown} unknown · ${proof.accepted_history.error} error`);
1175
+ console.log(` mutations: ${proof.mutations.violated}/${proof.mutations.total} caught · ${Object.keys(proof.mutations.operator_coverage).join(", ") || "none"}`);
1176
+ for (const limitation of proof.limitations)
1177
+ console.log(` limitation: ${limitation}`);
1178
+ console.log(" next: hunch policy accept " + policy.id + " --advisory|--blocking --actor human:<identity>");
1179
+ }
1180
+ catch (e) {
1181
+ fail(e.message);
1182
+ }
1183
+ finally {
1184
+ store.close();
1185
+ }
1186
+ });
1187
+ policyCmd
1188
+ .command("history")
1189
+ .description("Inspect or human-classify exact violated accepted-history receipts. Classifications are append-only and grant no activation authority.")
1190
+ .argument("<id>", "policy id")
1191
+ .option("--commit <sha>", "full 40-character accepted-history commit SHA")
1192
+ .option("--classify <kind>", "true_positive_actionable | true_positive_accepted_exception | false_positive_selector | false_positive_semantics | false_positive_stale | unknown_insufficient_parser")
1193
+ .option("--actor <identity>", "explicit human identity: human:, github:, or git:")
1194
+ .option("--reason <reason>", "bounded audited reason for the classification")
1195
+ .option("--supersedes <id>", "current disposition id when appending a corrected classification")
1196
+ .option("--public-only", "exclude private-overlay policy/disposition records when inspecting")
1197
+ .action((id, opts) => {
1198
+ const { store, root } = storeFor();
1199
+ try {
1200
+ const service = new ConstitutionService(store, root);
1201
+ const writing = !!opts.commit || !!opts.classify || !!opts.actor || !!opts.reason || !!opts.supersedes;
1202
+ if (writing) {
1203
+ if (opts.publicOnly)
1204
+ throw new Error("--public-only cannot be combined with history classification");
1205
+ if (!opts.commit || !opts.classify || !opts.actor || !opts.reason) {
1206
+ throw new Error("history classification requires --commit, --classify, --actor, and --reason");
1207
+ }
1208
+ const classification = HistoryDispositionClassificationSchema.parse(opts.classify);
1209
+ const disposition = service.classifyHistory(id, opts.commit, classification, opts.actor, opts.reason, { supersedes: opts.supersedes });
1210
+ console.log(`✓ recorded ${disposition.id}: ${disposition.classification} for ${disposition.commit}`);
1211
+ console.log(` proof: ${disposition.proof_id} · actor: ${disposition.actor} · home follows policy data class (${disposition.data_class})`);
1212
+ console.log(" classification grants no activation authority; blocking still requires an explicit human policy acceptance.");
1213
+ }
1214
+ else {
1215
+ console.log(JSON.stringify(service.historyDispositions(id, { publicOnly: opts.publicOnly }), null, 2));
1216
+ }
1217
+ }
1218
+ catch (e) {
1219
+ fail(e.message);
1220
+ }
1221
+ finally {
1222
+ store.close();
1223
+ }
1224
+ });
1225
+ policyCmd
1226
+ .command("shadow")
1227
+ .description("Record or inspect non-blocking shadow evaluations, append human dispositions, and derive raw precision/P4-readiness measurements. Never activates policy.")
1228
+ .argument("<id>", "policy id")
1229
+ .option("--record", "record the current deterministic evaluation once for this exact graph receipt")
1230
+ .option("--event <id>", "shadow evaluation id to classify")
1231
+ .option("--classify <kind>", "true_positive_actionable | true_positive_accepted_exception | false_positive_selector | false_positive_semantics | false_positive_stale | unknown_insufficient_parser")
1232
+ .option("--actor <identity>", "explicit human identity: human:, github:, or git:")
1233
+ .option("--reason <reason>", "bounded audited reason for the classification")
1234
+ .option("--supersedes <id>", "current shadow disposition id when appending a correction")
1235
+ .option("--min-applicable <n>", "minimum recent applicable changes for P4 review eligibility", "20")
1236
+ .option("--recent <n>", "maximum recent applicable changes in the precision window", "100")
1237
+ .option("--max-unknown-error-rate <rate>", "exclusive unknown/error-rate ceiling", "0.01")
1238
+ .option("--public-only", "exclude private-overlay shadow records when inspecting")
1239
+ .action((id, opts) => {
1240
+ const { store, root } = storeFor();
1241
+ try {
1242
+ const service = new ConstitutionService(store, root);
1243
+ const classifying = !!opts.event || !!opts.classify || !!opts.actor || !!opts.reason || !!opts.supersedes;
1244
+ if (opts.record && classifying)
1245
+ throw new Error("choose either --record or a shadow classification, not both");
1246
+ if ((opts.record || classifying) && opts.publicOnly)
1247
+ throw new Error("--public-only cannot be combined with shadow writes");
1248
+ if (opts.record) {
1249
+ indexRepo(store, root, { churn: false });
1250
+ store.reindex();
1251
+ const record = service.recordShadow(id);
1252
+ console.log(`✓ recorded ${record.id}: ${record.evaluation.result} on ${record.evaluation.repository.graph_hash}`);
1253
+ console.log(" shadow recording never warns, blocks, changes lifecycle, or grants authority.");
1254
+ return;
1255
+ }
1256
+ if (classifying) {
1257
+ if (!opts.event || !opts.classify || !opts.actor || !opts.reason) {
1258
+ throw new Error("shadow classification requires --event, --classify, --actor, and --reason");
1259
+ }
1260
+ const classification = HistoryDispositionClassificationSchema.parse(opts.classify);
1261
+ const disposition = service.classifyShadow(id, opts.event, classification, opts.actor, opts.reason, { supersedes: opts.supersedes });
1262
+ console.log(`✓ recorded ${disposition.id}: ${disposition.classification} for ${disposition.shadow_id}`);
1263
+ console.log(" disposition changes measurement only; it cannot activate or block.");
1264
+ return;
1265
+ }
1266
+ const minApplicable = Number(opts.minApplicable);
1267
+ const recentApplicable = Number(opts.recent);
1268
+ const maxUnknownErrorRate = Number(opts.maxUnknownErrorRate);
1269
+ console.log(JSON.stringify(service.shadowReport(id, { minApplicable, recentApplicable, maxUnknownErrorRate }, { publicOnly: opts.publicOnly }), null, 2));
1270
+ }
1271
+ catch (e) {
1272
+ fail(e.message);
1273
+ }
1274
+ finally {
1275
+ store.close();
1276
+ }
1277
+ });
1278
+ policyCmd
1279
+ .command("accept")
1280
+ .description("Human-only activation of a proved policy. Blocking requires P3+ proof.")
1281
+ .argument("<id>", "policy id")
1282
+ .option("--advisory", "activate as advisory")
1283
+ .option("--blocking", "activate as blocking")
1284
+ .requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
1285
+ .action((id, opts) => {
1286
+ const { store, root } = storeFor();
1287
+ try {
1288
+ if (!!opts.advisory === !!opts.blocking)
1289
+ throw new Error("choose exactly one of --advisory or --blocking");
1290
+ const mode = opts.blocking ? "blocking" : "advisory";
1291
+ const policy = new ConstitutionService(store, root).approve(id, mode, opts.actor);
1292
+ console.log(`✓ ${policy.id} is ${policy.state} by ${policy.authority?.actor} (revision ${policy.revision})`);
1293
+ }
1294
+ catch (e) {
1295
+ fail(e.message);
1296
+ }
1297
+ finally {
1298
+ store.close();
1299
+ }
1300
+ });
1301
+ policyCmd
1302
+ .command("withdraw")
1303
+ .description("Targeted advisory withdrawal: pull the human authority back (active_advisory → proposed). The policy stops surfacing as an active rule and re-enters the inline escalation loop. History retained.")
1304
+ .argument("<id>", "policy id")
1305
+ .requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
1306
+ .requiredOption("--reason <reason>", "audited withdrawal reason")
1307
+ .action((id, opts) => {
1308
+ const { store, root } = storeFor();
1309
+ try {
1310
+ const policy = new ConstitutionService(store, root).withdraw(id, opts.actor, opts.reason);
1311
+ console.log(`✓ ${policy.id} withdrawn to ${policy.state}; authority returned to the human pool (revision ${policy.revision})`);
1312
+ }
1313
+ catch (e) {
1314
+ fail(e.message);
1315
+ }
1316
+ finally {
1317
+ store.close();
1318
+ }
1319
+ });
1320
+ policyCmd
1321
+ .command("retire")
1322
+ .description("Permanently retire a policy (active or proposed → retired): it stops surfacing anywhere, its valid-time window closes, and its full history stays (supersede, never erase).")
1323
+ .argument("<id>", "policy id")
1324
+ .requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
1325
+ .requiredOption("--reason <reason>", "audited retirement reason")
1326
+ .action((id, opts) => {
1327
+ const { store, root } = storeFor();
1328
+ try {
1329
+ const policy = new ConstitutionService(store, root).retire(id, opts.actor, opts.reason);
1330
+ console.log(`✓ ${policy.id} retired; window closed, history retained (revision ${policy.revision})`);
1331
+ }
1332
+ catch (e) {
1333
+ fail(e.message);
1334
+ }
1335
+ finally {
1336
+ store.close();
1337
+ }
1338
+ });
1339
+ policyCmd
1340
+ .command("demote")
1341
+ .description("Immediately demote an active blocking policy to advisory without erasing history.")
1342
+ .argument("<id>", "policy id")
1343
+ .requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
1344
+ .requiredOption("--reason <reason>", "audited demotion reason")
1345
+ .action((id, opts) => {
1346
+ const { store, root } = storeFor();
1347
+ try {
1348
+ const policy = new ConstitutionService(store, root).demote(id, opts.actor, opts.reason);
1349
+ console.log(`✓ ${policy.id} demoted to ${policy.state}; history retained (revision ${policy.revision})`);
1350
+ }
1351
+ catch (e) {
1352
+ fail(e.message);
1353
+ }
1354
+ finally {
1355
+ store.close();
1356
+ }
1357
+ });
1358
+ policyCmd
1359
+ .command("exception")
1360
+ .description("Human-link a strictly narrower opposite policy to its parent; invalidates child proof/authority and never enables blocking.")
1361
+ .argument("<id>", "narrow exception policy id")
1362
+ .requiredOption("--parent <id>", "broader parent policy id")
1363
+ .requiredOption("--actor <identity>", "explicit human identity: human:, github:, or git:")
1364
+ .requiredOption("--reason <reason>", "audited reason this narrow opposite is intentional")
1365
+ .action((id, opts) => {
1366
+ const { store, root } = storeFor();
1367
+ try {
1368
+ const policy = new ConstitutionService(store, root).linkException(id, opts.parent, opts.actor, opts.reason);
1369
+ console.log(`✓ ${policy.id} linked as a non-blocking exception of ${policy.exception_of} (revision ${policy.revision})`);
1370
+ console.log(" proof and authority cleared; composition remains advisory until separately proved.");
1371
+ }
1372
+ catch (e) {
1373
+ fail(e.message);
1374
+ }
1375
+ finally {
1376
+ store.close();
1377
+ }
1378
+ });
1379
+ policyCmd
1380
+ .command("relations")
1381
+ .description("Inspect explicit exception-parent links without evaluating, composing, activating, or enforcing policy.")
1382
+ .argument("<id>", "policy id")
1383
+ .option("--public-only", "exclude private-overlay policy records")
1384
+ .action((id, opts) => {
1385
+ const { store, root } = storeFor();
1386
+ try {
1387
+ const relations = new ConstitutionService(store, root).relations(id, { publicOnly: opts.publicOnly });
1388
+ console.log(JSON.stringify(relations, null, 2));
1389
+ }
1390
+ catch (e) {
1391
+ fail(e.message);
1392
+ }
1393
+ finally {
1394
+ store.close();
1395
+ }
1396
+ });
1397
+ policyCmd
1398
+ .command("consolidation")
1399
+ .description("Inspect a non-mutating advisory consolidation candidate from an existing scope suggestion.")
1400
+ .argument("<id>", "anchor policy id")
1401
+ .option("--public-only", "exclude private-overlay policy records")
1402
+ .action((id, opts) => {
1403
+ const { store, root } = storeFor();
1404
+ try {
1405
+ const candidate = new ConstitutionService(store, root).consolidation(id, { publicOnly: opts.publicOnly });
1406
+ console.log(JSON.stringify(candidate, null, 2));
1407
+ }
1408
+ catch (e) {
1409
+ fail(e.message);
1410
+ }
1411
+ finally {
1412
+ store.close();
1413
+ }
1414
+ });
1415
+ policyCmd
1416
+ .command("evaluate")
1417
+ .description("Evaluate one or all policies with the neutral deterministic result algebra.")
1418
+ .argument("[id]", "optional policy id")
1419
+ .option("--active", "evaluate active policies only")
1420
+ .option("--public-only", "exclude private-overlay policies and graph records")
1421
+ .option("--staged", "evaluate executable-behavior policies against the staged index snapshot")
1422
+ .option("--working", "evaluate executable-behavior policies against all staged, unstaged, and untracked changes")
1423
+ .option("--commit <sha>", "evaluate executable-behavior policies at an exact commit")
1424
+ .option("--strict", "exit non-zero on an authorized blocking violation or evaluator error")
1425
+ .option("--json", "emit canonical receipt objects as JSON")
1426
+ .action((id, opts) => {
1427
+ const { store, root } = storeFor();
1428
+ try {
1429
+ const sources = [opts.staged && "--staged", opts.working && "--working", opts.commit && "--commit"].filter(Boolean);
1430
+ if (sources.length > 1)
1431
+ throw new Error(`pick one executable-behavior snapshot source (got ${sources.join(", ")})`);
1432
+ if (opts.commit && !revExists(opts.commit, root))
1433
+ throw new Error(`--commit ref "${opts.commit}" does not resolve`);
1434
+ const behavior = opts.staged ? { workspace: "staged" }
1435
+ : opts.working ? { workspace: "working" }
1436
+ : opts.commit ? { commit: revParse(opts.commit, root) }
1437
+ : undefined;
1438
+ indexRepo(store, root, { churn: false });
1439
+ store.reindex();
1440
+ const results = new ConstitutionService(store, root).evaluate({ id, activeOnly: opts.active, publicOnly: opts.publicOnly, behavior });
1441
+ if (opts.json)
1442
+ console.log(JSON.stringify(results.map((r) => r.evaluation), null, 2));
1443
+ else
1444
+ renderPolicyEvaluations(results).forEach((line) => console.log(line));
1445
+ if (opts.strict && results.some((r) => r.blocks || r.strict_error))
1446
+ process.exitCode = 1;
1447
+ }
1448
+ catch (e) {
1449
+ fail(e.message);
1450
+ }
1451
+ finally {
1452
+ store.close();
1453
+ }
1454
+ });
1455
+ function renderPolicyEvaluations(results) {
1456
+ if (!results.length)
1457
+ return ["No Constitution policies matched."];
1458
+ const icon = { satisfied: "✅", violated: "⛔", not_applicable: "·", unknown: "?", error: "‼" };
1459
+ const out = [`Constitution policy evaluation: ${results.length} canonical receipt(s)`];
1460
+ for (const r of results) {
1461
+ out.push(` ${icon[r.evaluation.result] ?? "·"} ${r.policy.id} [${r.policy.state}] ${r.evaluation.result}${r.blocks ? " — BLOCK" : ""}`);
1462
+ out.push(` ${r.evaluation.explanation}`);
1463
+ if (r.gate_error)
1464
+ out.push(` gate error: ${r.gate_error}`);
1465
+ out.push(` receipt: ${r.evaluation.deterministic_hash}`);
1466
+ }
1467
+ return out;
1468
+ }
1469
+ // ---- constitution (deterministic evidence -> candidate bootstrap) --------
1470
+ const constitutionCmd = program
1471
+ .command("constitution")
1472
+ .description("Bootstrap Hunch Constitution candidates from attributable structured evidence.");
1473
+ constitutionCmd
1474
+ .command("scorecard")
1475
+ .description("Validate and score a versioned EXP-03 Intent Compiler case bank; writes nothing.")
1476
+ .argument("[file]", "path to a version-1 EXP-03 JSON case bank", join(dirname(fileURLToPath(import.meta.url)), "../../bench/constitution-exp03-v1.json"))
1477
+ .option("--json", "emit the canonical scorecard as JSON")
1478
+ .action((file, opts) => {
1479
+ try {
1480
+ const scorecard = scoreCompilerCaseBank(JSON.parse(readFileSync(resolve(file), "utf8")));
1481
+ console.log(opts.json ? JSON.stringify(scorecard, null, 2) : renderCompilerScorecard(scorecard));
1482
+ if (!scorecard.passed)
1483
+ process.exitCode = 1;
1484
+ }
1485
+ catch (e) {
1486
+ fail(e.message);
1487
+ }
1488
+ });
1489
+ constitutionCmd
1490
+ .command("ingest")
1491
+ .description("Normalize local corrections/failures, committed instructions/ADRs, and local review/PR exports into Git-native EvidenceEvents; creates no policy authority.")
1492
+ .option("--since <duration>", "evidence window, e.g. 90d or 12w", "90d")
1493
+ .option("--max-events <n>", "maximum events normalized in one run (hard-capped at 200)", "100")
1494
+ .option("--instructions", "hash-normalize bounded committed instruction and ADR markdown")
1495
+ .option("--from <files...>", "strict local review/conversation/PR export JSON file(s)")
1496
+ .option("--public-only", "read/write only public records")
1497
+ .option("--private", "read/write only the configured private overlay")
1498
+ .action((opts) => {
1499
+ const { store, root } = storeFor();
1500
+ try {
1501
+ const maxEvents = Number(opts.maxEvents);
1502
+ if (!Number.isFinite(maxEvents) || maxEvents <= 0)
1503
+ throw new Error("--max-events must be a positive number");
1504
+ const report = new ConstitutionService(store, root).ingest({
1505
+ since: opts.since,
1506
+ maxEvents,
1507
+ instructions: opts.instructions,
1508
+ importFiles: opts.from,
1509
+ publicOnly: opts.publicOnly,
1510
+ privateOnly: opts.private,
1511
+ });
1512
+ console.log(`Constitution evidence ingest: scanned ${report.scanned} record(s), ${report.eligible} eligible`);
1513
+ console.log(` ${report.normalized} normalized · ${report.existing} existing · ${report.covered} covered · ${report.uncompilable} uncompilable · ${report.excluded} excluded`);
1514
+ for (const event of report.events) {
1515
+ console.log(` ${event.id} [${event.kind}/${event.compiler?.status ?? "normalized"}] ${event.text_ref ?? ""}`);
1516
+ }
1517
+ console.log(" authority: none; ingestion never proves, proposes, activates, or blocks.");
1518
+ }
1519
+ catch (e) {
1520
+ fail(e.message);
1521
+ }
1522
+ finally {
1523
+ store.close();
1524
+ }
1525
+ });
1526
+ constitutionCmd
1527
+ .command("delta")
1528
+ .description("Inspect the exact first-parent structural delta and supported candidates for one human-confirmed fix/revert decision; writes nothing.")
1529
+ .argument("<decision-id>", "git-anchored decision id")
1530
+ .option("--public-only", "read only the public decision and graph")
1531
+ .option("--private", "read only the configured private overlay decision and union graph")
1532
+ .action((decisionId, opts) => {
1533
+ const { store, root } = storeFor();
1534
+ try {
1535
+ const inspection = new ConstitutionService(store, root).inspectStructural(decisionId, {
1536
+ publicOnly: opts.publicOnly,
1537
+ privateOnly: opts.private,
1538
+ });
1539
+ console.log(JSON.stringify(inspection, null, 2));
1540
+ }
1541
+ catch (e) {
1542
+ fail(e.message);
1543
+ }
1544
+ finally {
1545
+ store.close();
1546
+ }
1547
+ });
1548
+ constitutionCmd
1549
+ .command("bootstrap")
1550
+ .description("Normalize eligible decisions into evidence events and compile at most three non-active Policy IR candidates.")
1551
+ .option("--since <duration>", "evidence window, e.g. 90d or 12w", "90d")
1552
+ .option("--max-candidates <n>", "maximum surfaced candidates (hard-capped at 3)", "3")
1553
+ .option("--public-only", "read/write only public evidence and policies")
1554
+ .option("--private", "read/write only the configured private overlay")
1555
+ .option("--history", "use exact first-parent deltas from human-confirmed fix/revert decisions; ambiguous deltas remain uncompilable")
1556
+ .action((opts) => {
1557
+ const { store, root } = storeFor();
1558
+ try {
1559
+ const requested = Number(opts.maxCandidates);
1560
+ if (!Number.isFinite(requested) || requested <= 0)
1561
+ throw new Error("--max-candidates must be a positive number");
1562
+ if (opts.history) {
1563
+ indexRepo(store, root, { churn: false });
1564
+ store.reindex();
1565
+ }
1566
+ const report = new ConstitutionService(store, root).bootstrap({
1567
+ since: opts.since,
1568
+ maxCandidates: requested,
1569
+ publicOnly: opts.publicOnly,
1570
+ privateOnly: opts.private,
1571
+ history: opts.history,
1572
+ });
1573
+ console.log(`Constitution ${opts.history ? "history " : ""}bootstrap: scanned ${report.scanned} decision(s), ${report.eligible} eligible`);
1574
+ for (const candidate of report.compiled) {
1575
+ console.log(` + ${candidate.policy.id} [${candidate.policy.state}] ${candidate.policy.statement}`);
1576
+ console.log(` evidence: ${candidate.evidence.id} · ${candidate.policy.assertion.kind} · authority: none`);
1577
+ }
1578
+ console.log(` ${report.compiled.length} compiled · ${report.covered} already covered · ${report.conflicted} conflicted · ${report.deferred} deferred by max-three cap · ${report.uncompilable} uncompilable`);
1579
+ if (!report.compiled.length)
1580
+ console.log(" No new candidates; existing policy lifecycle states were left untouched.");
1581
+ }
1582
+ catch (e) {
1583
+ fail(e.message);
1584
+ }
1585
+ finally {
1586
+ store.close();
1587
+ }
1588
+ });
1589
+ constitutionCmd
1590
+ .command("g2")
1591
+ .description("Inspect exact private G2 evidence; optionally append a human plan, runbook rehearsal, or candidate review. Never grants policy authority.")
1592
+ .option("--plan <file>", "append a private human-selected G2 plan from JSON")
1593
+ .option("--rehearse <runbook-id>", "append a rehearsal receipt for the exact current private runbook content")
1594
+ .option("--attest <candidate-id>", "append an exact private human selection or rejection for one reviewed candidate")
1595
+ .option("--observe", "record at most one private shadow observation per selected policy for the current real HEAD/graph")
1596
+ .option("--backfill <n>", "atomically record shadow observations across up to n real first-parent commits after an all-policy preflight")
1597
+ .option("--drill <category>", "execute one exact selected operational runbook drill, or all seven with category=all; writes no evidence")
1598
+ .option("--queue <limit>", "return the bounded current-proof queue of unclassified G2 shadow violations")
1599
+ .option("--candidates <limit>", "return a bounded read-only review packet of exact fix-history candidates requiring human attestation")
1600
+ .option("--behavior-candidates <limit>", "derive bounded behavior-level candidates from rejected grounded proxies plus newly added regression tests")
1601
+ .option("--behavior-decision <decision-id>", "scope behavior review/replay/materialization to one exact current human-confirmed decision")
1602
+ .option("--behavior-replay <candidate-id>", "run one exact behavior candidate in disposable known-bad/good worktrees")
1603
+ .option("--behavior-deps <candidate-id>", "explicitly build or validate content-addressed historical dependency snapshots for one behavior candidate")
1604
+ .option("--behavior-attest <candidate-id>", "append an exact private human selection or rejection bound to a snapshot-backed behavior replay")
1605
+ .option("--behavior-materialize", "assess current selected behavior attestations against the exact supported Policy IR without creating artifacts")
1606
+ .option("--behavior-policy-materialize", "materialize and P3-prove every current selected behavior as a private non-authoritative policy proposal")
1607
+ .option("--behavior-review-hash <hash>", "exact behavior-candidate review content hash being replayed or provisioned")
1608
+ .option("--allow-install-script <packages...>", "dependency packages explicitly allowed to run lifecycle scripts while building --behavior-deps")
1609
+ .option("--dependency-timeout-ms <n>", "timeout for each npm dependency snapshot operation", "300000")
1610
+ .option("--candidate-since <window>", "git history window for candidate review or attestation", "180d")
1611
+ .option("--candidate-commits <n>", "maximum fix-labeled commits inspected for candidate review or attestation", "100")
1612
+ .option("--candidate-limit <n>", "exact review-packet limit used by --attest or --behavior-replay", "30")
1613
+ .option("--review-hash <hash>", "exact candidate review content hash being attested")
1614
+ .option("--disposition <value>", "candidate disposition: selected | rejected")
1615
+ .option("--result <result>", "rehearsal result: passed | failed")
1616
+ .option("--actor <actor>", "explicit human actor (human:, github:, or git:)")
1617
+ .option("--evidence <hashes...>", "one or more sha1 evidence hashes for a rehearsal")
1618
+ .option("--notes <text>", "rehearsal evidence notes")
1619
+ .option("--reason <text>", "candidate selection or rejection rationale")
1620
+ .option("--supersedes <id>", "current rehearsal or candidate attestation corrected by this append-only receipt")
1621
+ .option("--strict", "exit nonzero while the packet is not eligible for explicit human G2 signoff")
1622
+ .action((opts) => {
1623
+ const { store, root } = storeFor();
1624
+ try {
1625
+ const queueRequested = opts.queue !== undefined;
1626
+ const candidatesRequested = opts.candidates !== undefined;
1627
+ const behaviorCandidatesRequested = opts.behaviorCandidates !== undefined;
1628
+ const behaviorReplayRequested = opts.behaviorReplay !== undefined;
1629
+ const behaviorDepsRequested = opts.behaviorDeps !== undefined;
1630
+ const behaviorAttestRequested = opts.behaviorAttest !== undefined;
1631
+ const behaviorMaterializeRequested = opts.behaviorMaterialize === true;
1632
+ const behaviorPolicyMaterializeRequested = opts.behaviorPolicyMaterialize === true;
1633
+ const backfillRequested = opts.backfill !== undefined;
1634
+ const drillRequested = opts.drill !== undefined;
1635
+ const behaviorActionRequested = behaviorCandidatesRequested || behaviorReplayRequested || behaviorDepsRequested
1636
+ || behaviorAttestRequested || behaviorMaterializeRequested || behaviorPolicyMaterializeRequested;
1637
+ const attestRequested = opts.attest !== undefined;
1638
+ const actions = [!!opts.plan, !!opts.rehearse, attestRequested, !!opts.observe, backfillRequested, drillRequested, queueRequested, candidatesRequested, behaviorCandidatesRequested, behaviorReplayRequested, behaviorDepsRequested, behaviorAttestRequested, behaviorMaterializeRequested, behaviorPolicyMaterializeRequested].filter(Boolean).length;
1639
+ if (actions > 1)
1640
+ throw new Error("choose only one G2 plan, rehearsal, structural attestation, observation, historical backfill, operational drill, queue, structural candidate, behavior candidate, behavior replay, dependency snapshot, behavior attestation, behavior assessment, or behavior policy materialization action");
1641
+ if (opts.allowInstallScript && !behaviorDepsRequested && !behaviorPolicyMaterializeRequested)
1642
+ throw new Error("--allow-install-script requires --behavior-deps or --behavior-policy-materialize");
1643
+ if (opts.behaviorDecision && !behaviorActionRequested)
1644
+ throw new Error("--behavior-decision requires a behavior candidate, replay, dependency, attestation, assessment, or materialization action");
1645
+ const service = new ConstitutionService(store, root);
1646
+ let output;
1647
+ if (opts.plan) {
1648
+ const appended = service.createG2Plan(JSON.parse(readFileSync(resolve(opts.plan), "utf8")));
1649
+ output = { appended, readiness: service.g2Readiness() };
1650
+ }
1651
+ else if (attestRequested) {
1652
+ if (opts.result || opts.evidence || opts.notes)
1653
+ throw new Error("--attest does not accept rehearsal --result, --evidence, or --notes");
1654
+ if (!opts.reviewHash)
1655
+ throw new Error("--attest requires --review-hash");
1656
+ if (opts.disposition !== "selected" && opts.disposition !== "rejected")
1657
+ throw new Error("--attest requires --disposition selected|rejected");
1658
+ if (!opts.actor)
1659
+ throw new Error("--attest requires --actor");
1660
+ if (!opts.reason)
1661
+ throw new Error("--attest requires --reason");
1662
+ const reviewOptions = {
1663
+ since: opts.candidateSince,
1664
+ maxCommits: Number(opts.candidateCommits),
1665
+ limit: Number(opts.candidateLimit),
1666
+ };
1667
+ const appended = service.attestG2Candidate(opts.attest, opts.reviewHash, opts.disposition, opts.actor, opts.reason, {
1668
+ ...reviewOptions,
1669
+ supersedes: opts.supersedes,
1670
+ });
1671
+ output = { appended, review: service.g2CandidateReview(reviewOptions) };
1672
+ }
1673
+ else if (opts.rehearse) {
1674
+ if (opts.reviewHash || opts.disposition || opts.reason)
1675
+ throw new Error("--rehearse does not accept candidate --review-hash, --disposition, or --reason");
1676
+ if (opts.result !== "passed" && opts.result !== "failed")
1677
+ throw new Error("--rehearse requires --result passed|failed");
1678
+ if (!opts.actor)
1679
+ throw new Error("--rehearse requires --actor");
1680
+ if (!opts.evidence?.length)
1681
+ throw new Error("--rehearse requires at least one --evidence sha1 hash");
1682
+ if (!opts.notes)
1683
+ throw new Error("--rehearse requires --notes");
1684
+ const appended = service.recordRunbookRehearsal(opts.rehearse, opts.result, opts.actor, opts.evidence, opts.notes, { supersedes: opts.supersedes });
1685
+ output = { appended, readiness: service.g2Readiness() };
1686
+ }
1687
+ else if (opts.observe) {
1688
+ indexRepo(store, root, { churn: false });
1689
+ store.reindex();
1690
+ output = { sweep: service.g2ShadowSweep(), readiness: service.g2Readiness() };
1691
+ }
1692
+ else if (backfillRequested) {
1693
+ output = { backfill: service.g2ShadowBackfill(Number(opts.backfill)), readiness: service.g2Readiness() };
1694
+ }
1695
+ else if (drillRequested) {
1696
+ const categories = opts.drill === "all" ? [...G2_RUNBOOK_CATEGORIES] : G2_RUNBOOK_CATEGORIES.filter((category) => category === opts.drill);
1697
+ if (!categories.length)
1698
+ throw new Error(`--drill must be all or one of: ${G2_RUNBOOK_CATEGORIES.join(", ")}`);
1699
+ output = { drills: categories.map((category) => service.g2OperationalDrill(category)), readiness: service.g2Readiness() };
1700
+ }
1701
+ else if (queueRequested) {
1702
+ output = service.g2ShadowQueue(Number(opts.queue));
1703
+ }
1704
+ else if (candidatesRequested) {
1705
+ output = service.g2CandidateReview({ since: opts.candidateSince, maxCommits: Number(opts.candidateCommits), limit: Number(opts.candidates) });
1706
+ }
1707
+ else if (behaviorCandidatesRequested) {
1708
+ if (opts.behaviorReviewHash)
1709
+ throw new Error("--behavior-candidates does not accept --behavior-review-hash");
1710
+ output = service.g2BehaviorCandidateReview({ since: opts.candidateSince, maxCommits: Number(opts.candidateCommits), limit: Number(opts.behaviorCandidates), decisionId: opts.behaviorDecision });
1711
+ }
1712
+ else if (behaviorReplayRequested) {
1713
+ if (!opts.behaviorReviewHash)
1714
+ throw new Error("--behavior-replay requires --behavior-review-hash");
1715
+ if (opts.result || opts.actor || opts.evidence || opts.notes || opts.reason || opts.reviewHash || opts.disposition || opts.supersedes || opts.allowInstallScript) {
1716
+ throw new Error("--behavior-replay accepts no human evidence or structural-attestation options");
1717
+ }
1718
+ output = service.g2BehaviorCandidateReplay(opts.behaviorReplay, opts.behaviorReviewHash, {
1719
+ since: opts.candidateSince,
1720
+ maxCommits: Number(opts.candidateCommits),
1721
+ limit: Number(opts.candidateLimit),
1722
+ decisionId: opts.behaviorDecision,
1723
+ });
1724
+ }
1725
+ else if (behaviorDepsRequested) {
1726
+ if (!opts.behaviorReviewHash)
1727
+ throw new Error("--behavior-deps requires --behavior-review-hash");
1728
+ if (opts.result || opts.actor || opts.evidence || opts.notes || opts.reason || opts.reviewHash || opts.disposition || opts.supersedes) {
1729
+ throw new Error("--behavior-deps accepts no human evidence or structural-attestation options");
1730
+ }
1731
+ output = service.g2BehaviorDependencySnapshots(opts.behaviorDeps, opts.behaviorReviewHash, {
1732
+ since: opts.candidateSince,
1733
+ maxCommits: Number(opts.candidateCommits),
1734
+ limit: Number(opts.candidateLimit),
1735
+ decisionId: opts.behaviorDecision,
1736
+ allowInstallScripts: opts.allowInstallScript ?? [],
1737
+ timeoutMs: Number(opts.dependencyTimeoutMs),
1738
+ });
1739
+ }
1740
+ else if (behaviorAttestRequested) {
1741
+ if (!opts.behaviorReviewHash)
1742
+ throw new Error("--behavior-attest requires --behavior-review-hash");
1743
+ if (opts.disposition !== "selected" && opts.disposition !== "rejected")
1744
+ throw new Error("--behavior-attest requires --disposition selected|rejected");
1745
+ if (!opts.actor)
1746
+ throw new Error("--behavior-attest requires --actor");
1747
+ if (!opts.reason)
1748
+ throw new Error("--behavior-attest requires --reason");
1749
+ if (opts.result || opts.evidence || opts.notes || opts.reviewHash || opts.allowInstallScript) {
1750
+ throw new Error("--behavior-attest accepts no rehearsal, structural-review, or dependency-install options");
1751
+ }
1752
+ const reviewOptions = {
1753
+ since: opts.candidateSince,
1754
+ maxCommits: Number(opts.candidateCommits),
1755
+ limit: Number(opts.candidateLimit),
1756
+ decisionId: opts.behaviorDecision,
1757
+ };
1758
+ const appended = service.attestG2BehaviorCandidate(opts.behaviorAttest, opts.behaviorReviewHash, opts.disposition, opts.actor, opts.reason, { ...reviewOptions, supersedes: opts.supersedes });
1759
+ output = { appended, review: service.g2BehaviorCandidateReview(reviewOptions) };
1760
+ }
1761
+ else if (behaviorMaterializeRequested) {
1762
+ if (opts.result || opts.actor || opts.evidence || opts.notes || opts.reason || opts.reviewHash || opts.disposition || opts.supersedes || opts.behaviorReviewHash || opts.allowInstallScript) {
1763
+ throw new Error("--behavior-materialize accepts no human evidence, review-hash, or dependency-install options");
1764
+ }
1765
+ output = service.g2BehaviorMaterializationAssessment({
1766
+ since: opts.candidateSince,
1767
+ maxCommits: Number(opts.candidateCommits),
1768
+ limit: Number(opts.candidateLimit),
1769
+ decisionId: opts.behaviorDecision,
1770
+ });
1771
+ }
1772
+ else if (behaviorPolicyMaterializeRequested) {
1773
+ if (opts.result || opts.actor || opts.evidence || opts.notes || opts.reason || opts.reviewHash || opts.disposition || opts.supersedes || opts.behaviorReviewHash) {
1774
+ throw new Error("--behavior-policy-materialize accepts no human evidence or review-hash options");
1775
+ }
1776
+ output = service.g2BehaviorPolicyMaterialize({
1777
+ since: opts.candidateSince,
1778
+ maxCommits: Number(opts.candidateCommits),
1779
+ limit: Number(opts.candidateLimit),
1780
+ decisionId: opts.behaviorDecision,
1781
+ allowInstallScripts: opts.allowInstallScript ?? [],
1782
+ dependencyTimeoutMs: Number(opts.dependencyTimeoutMs),
1783
+ });
1784
+ }
1785
+ else if (opts.result || opts.actor || opts.evidence || opts.notes || opts.reason || opts.reviewHash || opts.disposition || opts.supersedes || opts.behaviorReviewHash || opts.allowInstallScript) {
1786
+ throw new Error("human evidence options require --rehearse <runbook-id> or --attest <candidate-id>");
1787
+ }
1788
+ else {
1789
+ output = service.g2Readiness();
1790
+ }
1791
+ const readiness = service.g2Readiness();
1792
+ console.log(JSON.stringify(output, null, 2));
1793
+ if (opts.strict && readiness.recommendation !== "eligible_for_human_g2_signoff")
1794
+ process.exitCode = 1;
1795
+ }
1796
+ catch (e) {
1797
+ fail(e.message);
1798
+ }
1799
+ finally {
1800
+ store.close();
1801
+ }
1802
+ });
1803
+ constitutionCmd
1804
+ .command("g3")
1805
+ .description("Inspect exact private G3 advisory evidence; optionally append a preregistration, human plan, proof review, or executable adapter receipt. Never signs off G3.")
1806
+ .option("--experiment <file>", "append an immutable private EXP-01 or EXP-03 preregistration from JSON")
1807
+ .option("--plan <file>", "append a private human-selected G3 plan bound to exact G2 readiness and experiment records")
1808
+ .option("--review <file>", "append a measured human proof-card review for one plan-selected policy")
1809
+ .option("--conformance", "execute and append the exact current plan's supported three-client adapter fixture")
1810
+ .option("--timeout-ms <n>", "timeout for the executable adapter conformance fixture", "180000")
1811
+ .option("--strict", "exit nonzero while the packet is not eligible for explicit human G3 signoff")
1812
+ .action((opts) => {
1813
+ const { store, root } = storeFor();
1814
+ try {
1815
+ const actions = [!!opts.experiment, !!opts.plan, !!opts.review, !!opts.conformance].filter(Boolean).length;
1816
+ if (actions > 1)
1817
+ throw new Error("choose only one G3 preregistration, plan, proof review, or adapter-conformance action");
1818
+ const service = new ConstitutionService(store, root);
1819
+ let output;
1820
+ if (opts.experiment) {
1821
+ const appended = service.registerG3Experiment(JSON.parse(readFileSync(resolve(opts.experiment), "utf8")));
1822
+ output = { appended, readiness: service.g3Readiness() };
1823
+ }
1824
+ else if (opts.plan) {
1825
+ const appended = service.createG3Plan(JSON.parse(readFileSync(resolve(opts.plan), "utf8")));
1826
+ output = { appended, readiness: service.g3Readiness() };
1827
+ }
1828
+ else if (opts.review) {
1829
+ const input = JSON.parse(readFileSync(resolve(opts.review), "utf8"));
1830
+ const appended = service.recordG3ProofReview(input);
1831
+ output = { appended, readiness: service.g3Readiness() };
1832
+ }
1833
+ else if (opts.conformance) {
1834
+ const appended = service.g3AdapterConformance({ timeoutMs: Number(opts.timeoutMs) });
1835
+ output = { appended, readiness: service.g3Readiness() };
1836
+ }
1837
+ else {
1838
+ output = service.g3Readiness();
1839
+ }
1840
+ const readiness = service.g3Readiness();
1841
+ console.log(JSON.stringify(output, null, 2));
1842
+ if (opts.strict && readiness.recommendation !== "eligible_for_human_g3_signoff")
1843
+ process.exitCode = 1;
1844
+ }
1845
+ catch (e) {
1846
+ fail(e.message);
1847
+ }
1848
+ finally {
1849
+ store.close();
1850
+ }
1851
+ });
1852
+ // ---- experiment (fresh preregistration-bound execution) -----------------
1853
+ const experimentCmd = program
1854
+ .command("experiment")
1855
+ .description("Lock, execute, review, and report fresh private preregistered experiments. No result grants policy authority.");
1856
+ experimentCmd
1857
+ .command("validate")
1858
+ .description("Validate and content-address a draft case bank against the current preregistration without writing it.")
1859
+ .argument("<file>", "case bank JSON input")
1860
+ .action((file) => {
1861
+ const { store, root } = storeFor();
1862
+ try {
1863
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1864
+ console.log(JSON.stringify(new ConstitutionService(store, root).validateExperimentCaseBank(input), null, 2));
1865
+ }
1866
+ catch (e) {
1867
+ fail(e.message);
1868
+ }
1869
+ finally {
1870
+ store.close();
1871
+ }
1872
+ });
1873
+ experimentCmd
1874
+ .command("prepare")
1875
+ .description("Lock a fresh private case bank to the exact current preregistration before assignment.")
1876
+ .argument("<file>", "case bank JSON input")
1877
+ .action((file) => {
1878
+ const { store, root } = storeFor();
1879
+ try {
1880
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1881
+ console.log(JSON.stringify(new ConstitutionService(store, root).lockExperimentCaseBank(input), null, 2));
1882
+ }
1883
+ catch (e) {
1884
+ fail(e.message);
1885
+ }
1886
+ finally {
1887
+ store.close();
1888
+ }
1889
+ });
1890
+ experimentCmd
1891
+ .command("create")
1892
+ .description("Create one immutable balanced assignment manifest; one run is allowed per preregistration.")
1893
+ .argument("<case-bank-id>", "locked private case bank id")
1894
+ .requiredOption("--sample-per-arm <n>", "full preregistered target per arm; the minimum is only a checkpoint")
1895
+ .requiredOption("--actor <actor>", "explicit owner (human:, git:, or github:)")
1896
+ .requiredOption("--reason <text>", "why this exact run manifest is being created")
1897
+ .option("--provider <provider>", "EXP-01 subscription CLI: claude-cli | codex-cli")
1898
+ .option("--model <model>", "exact EXP-01 model version or alias")
1899
+ .option("--max-turns <n>", "maximum agent turns for every EXP-01 assignment", "40")
1900
+ .action((caseBankId, opts) => {
1901
+ const { store, root } = storeFor();
1902
+ try {
1903
+ const service = new ConstitutionService(store, root);
1904
+ const bank = service.experimentRepository.listCaseBanks().find((item) => item.id === caseBankId);
1905
+ if (!bank)
1906
+ throw new Error(`unknown private experiment case bank: ${caseBankId}`);
1907
+ if (bank.experiment === "EXP-01" && opts.provider !== "claude-cli" && opts.provider !== "codex-cli") {
1908
+ throw new Error("EXP-01 run creation requires explicit --provider claude-cli|codex-cli; Hunch never chooses which subscription to spend");
1909
+ }
1910
+ if (bank.experiment === "EXP-01" && !opts.model)
1911
+ throw new Error("EXP-01 run creation requires an exact --model stratum");
1912
+ if (bank.experiment === "EXP-03" && (opts.provider || opts.model))
1913
+ throw new Error("EXP-03 is a human-review experiment and accepts no model provider");
1914
+ const provider = opts.provider;
1915
+ const appended = service.createExperimentRun(caseBankId, {
1916
+ sample_per_arm: Number(opts.samplePerArm),
1917
+ ...(provider ? { provider, provider_version: subscriptionCliVersion(provider), model_version: opts.model, max_turns: Number(opts.maxTurns) } : {}),
1918
+ actor: opts.actor,
1919
+ reason: opts.reason,
1920
+ });
1921
+ console.log(JSON.stringify({ appended, report: service.experimentReport(appended.id) }, null, 2));
1922
+ }
1923
+ catch (e) {
1924
+ fail(e.message);
1925
+ }
1926
+ finally {
1927
+ store.close();
1928
+ }
860
1929
  });
861
- // ---- conform (Architectural Conformance: does the code still satisfy recorded intent) ----
862
- program
863
- .command("conform")
864
- .description("Architectural Conformance: prove the code still SATISFIES each recorded architectural invariant (deterministic, over the graph) — the semantic rules pattern-SAST can't express: layering, must-reach, dependency direction. Catches AI changes that pass a linter but break the architecture.")
865
- .option("--strict", "exit non-zero if any invariant is violated (use as a CI gate)")
866
- .option("--add <title>", "record an architectural invariant instead of checking, e.g. --add \"controllers never touch the DB directly\"")
867
- .option("--assert <kind>", "calls | not-calls | imports | not-imports | exists (with --add)")
868
- .option("--subject <sym>", "the symbol/file:name the invariant is about (with --add)")
869
- .option("--object <sym>", "the symbol it must reach (calls/imports) or must NOT reach (not-calls/not-imports) (with --add)")
870
- .option("--transitive", "evaluate reachability transitively, not just direct edges (with --add)")
871
- .option("--why <text>", "why it holds — the rationale, surfaced in the block receipt (with --add)")
872
- .option("--bug <id>", "the bug id this invariant prevents recurring — surfaced in the receipt (with --add)")
873
- .action((opts) => {
1930
+ experimentCmd
1931
+ .command("run")
1932
+ .description("Execute the next pending EXP-01 assignments with the manifest-selected subscription CLI.")
1933
+ .argument("<run-id>", "immutable experiment run id")
1934
+ .option("--limit <n>", "maximum assignments to execute in this invocation", "1")
1935
+ .option("--timeout-ms <n>", "per-assignment subscription CLI timeout", "1800000")
1936
+ .action((runId, opts) => {
874
1937
  const { store, root } = storeFor();
875
- if (opts.add) {
876
- const ASSERTS = ["calls", "not-calls", "imports", "not-imports", "exists"];
877
- if (!opts.assert || !ASSERTS.includes(opts.assert))
878
- return fail(`--assert must be one of: ${ASSERTS.join(", ")}`);
879
- if (!opts.subject)
880
- return fail("--subject is required with --add");
881
- if (opts.assert !== "exists" && !opts.object)
882
- return fail(`--object is required for --assert ${opts.assert}`);
883
- store.json.ensureDirs();
884
- const now = new Date().toISOString();
885
- const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
886
- const d = store.putCapture("decisions", {
887
- id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
888
- title: opts.add,
889
- status: "accepted",
890
- context: opts.why ?? "",
891
- decision: `Architectural invariant: ${opts.subject} ${opts.assert}${opts.object ? ` ${opts.object}` : ""}.`,
892
- caused_by_bug: opts.bug ?? null,
893
- conformance: [{ assert: opts.assert, subject: opts.subject, object: opts.assert === "exists" ? undefined : opts.object, transitive: !!opts.transitive }],
894
- provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
895
- date: now,
896
- valid_from: now,
1938
+ try {
1939
+ console.log(JSON.stringify(new ConstitutionService(store, root).executeExperimentRun(runId, {
1940
+ limit: Number(opts.limit),
1941
+ timeoutMs: Number(opts.timeoutMs),
1942
+ }), null, 2));
1943
+ }
1944
+ catch (e) {
1945
+ fail(e.message);
1946
+ }
1947
+ finally {
1948
+ store.close();
1949
+ }
1950
+ });
1951
+ experimentCmd
1952
+ .command("next")
1953
+ .description("Start or resume the next randomized EXP-03 human review and return only its assigned treatment.")
1954
+ .argument("<run-id>", "immutable experiment run id")
1955
+ .requiredOption("--reviewer <actor>", "explicit human reviewer")
1956
+ .action((runId, opts) => {
1957
+ const { store, root } = storeFor();
1958
+ try {
1959
+ console.log(JSON.stringify(new ConstitutionService(store, root).nextExperimentReview(runId, opts.reviewer), null, 2));
1960
+ }
1961
+ catch (e) {
1962
+ fail(e.message);
1963
+ }
1964
+ finally {
1965
+ store.close();
1966
+ }
1967
+ });
1968
+ experimentCmd
1969
+ .command("submit")
1970
+ .description("Complete a machine-timed EXP-03 review; duration is derived from the append-only start record.")
1971
+ .argument("<run-id>", "immutable experiment run id")
1972
+ .argument("<assignment-id>", "assignment returned by experiment next")
1973
+ .argument("<file>", "review outcome JSON without a duration field")
1974
+ .action((runId, assignmentId, file) => {
1975
+ const { store, root } = storeFor();
1976
+ try {
1977
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
1978
+ const service = new ConstitutionService(store, root);
1979
+ const appended = service.submitExperimentReview(runId, assignmentId, input);
1980
+ console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
1981
+ }
1982
+ catch (e) {
1983
+ fail(e.message);
1984
+ }
1985
+ finally {
1986
+ store.close();
1987
+ }
1988
+ });
1989
+ experimentCmd
1990
+ .command("respond")
1991
+ .description("Complete a revision-2 EXP-03 review with the standardized plain-language response: one choice, the rule you keep (accept/edit), one sentence of reasoning. Duration derives from the append-only start record; metrics are mapped deterministically — never hand-crafted.")
1992
+ .argument("<run-id>", "immutable experiment run id")
1993
+ .argument("<assignment-id>", "assignment returned by experiment next")
1994
+ .requiredOption("--reviewer <actor>", "explicit human reviewer (human:name)")
1995
+ .requiredOption("--choice <choice>", "accept | edit | reject | cannot_decide")
1996
+ .requiredOption("--reason <text>", "one plain-language sentence")
1997
+ .option("--rule <text>", "the rule exactly as it should be recorded (required for accept/edit)")
1998
+ .option("--rule-file <file>", "read the rule text from a file instead of --rule")
1999
+ .option("--inspected", "arm C only: you looked at the supporting checks before answering")
2000
+ .option("--confirmed-private-leak", "record an independently confirmed private leak incident")
2001
+ .option("--data-loss", "record a data loss/corruption incident")
2002
+ .option("--unsafe-evaluator", "record unsafe evaluator behavior")
2003
+ .action((runId, assignmentId, opts) => {
2004
+ const { store, root } = storeFor();
2005
+ try {
2006
+ if (opts.rule && opts.ruleFile)
2007
+ throw new Error("pass --rule or --rule-file, not both");
2008
+ const rule = opts.ruleFile ? readFileSync(resolve(opts.ruleFile), "utf8") : opts.rule ?? null;
2009
+ const service = new ConstitutionService(store, root);
2010
+ const appended = service.respondExperimentReview(runId, assignmentId, {
2011
+ reviewer: opts.reviewer,
2012
+ choice: opts.choice,
2013
+ rule_text: rule,
2014
+ reason: opts.reason,
2015
+ inspected_supporting_checks: !!opts.inspected,
2016
+ confirmed_private_leak: !!opts.confirmedPrivateLeak,
2017
+ data_loss_or_corruption: !!opts.dataLoss,
2018
+ unsafe_evaluator_behavior: !!opts.unsafeEvaluator,
897
2019
  });
898
- store.reindex();
899
- refreshExistingGrounding(root, store); // the invariant reaches every assistant's grounding
900
- console.log(`✓ recorded architectural invariant ${d.id}: "${opts.add}"`);
901
- console.log(` ${opts.subject} ${arrow} ${opts.object ?? ""}${opts.transitive ? " (transitive)" : ""} [${opts.assert}]`);
902
- console.log(` enforce on every change: hunch conform --strict (wire into CI alongside hunch ci)`);
2020
+ console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2021
+ }
2022
+ catch (e) {
2023
+ fail(e.message);
2024
+ }
2025
+ finally {
903
2026
  store.close();
904
- return;
905
2027
  }
906
- store.reindex();
907
- const results = checkConformance(store);
908
- if (!results.length) {
909
- console.log("No architectural invariants recorded yet.");
910
- console.log(dim(' Record one: hunch conform --add "controllers never touch the DB directly" --assert not-calls --subject OrdersController --object dbQuery'));
2028
+ });
2029
+ experimentCmd
2030
+ .command("followup")
2031
+ .description("Append the preregistered seven-day EXP-03 reversal measurement.")
2032
+ .argument("<run-id>", "immutable experiment run id")
2033
+ .argument("<assignment-id>", "completed review assignment")
2034
+ .argument("<file>", "follow-up JSON")
2035
+ .action((runId, assignmentId, file) => {
2036
+ const { store, root } = storeFor();
2037
+ try {
2038
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
2039
+ const service = new ConstitutionService(store, root);
2040
+ const appended = service.recordExperimentFollowup(runId, assignmentId, input);
2041
+ console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2042
+ }
2043
+ catch (e) {
2044
+ fail(e.message);
2045
+ }
2046
+ finally {
911
2047
  store.close();
912
- return;
913
2048
  }
914
- const violations = results.filter((r) => !r.satisfied);
915
- console.log(`Architectural conformance: ${results.length - violations.length}/${results.length} invariants satisfied\n`);
916
- for (const r of results) {
917
- console.log(` ${r.satisfied ? "✅" : "⛔"} ${r.decision} "${r.title}"`);
918
- console.log(` ${r.assert} ${r.subject}${r.object ? ` → ${r.object}` : ""}: ${r.detail}`);
919
- if (!r.satisfied) {
920
- // The receipt — WHY this invariant exists, which pattern-SAST can't tell you.
921
- const dec = store.json.get("decisions", r.decision);
922
- if (dec?.context)
923
- console.log(` ↳ why: ${dec.context}`);
924
- if (dec?.caused_by_bug)
925
- console.log(` ↳ prevents recurrence of: ${dec.caused_by_bug}`);
926
- }
2049
+ });
2050
+ experimentCmd
2051
+ .command("stop")
2052
+ .description("Irreversibly stop a run for a preregistered safety/privacy or provider-wide inability condition.")
2053
+ .argument("<run-id>", "immutable experiment run id")
2054
+ .argument("<file>", "stop receipt JSON with category, actor, reason, and evidence_hashes")
2055
+ .action((runId, file) => {
2056
+ const { store, root } = storeFor();
2057
+ try {
2058
+ const input = JSON.parse(readFileSync(resolve(file), "utf8"));
2059
+ const service = new ConstitutionService(store, root);
2060
+ const appended = service.stopExperiment(runId, input);
2061
+ console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
927
2062
  }
928
- if (violations.length) {
929
- console.log(`\n⛔ ${violations.length} architectural invariant(s) the code no longer satisfies — an AI change drifted from the recorded architecture.`);
930
- if (opts.strict)
931
- process.exitCode = 1;
2063
+ catch (e) {
2064
+ fail(e.message);
932
2065
  }
933
- else {
934
- console.log(`\n✅ the code satisfies every recorded architectural invariant.`);
2066
+ finally {
2067
+ store.close();
935
2068
  }
936
- store.close();
937
2069
  });
2070
+ for (const command of ["status", "report"]) {
2071
+ experimentCmd
2072
+ .command(command)
2073
+ .description(command === "status" ? "Show raw denominators and remaining assignments." : "Emit the deterministic preregistration-bound analysis receipt.")
2074
+ .argument("<run-id>", "immutable experiment run id")
2075
+ .action((runId) => {
2076
+ const { store, root } = storeFor();
2077
+ try {
2078
+ console.log(JSON.stringify(new ConstitutionService(store, root).experimentReport(runId), null, 2));
2079
+ }
2080
+ catch (e) {
2081
+ fail(e.message);
2082
+ }
2083
+ finally {
2084
+ store.close();
2085
+ }
2086
+ });
2087
+ }
938
2088
  // ---- compare (rank N candidate solutions by architectural fit) ------------
939
2089
  program
940
2090
  .command("compare")
@@ -1300,6 +2450,10 @@ program
1300
2450
  store.close();
1301
2451
  return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
1302
2452
  }
2453
+ if (opts.commit && !revExists(opts.commit, root)) {
2454
+ store.close();
2455
+ return fail(`--commit ref "${opts.commit}" does not resolve.`);
2456
+ }
1303
2457
  store.reindex(); // blast radius walks the edge graph — make the index current
1304
2458
  const files = opts.commit ? commitFiles(opts.commit, root)
1305
2459
  : opts.base ? rangeFiles(opts.base, root)
@@ -1338,7 +2492,9 @@ program
1338
2492
  // gate cases (--staged / --base / --commit HEAD) all have the working tree AT the change.
1339
2493
  // Surfaced always; gates the commit/PR under --strict, with the receipt of the why.
1340
2494
  const hasConformance = store.recs("decisions").some((d) => (d.conformance?.length ?? 0) > 0);
1341
- if (hasConformance) {
2495
+ const constitution = new ConstitutionService(store, root);
2496
+ const hasActivePolicies = constitution.list({ publicOnly: !!opts.publicOnly }).some((p) => p.state === "active_advisory" || p.state === "active_blocking");
2497
+ if (hasConformance || hasActivePolicies) {
1342
2498
  indexRepo(store, root, { churn: false }); // refresh the symbol/dep graph from the working tree
1343
2499
  store.reindex();
1344
2500
  }
@@ -1366,7 +2522,34 @@ program
1366
2522
  console.log(` The semantic invariant a linter can't see — run \`hunch conform\` for the full picture.`);
1367
2523
  }
1368
2524
  }
1369
- if (reportFailsStrict(report) || (!!opts.strict && confViolations.length > 0))
2525
+ // CONSTITUTION POLICY: the same neutral receipt used by `hunch policy evaluate`
2526
+ // and hunch_policy_evaluate. Only an active_blocking policy with explicit human
2527
+ // authority can block. An evaluator error also fails strict CI; unknown remains
2528
+ // visible/advisory and can never masquerade as satisfied.
2529
+ const behavior = opts.working ? { workspace: "working" }
2530
+ : opts.commit ? { commit: revParse(opts.commit, root) }
2531
+ : opts.base ? undefined
2532
+ : { workspace: "staged" };
2533
+ const policyResults = hasActivePolicies
2534
+ ? constitution.evaluate({ activeOnly: true, publicOnly: !!opts.publicOnly, behavior })
2535
+ : [];
2536
+ if (policyResults.length) {
2537
+ if (markdown) {
2538
+ console.log(`\n### 📜 Hunch Constitution — ${policyResults.length} policy receipt(s)\n`);
2539
+ for (const r of policyResults) {
2540
+ console.log(`- ${r.blocks ? "⛔" : r.evaluation.result === "satisfied" ? "✅" : r.evaluation.result === "error" ? "‼" : "⚠"} \`${r.policy.id}\` **${r.evaluation.result}** — ${r.evaluation.explanation}`);
2541
+ console.log(` - receipt: \`${r.evaluation.deterministic_hash}\`${r.blocks ? " **(authorized block)**" : ""}`);
2542
+ if (r.gate_error)
2543
+ console.log(` - ‼ gate error: ${r.gate_error}`);
2544
+ }
2545
+ }
2546
+ else {
2547
+ console.log("");
2548
+ renderPolicyEvaluations(policyResults).forEach((line) => console.log(line));
2549
+ }
2550
+ }
2551
+ const constitutionFails = policyResults.some((r) => r.blocks || r.strict_error);
2552
+ if (reportFailsStrict(report) || (!!opts.strict && (confViolations.length > 0 || constitutionFails)))
1370
2553
  process.exitCode = 1;
1371
2554
  store.close();
1372
2555
  });
@@ -1543,9 +2726,14 @@ program
1543
2726
  .command("firmness")
1544
2727
  .description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
1545
2728
  .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
1546
- .action((level) => {
2729
+ .option("--json", "print the current level + choices as JSON (for the VS Code switch)")
2730
+ .action((level, opts) => {
1547
2731
  const paths = hunchPaths(findRoot());
1548
2732
  if (!level) {
2733
+ if (opts.json) {
2734
+ console.log(JSON.stringify({ firmness: readConfig(paths).firmness, levels: FIRMNESS_LEVELS }));
2735
+ return;
2736
+ }
1549
2737
  console.log(`firmness: ${readConfig(paths).firmness}`);
1550
2738
  console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
1551
2739
  return;
@@ -1609,7 +2797,7 @@ program
1609
2797
  const blocking = store.recs("constraints").filter((c) => c.status === "active" && c.severity === "blocking" && vouchedSrc(c.provenance?.source));
1610
2798
  const precise = blocking.filter((c) => !!effectiveForbids(c));
1611
2799
  const scopeOnly = blocking.filter((c) => !effectiveForbids(c));
1612
- const drafts = store.json.loadAll("decisions").filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
2800
+ const drafts = store.json.loadAll("decisions").filter(isReviewDraft);
1613
2801
  const { ready, scrutiny } = partitionReview(drafts, READY_MIN_GROUNDED);
1614
2802
  const stale = store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint");
1615
2803
  const fnote = {
@@ -1638,6 +2826,54 @@ program
1638
2826
  store.close();
1639
2827
  });
1640
2828
  // ---- hook (multi-agent lifecycle hook handler) ----------------------------
2829
+ function parseStatsSince(value) {
2830
+ const match = /^(\d+)\s*([mhdw])$/.exec(value.trim());
2831
+ if (!match)
2832
+ return { ms: 7 * 864e5, label: "7d" };
2833
+ const units = { m: 6e4, h: 36e5, d: 864e5, w: 7 * 864e5 };
2834
+ const unit = match[2];
2835
+ return { ms: Number(match[1]) * units[unit], label: `${match[1]}${unit}` };
2836
+ }
2837
+ program
2838
+ .command("stats")
2839
+ .description("Compounding-value receipt: accumulated memory, caught violations, and payback")
2840
+ .option("--json", "emit the hunch.stats/1 machine contract")
2841
+ .option("--since <duration>", "recent return window (7d, 24h, 2w)", "7d")
2842
+ .option("--private", "include the local private/shared overlay")
2843
+ .action((opts) => {
2844
+ const { store, root } = storeFor();
2845
+ try {
2846
+ if (opts.private && !store.hasPrivate)
2847
+ return fail("--private needs a configured private/shared overlay");
2848
+ const load = (kind) => opts.private ? store.recs(kind) : store.json.loadAll(kind);
2849
+ const events = readEvents(hunchPaths(root));
2850
+ if (opts.private && store.privateDir)
2851
+ events.push(...readEvents(hunchPathsForDir(store.privateDir)));
2852
+ const since = parseStatsSince(opts.since);
2853
+ const now = Date.now();
2854
+ const constraints = load("constraints");
2855
+ const staleConstraints = constraints.filter((constraint) => {
2856
+ const verified = constraint.provenance.last_verified;
2857
+ if (!verified || Number.isNaN(Date.parse(verified)))
2858
+ return false;
2859
+ return constraint.scope.some((file) => {
2860
+ const changed = lastChangeDate(file, root);
2861
+ return !!changed && Date.parse(changed) > Date.parse(verified);
2862
+ });
2863
+ }).length;
2864
+ const stats = computeStats({
2865
+ decisions: load("decisions"), constraints, bugs: load("bugs"),
2866
+ componentIds: load("components").map((component) => component.id),
2867
+ runbooksCount: load("runbooks").length, events,
2868
+ staleConstraints,
2869
+ now, windowStart: now - since.ms, windowLabel: since.label,
2870
+ });
2871
+ console.log(opts.json ? JSON.stringify(stats, null, 2) : formatStats(stats));
2872
+ }
2873
+ finally {
2874
+ store.close();
2875
+ }
2876
+ });
1641
2877
  program
1642
2878
  .command("hook")
1643
2879
  .description("Agent-agnostic hook handler: normalizes Claude, VS Code, Cursor, Windsurf, and Antigravity events into Hunch context and strict policy checks. Reads hook JSON on stdin.")
@@ -1734,7 +2970,19 @@ program
1734
2970
  L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
1735
2971
  }
1736
2972
  if (pendingReview > 0)
1737
- L.push(`${pendingReview} auto-draft(s) awaiting \`hunch review\`.`);
2973
+ L.push(`${pendingReview} legacy un-vouched draft(s) adopt as advisory memory with \`hunch adopt-drafts\` (new captures auto-trust).`);
2974
+ const escalations = pendingEscalations(decisions);
2975
+ try {
2976
+ // Constitution human moments ride the same line; a broken policy store
2977
+ // must never take session-start orientation down (fail open). Public
2978
+ // store only — session transcripts travel further than a terminal.
2979
+ const { ConstitutionService: CS } = await import("../constitution/service.js");
2980
+ escalations.push(...policyEscalations(new CS(s, paths.root).list({ publicOnly: true }).map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
2981
+ }
2982
+ catch { /* constitution unavailable */ }
2983
+ if (escalations.length) {
2984
+ L.push(`⚖ ${escalations.length} decision(s) need YOUR call — ASK the user inline (don't queue): ${escalations.map((e) => e.question).join(" · ")}`);
2985
+ }
1738
2986
  L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
1739
2987
  // The operating loop rides session start — guaranteed delivery, once
1740
2988
  // (the zod bench showed ambient skills are read in ~0% of sessions).
@@ -1778,6 +3026,7 @@ program
1778
3026
  const proposedLines = proposedEditLines(evt.tool_input);
1779
3027
  const deny = blockingInScope(store, target, proposedLines);
1780
3028
  if (deny) {
3029
+ appendEvent(paths, { at: new Date().toISOString(), file: target, ...deny.event });
1781
3030
  emitDeny(provider, deny.reason);
1782
3031
  return;
1783
3032
  }
@@ -1786,6 +3035,7 @@ program
1786
3035
  // only human-confirmed tripwires deny.
1787
3036
  const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
1788
3037
  if (vetoDeny) {
3038
+ appendEvent(paths, { at: new Date().toISOString(), file: target, ...vetoDeny.event });
1789
3039
  emitDeny(provider, vetoDeny.reason);
1790
3040
  return;
1791
3041
  }
@@ -1873,6 +3123,44 @@ function printReviewItem(it) {
1873
3123
  const synthLine = synth.raw ? `\n ↳ ${synth.raw}` : "";
1874
3124
  console.log(` ${d.id} [${d.status}, ${d.provenance.source} ${d.provenance.confidence}]${pruneNote}\n ${d.title}\n ${d.decision.slice(0, 120)}${synthLine}`);
1875
3125
  }
3126
+ program
3127
+ .command("adopt-drafts")
3128
+ .description("Auto-trust migration: adopt every legacy un-vouched proposed draft as trusted ADVISORY memory (status → accepted, source unchanged so it STILL never blocks). Clears the old review backlog in one shot — nothing is deleted, enforcement stays human-gated, and blocking authority is still granted inline. Idempotent.")
3129
+ .option("--dry-run", "list what would be adopted; change nothing")
3130
+ .option("--private", "include local private/shared-overlay drafts")
3131
+ .action((opts) => {
3132
+ const { store, root } = storeFor();
3133
+ try {
3134
+ if (opts.private && !store.hasPrivate)
3135
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
3136
+ const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
3137
+ const drafts = all.filter(isReviewDraft);
3138
+ if (!drafts.length) {
3139
+ console.log("✓ No un-vouched drafts — the graph is already fully auto-trusted.");
3140
+ return;
3141
+ }
3142
+ if (opts.dryRun) {
3143
+ console.log(`Would adopt ${drafts.length} draft(s) as trusted advisory memory (still never block):`);
3144
+ for (const d of drafts)
3145
+ console.log(` ${d.id} [${d.provenance.source} ${d.provenance.confidence}] ${d.title}`);
3146
+ console.log(`\n${dim("Dry run — nothing changed. Re-run without --dry-run to adopt. Roadmap intent? Re-declare it with hunch_record_decision(status:proposed).")}`);
3147
+ return;
3148
+ }
3149
+ // Flip status proposed → accepted (in-force advisory). Source/confidence are
3150
+ // UNCHANGED: it stays llm_draft-sourced, so the veto/strict gates (which key on
3151
+ // human_confirmed, not status) keep treating it as advisory — never blocking.
3152
+ let adopted = 0;
3153
+ for (const d of drafts) {
3154
+ store.putWhereItLives("decisions", { ...d, status: "accepted", provenance: { ...d.provenance, last_verified: new Date().toISOString() } });
3155
+ adopted++;
3156
+ }
3157
+ store.reindex();
3158
+ console.log(`✓ Adopted ${adopted} draft(s) as trusted advisory memory. The review backlog is clear; none of them can block an edit until a human grants blocking authority inline.`);
3159
+ }
3160
+ finally {
3161
+ store.close();
3162
+ }
3163
+ });
1876
3164
  program
1877
3165
  .command("review")
1878
3166
  .description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
@@ -1907,9 +3195,18 @@ program
1907
3195
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
1908
3196
  }
1909
3197
  else if (opts.reject) {
1910
- const ok2 = opts.private ? store.deleteWhereItLives("decisions", opts.reject) : store.json.delete("decisions", opts.reject);
3198
+ const d = opts.private ? store.getRec("decisions", opts.reject) : store.json.get("decisions", opts.reject);
3199
+ if (!d) {
3200
+ store.close();
3201
+ return fail(`decision ${opts.reject} not found`);
3202
+ }
3203
+ if (d.status !== "proposed") {
3204
+ store.close();
3205
+ return fail(`refusing to reject ${d.status} decision ${d.id}; review --reject only removes proposed drafts`);
3206
+ }
3207
+ const ok2 = opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id);
1911
3208
  store.reindex();
1912
- console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
3209
+ console.log(ok2 ? `✓ rejected and removed ${d.id}` : `decision ${d.id} not found`);
1913
3210
  }
1914
3211
  else if (opts.rejectDuplicates) {
1915
3212
  // Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
@@ -1957,10 +3254,10 @@ program
1957
3254
  }
1958
3255
  }
1959
3256
  else {
1960
- const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
3257
+ const drafts = decisions().filter(isReviewDraft);
1961
3258
  const { ready, scrutiny } = partitionReview(drafts, minGrounded);
1962
3259
  if (!ready.length && !scrutiny.length) {
1963
- console.log("✓ No low-confidence drafts to review.");
3260
+ console.log("✓ No drafts awaiting review — captured memory auto-trusts (advisory) the moment it lands.");
1964
3261
  }
1965
3262
  else {
1966
3263
  if (ready.length) {
@@ -1996,8 +3293,8 @@ function printAutoEntry(e) {
1996
3293
  }
1997
3294
  program
1998
3295
  .command("auto-review")
1999
- .description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and delete duplicates/irrelevant. Dry-run unless --apply.")
2000
- .option("--apply", "execute the plan (accept/delete). Without it, print the plan and change nothing.")
3296
+ .description("Harness-driven draft triage: delegate relevance to the coding-assistant CLI, then dedup, auto-confirm the verified+relevant, and delete duplicates/irrelevant. Dry-run unless --apply; apply refuses an incomplete requested harness batch.")
3297
+ .option("--apply", "execute the plan (accept/delete) only after a complete requested harness batch. Without it, print the plan and change nothing.")
2001
3298
  .option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
2002
3299
  .option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
2003
3300
  .option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
@@ -2011,33 +3308,42 @@ program
2011
3308
  const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
2012
3309
  const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
2013
3310
  // Same draft set `hunch review` / `hunch status` triage.
2014
- const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
3311
+ const drafts = all.filter(isReviewDraft);
2015
3312
  if (!drafts.length) {
2016
3313
  console.log("✓ No drafts to auto-review.");
2017
3314
  return;
2018
3315
  }
2019
- // Delegate relevance to the harness (subscription CLI) — feature-detected,
2020
- // and any per-draft failure degrades to "not judged" (kept for a human).
3316
+ // Delegate relevance to the harness (subscription CLI) — feature-detected.
3317
+ // A dry-run may remain partial (missing verdicts are kept), but --apply is
3318
+ // all-or-nothing when judgment was requested: a provider outage must never
3319
+ // turn an incomplete batch into an apparently safe mutation plan.
2021
3320
  const verdicts = new Map();
2022
- if (opts.llm !== false && !opts.private) {
3321
+ const judgmentRequested = opts.llm !== false && !opts.private;
3322
+ const judgmentFailures = [];
3323
+ if (judgmentRequested) {
2023
3324
  const provider = await selectProvider({ root });
2024
3325
  if (provider.judgeDraft) {
2025
- // The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
3326
+ // Deletion anchors are only finalized, live, human-confirmed records.
2026
3327
  const existing = all
2027
- .filter((d) => d.provenance.source.includes("human_confirmed") && d.status !== "superseded" && d.status !== "rejected")
3328
+ .filter(isAcceptedDuplicateAnchor)
2028
3329
  .map((d) => ({ id: d.id, title: d.title, decision: d.decision }));
2029
3330
  console.log(`Judging ${drafts.length} draft(s) via ${provider.name} (subscription)…`);
2030
3331
  for (const d of drafts) {
2031
3332
  try {
2032
3333
  verdicts.set(d.id, await provider.judgeDraft(d, existing.filter((e) => e.id !== d.id)));
2033
3334
  }
2034
- catch {
2035
- /* transient / unparseable leave unjudged, planner keeps it for a human */
3335
+ catch (e) {
3336
+ // Preserve the safe keep-for-human plan while making the missing
3337
+ // evidence observable. The apply gate below binds to this exact
3338
+ // draft set, so partial provider success cannot mutate the store.
3339
+ const error = (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3340
+ judgmentFailures.push({ id: d.id, error: error.slice(0, 240) || "unknown provider failure" });
2036
3341
  }
2037
3342
  }
2038
3343
  }
2039
3344
  else {
2040
3345
  console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
3346
+ judgmentFailures.push(...drafts.map((d) => ({ id: d.id, error: "no subscription relevance judge available" })));
2041
3347
  }
2042
3348
  }
2043
3349
  else if (opts.private && opts.llm !== false) {
@@ -2045,11 +3351,28 @@ program
2045
3351
  }
2046
3352
  const plan = planAutoReview(drafts, all, verdicts, { minGrounded, minRejectConfidence });
2047
3353
  printAutoReviewPlan(plan);
3354
+ if (judgmentRequested) {
3355
+ const coverage = `${verdicts.size}/${drafts.length} judged`;
3356
+ if (judgmentFailures.length) {
3357
+ console.error(`\n⚠ Incomplete harness batch: ${coverage}; ${judgmentFailures.length} failure(s).`);
3358
+ for (const failure of judgmentFailures.slice(0, 5))
3359
+ console.error(` ${failure.id}: ${failure.error}`);
3360
+ if (judgmentFailures.length > 5)
3361
+ console.error(` … ${judgmentFailures.length - 5} more failure(s)`);
3362
+ }
3363
+ else {
3364
+ console.log(`\n✓ Harness batch complete: ${coverage}.`);
3365
+ }
3366
+ }
2048
3367
  if (!opts.apply) {
2049
3368
  const n = planMutations(plan);
2050
3369
  console.log(`\n${dim(`Dry run — nothing changed. Re-run with --apply to ${n ? `apply ${n} change(s)` : "confirm (no changes)"}.`)}`);
2051
3370
  return;
2052
3371
  }
3372
+ if (judgmentRequested && judgmentFailures.length) {
3373
+ fail(`incomplete harness batch (${verdicts.size}/${drafts.length} judged); no changes applied. Re-run when the provider is healthy, or explicitly choose deterministic-only triage with --no-llm --apply.`);
3374
+ return;
3375
+ }
2053
3376
  // Apply: accept the verified+relevant, delete duplicates + irrelevant.
2054
3377
  let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
2055
3378
  for (const e of plan.accept) {
@@ -2154,6 +3477,196 @@ program
2154
3477
  store.close();
2155
3478
  }
2156
3479
  });
3480
+ // ---- escalations (the inline "ask the human" surface) ---------------------
3481
+ program
3482
+ .command("escalations")
3483
+ .description("The decisions a human must make NOW — surfaced to be asked INLINE (in the prompt), never a background queue. Captured memory auto-trusts on landing; this lists only what the graph genuinely can't resolve itself: topic conflicts, Constitution candidates awaiting review, and proposed policies whose activation is a human call. Normally empty. Exits non-zero when any are open, so an assistant/CI knows to raise them.")
3484
+ .option("--json", "emit the escalation entries as JSON (the VS Code panel's data source)")
3485
+ .action(async (opts) => {
3486
+ const { store, root } = storeFor();
3487
+ try {
3488
+ const items = pendingEscalations(store.recs("decisions"));
3489
+ // Constitution moments ride the same inline surface (§59.5.3) — never a queue.
3490
+ // Fail open: a broken policy store must not take the memory escalations down.
3491
+ try {
3492
+ const { ConstitutionService: CS } = await import("../constitution/service.js");
3493
+ items.push(...policyEscalations(new CS(store, root).list().map((p) => ({ ...p, last_action: p.audit.at(-1)?.action ?? null }))));
3494
+ }
3495
+ catch { /* constitution unavailable — memory escalations still surface */ }
3496
+ if (opts.json) {
3497
+ console.log(JSON.stringify(items));
3498
+ if (items.length)
3499
+ process.exitCode = 1;
3500
+ return;
3501
+ }
3502
+ if (!items.length) {
3503
+ console.log("✓ Nothing needs your decision — memory is auto-trusted and self-consistent.");
3504
+ return;
3505
+ }
3506
+ console.log(`${items.length} decision(s) need your call (ask inline; nothing is queued):\n`);
3507
+ for (const e of items) {
3508
+ console.log(` ⚖ ${e.question}`);
3509
+ console.log(` ${dim(e.detail)}`);
3510
+ console.log(` ${dim("→ " + e.resolution)}\n`);
3511
+ }
3512
+ process.exitCode = 1;
3513
+ }
3514
+ finally {
3515
+ store.close();
3516
+ }
3517
+ });
3518
+ // ---- log / revert-move (the memory-move timeline — VS Code Source Control) -
3519
+ program
3520
+ .command("log")
3521
+ .description("The memory-move timeline: every commit that changed .hunch/ (capture/adopt/supersede/prune), newest first. --json powers the VS Code Hunch Source Control view.")
3522
+ .option("--json", "emit the moves as JSON for tooling")
3523
+ .option("-n, --limit <n>", "max moves to show", "100")
3524
+ .option("--diff <sha>", "print one move's .hunch/ diff (the click-through), instead of the list")
3525
+ .action((opts) => {
3526
+ const root = findRoot();
3527
+ if (!isGitRepo(root))
3528
+ return fail("not a git repo — the memory timeline needs git history.");
3529
+ if (opts.diff) {
3530
+ process.stdout.write(memoryMoveDiff(opts.diff, root));
3531
+ return;
3532
+ }
3533
+ const limit = Number.isFinite(Number(opts.limit)) ? Number(opts.limit) : 100;
3534
+ const moves = parseMemoryLog(gitMemoryLog(root, limit));
3535
+ if (opts.json) {
3536
+ console.log(JSON.stringify(moves));
3537
+ return;
3538
+ }
3539
+ if (!moves.length) {
3540
+ console.log("No memory moves yet — nothing has changed .hunch/.");
3541
+ return;
3542
+ }
3543
+ const icon = { capture: "✚", adopt: "✓", supersede: "↻", prune: "✗", repair: "🔧", edit: "•" };
3544
+ for (const m of moves) {
3545
+ const ids = [...m.decisionIds, ...m.otherIds].slice(0, 4).join(",");
3546
+ console.log(`${m.date.slice(0, 10)} ${icon[m.kind]} ${m.kind.padEnd(9)} ${m.shortSha} ${m.subject.slice(0, 60)}${ids ? " " + dim(ids) : ""}`);
3547
+ }
3548
+ });
3549
+ /** Self-repair (Phase 5): heal exact-path memory bindings after a commit's renames.
3550
+ * Returns the plan (null when the commit renamed nothing that memory binds).
3551
+ * Apply mode rewrites the records in their homes, reindexes, and auto-commits each
3552
+ * touched home as a `repair` move on the timeline — background, revertable. */
3553
+ function runRepair(store, root, sha, apply) {
3554
+ const renames = renamesOf(commitChanges(sha, root));
3555
+ if (!renames.length)
3556
+ return null;
3557
+ const plan = planRepair(renames, store.recs("decisions"), store.recs("constraints"));
3558
+ // Policy bindings heal under the same zero-guessing contract; a broken policy
3559
+ // store must never take graph-record repair down (fail open).
3560
+ let service = null;
3561
+ let policyRewrites = [];
3562
+ try {
3563
+ service = new ConstitutionService(store, root);
3564
+ policyRewrites = planPolicyRepair(renames, service.list());
3565
+ }
3566
+ catch {
3567
+ service = null;
3568
+ }
3569
+ if (!plan.rewrites.length && !policyRewrites.length)
3570
+ return null;
3571
+ if (!apply)
3572
+ return { plan, policyRewrites, applied: false };
3573
+ let privateTouched = false, publicTouched = false;
3574
+ for (const d of store.recs("decisions")) {
3575
+ const healed = repairDecision(d, plan);
3576
+ if (healed === d)
3577
+ continue;
3578
+ store.putWhereItLives("decisions", healed);
3579
+ if (store.getPrivateRec("decisions", d.id))
3580
+ privateTouched = true;
3581
+ else
3582
+ publicTouched = true;
3583
+ }
3584
+ for (const c of store.recs("constraints")) {
3585
+ const healed = repairConstraint(c, plan);
3586
+ if (healed === c)
3587
+ continue;
3588
+ store.putWhereItLives("constraints", healed);
3589
+ if (store.getPrivateRec("constraints", c.id))
3590
+ privateTouched = true;
3591
+ else
3592
+ publicTouched = true;
3593
+ }
3594
+ if (service && policyRewrites.length) {
3595
+ const at = new Date().toISOString();
3596
+ for (const p of service.list()) {
3597
+ const healed = repairPolicySpec(p, policyRewrites, at);
3598
+ if (healed === p)
3599
+ continue;
3600
+ service.repository.putPolicy(healed);
3601
+ if (healed.data_class === "public")
3602
+ publicTouched = true;
3603
+ else
3604
+ privateTouched = true;
3605
+ }
3606
+ }
3607
+ store.reindex();
3608
+ if (store.autoCommit) {
3609
+ const total = plan.rewrites.length + policyRewrites.length;
3610
+ const message = `hunch: repair ${total} binding(s) after rename (${sha.slice(0, 7)})`;
3611
+ if (publicTouched)
3612
+ commitAndPushHunch(hunchPaths(root).hunch, message, { push: false });
3613
+ if (privateTouched && store.privateDir)
3614
+ commitAndPushHunch(store.privateDir, message, { push: true });
3615
+ }
3616
+ return { plan, policyRewrites, applied: true };
3617
+ }
3618
+ program
3619
+ .command("repair")
3620
+ .description("Self-repair: heal memory bindings (decision files, tripwire/constraint scopes) after a commit's renames — git's own rename detection, exact-path matches only, zero guessing. Dry-run unless --apply; the sync hook applies this automatically in the background.")
3621
+ .argument("[sha]", "commit whose renames to heal (default: HEAD)")
3622
+ .option("--apply", "rewrite the bindings (auto-commits each touched store as a `repair` move)")
3623
+ .action((sha, opts) => {
3624
+ const { store, root } = storeFor();
3625
+ try {
3626
+ if (!isGitRepo(root))
3627
+ return fail("repair needs a git repo.");
3628
+ const res = runRepair(store, root, sha ?? headSha(root), !!opts.apply);
3629
+ if (!res) {
3630
+ console.log("✓ Nothing to repair — the commit renamed nothing that memory binds exactly.");
3631
+ return;
3632
+ }
3633
+ const total = res.plan.rewrites.length + res.policyRewrites.length;
3634
+ console.log(`${res.applied ? "✓ Repaired" : "Would repair"} ${total} binding(s):`);
3635
+ for (const r of res.plan.rewrites)
3636
+ console.log(` ${r.id} ${r.field}: ${r.from} → ${r.to}`);
3637
+ for (const r of res.policyRewrites)
3638
+ console.log(` ${r.id} ${r.field}: ${r.from} → ${r.to}`);
3639
+ if (res.policyRewrites.length && res.applied)
3640
+ console.log(dim("\nRepaired policies need a fresh proof — they ask via `hunch escalations`."));
3641
+ if (!res.applied)
3642
+ console.log(dim("\nDry run — nothing changed. Re-run with --apply."));
3643
+ }
3644
+ finally {
3645
+ store.close();
3646
+ }
3647
+ });
3648
+ program
3649
+ .command("revert-move <sha>")
3650
+ .description("Undo one memory move: git-revert the commit that made it (LOCAL only, never pushed). Powers the Hunch view's 'reject move'.")
3651
+ .action((sha) => {
3652
+ const root = findRoot();
3653
+ if (!isGitRepo(root))
3654
+ return fail("not a git repo.");
3655
+ if (!revertMemoryMove(sha, root))
3656
+ return fail(`could not revert ${sha} (conflict or unknown commit) — aborted; working tree unchanged.`);
3657
+ console.log(`✓ reverted memory move ${sha} (local; not pushed).`);
3658
+ });
3659
+ program
3660
+ .command("push")
3661
+ .description("The approve-to-push step: push the current branch to its remote. Auto-commit keeps memory LOCAL by design; this is the one explicit outward move (public .hunch/ rides the repo, so this is a plain branch push).")
3662
+ .action(() => {
3663
+ const root = findRoot();
3664
+ if (!isGitRepo(root))
3665
+ return fail("not a git repo.");
3666
+ if (!pushCurrentBranch(root))
3667
+ return fail("push failed — no upstream, offline, or nothing to push.");
3668
+ console.log("✓ pushed the current branch to its remote.");
3669
+ });
2157
3670
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
2158
3671
  program
2159
3672
  .command("drift")
@@ -2394,7 +3907,7 @@ program
2394
3907
  for (const r of roadmap)
2395
3908
  console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
2396
3909
  if (pendingReview > 0)
2397
- console.log(`\n (${pendingReview} auto-drafted proposal(s) awaiting review — \`hunch review\`)`);
3910
+ console.log(`\n (${pendingReview} legacy un-vouched draft(s) — \`hunch adopt-drafts\` to auto-trust them as advisory)`);
2398
3911
  }
2399
3912
  finally {
2400
3913
  store.close();
@@ -2592,6 +4105,16 @@ program
2592
4105
  }
2593
4106
  const c = store.reindex().counts;
2594
4107
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
4108
+ try {
4109
+ const policies = new ConstitutionService(store, root).list();
4110
+ const active = policies.filter((p) => p.state === "active_advisory" || p.state === "active_blocking").length;
4111
+ const proposed = policies.filter((p) => p.state === "proposed").length;
4112
+ console.log(`constitution: ${policies.length} policies (${active} active, ${proposed} proposed)`);
4113
+ }
4114
+ catch (e) {
4115
+ console.log(`constitution: ⛔ ${e.message}`);
4116
+ process.exitCode = 1;
4117
+ }
2595
4118
  // Overlay status speaks the TRUE mode, and a dead pointer is a loud finding, not a
2596
4119
  // silent empty store: the JSON reader degrades to [] when the target dir is missing,
2597
4120
  // so this is the one place the loss is visible.