@davesheffer/hunch 1.7.1 → 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.
package/README.md CHANGED
@@ -53,6 +53,34 @@ Hunch creates a local graph of:
53
53
  It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
54
54
  and an optional pull-request guard.
55
55
 
56
+ ## Memory that runs itself (v1.8)
57
+
58
+ There is no review queue to manage. Hunch's memory loop is fully automated, and the rare decision
59
+ that genuinely needs a human is asked **inline, at the moment** — never parked in a backlog:
60
+
61
+ - **Auto-trust** — every captured decision enters the graph as live advisory memory the moment it
62
+ lands. It grounds and ranks immediately; it can never hard-block anything until a human
63
+ explicitly vouches for it. Migrate an old draft backlog once with `hunch adopt-drafts`.
64
+ - **A source-control panel for memory** — the VS Code **Hunch Memory** view shows every move
65
+ Hunch makes (capture / adopt / supersede / prune / repair) as a timeline: click for the diff,
66
+ right-click to revert locally. `hunch log` is the same spine in the terminal.
67
+ - **Inline escalations** — `hunch escalations` (and the `hunch_escalations` MCP tool) lists only
68
+ what the graph cannot resolve itself: a topic conflict, a candidate rule awaiting review, a
69
+ proposed rule ready to activate. Each entry is a question with its resolution verb. Normally
70
+ empty.
71
+ - **Self-repair** — rename a file and the next sync automatically heals every decision binding,
72
+ tripwire scope, constraint scope, and policy selector that matched it exactly (git's own rename
73
+ detection, zero guessing). Repairs land as revertable timeline moves; a repaired *policy* asks
74
+ once, inline, for a fresh proof.
75
+ - **Local-first by design** — memory auto-commits locally and rides your next push;
76
+ `hunch push` (or the panel's Approve-to-push) is the one deliberate outward step.
77
+
78
+ ```bash
79
+ hunch log # the memory timeline (what Hunch did, when, revertable)
80
+ hunch escalations # the decisions only you can make — normally empty
81
+ hunch repair --apply # heal bindings after a rename (sync does this automatically)
82
+ ```
83
+
56
84
  ## One graph. Every assistant. No lock-in.
57
85
 
58
86
  Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code / Copilot,
package/dist/cli/index.js CHANGED
@@ -30,13 +30,16 @@ import { indexRepo } from "../extractors/indexer.js";
30
30
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
31
31
  import { parseTestReport } from "../extractors/testreport.js";
32
32
  import { readSynthesisPreference, resolveSynthesisProvider, selectProvider, SYNTH_PREFERENCES, writeSynthesisPreference, } from "../synthesis/provider.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 } 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";
34
37
  import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
35
38
  import { runbookId, decisionId } from "../core/ids.js";
36
39
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
37
40
  import { extractInlineIntent } from "../extractors/comments.js";
38
41
  import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
39
- import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
42
+ import { partitionReview, isReviewDraft, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
40
43
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
41
44
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
42
45
  import { flushCapture } from "../integrations/sync.js";
@@ -64,6 +67,7 @@ import { renderCompilerScorecard, scoreCompilerCaseBank } from "../constitution/
64
67
  import { generateWiki, wikiStatus, wikiPrompt, publicHome, privateHome, readWikiManifestAt, nowData } from "../wiki/wiki.js";
65
68
  import { adoptProsePrompt } from "../wiki/adopt.js";
66
69
  import { topicCollisions, renderGrounding } from "../core/topics.js";
70
+ import { pendingEscalations, policyEscalations } from "../core/escalations.js";
67
71
  import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
68
72
  import { compareCandidates } from "../core/compare.js";
69
73
  import { checkConformance } from "../core/conformance.js";
@@ -333,6 +337,15 @@ program
333
337
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
334
338
  }
335
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 */ }
336
349
  const r = await syncCommit(store, root, sha ?? headSha(root), {
337
350
  force: opts.force,
338
351
  private: toOverlay,
@@ -994,10 +1007,18 @@ policyCmd
994
1007
  .description("List Policy IR records from the public store plus the local private overlay.")
995
1008
  .option("--state <state>", "filter by lifecycle state")
996
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)")
997
1011
  .action((opts) => {
998
1012
  const { store, root } = storeFor();
999
1013
  try {
1000
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
+ }
1001
1022
  if (!policies.length) {
1002
1023
  console.log("No Constitution policies found.");
1003
1024
  return;
@@ -1277,6 +1298,44 @@ policyCmd
1277
1298
  store.close();
1278
1299
  }
1279
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
+ });
1280
1339
  policyCmd
1281
1340
  .command("demote")
1282
1341
  .description("Immediately demote an active blocking policy to advisory without erasing history.")
@@ -1927,6 +1986,46 @@ experimentCmd
1927
1986
  store.close();
1928
1987
  }
1929
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,
2019
+ });
2020
+ console.log(JSON.stringify({ appended, report: service.experimentReport(runId) }, null, 2));
2021
+ }
2022
+ catch (e) {
2023
+ fail(e.message);
2024
+ }
2025
+ finally {
2026
+ store.close();
2027
+ }
2028
+ });
1930
2029
  experimentCmd
1931
2030
  .command("followup")
1932
2031
  .description("Append the preregistered seven-day EXP-03 reversal measurement.")
@@ -2627,9 +2726,14 @@ program
2627
2726
  .command("firmness")
2628
2727
  .description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
2629
2728
  .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
2630
- .action((level) => {
2729
+ .option("--json", "print the current level + choices as JSON (for the VS Code switch)")
2730
+ .action((level, opts) => {
2631
2731
  const paths = hunchPaths(findRoot());
2632
2732
  if (!level) {
2733
+ if (opts.json) {
2734
+ console.log(JSON.stringify({ firmness: readConfig(paths).firmness, levels: FIRMNESS_LEVELS }));
2735
+ return;
2736
+ }
2633
2737
  console.log(`firmness: ${readConfig(paths).firmness}`);
2634
2738
  console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
2635
2739
  return;
@@ -2693,7 +2797,7 @@ program
2693
2797
  const blocking = store.recs("constraints").filter((c) => c.status === "active" && c.severity === "blocking" && vouchedSrc(c.provenance?.source));
2694
2798
  const precise = blocking.filter((c) => !!effectiveForbids(c));
2695
2799
  const scopeOnly = blocking.filter((c) => !effectiveForbids(c));
2696
- 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);
2697
2801
  const { ready, scrutiny } = partitionReview(drafts, READY_MIN_GROUNDED);
2698
2802
  const stale = store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint");
2699
2803
  const fnote = {
@@ -2866,7 +2970,19 @@ program
2866
2970
  L.push(`Roadmap (${roadmap.length} live proposed): ${roadmap.slice(0, 3).map((r) => r.title).join(" · ")}${roadmap.length > 3 ? " · …" : ""}`);
2867
2971
  }
2868
2972
  if (pendingReview > 0)
2869
- 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
+ }
2870
2986
  L.push("Orient further: hunch_context(task) · hunch_structure() · `hunch now`.");
2871
2987
  // The operating loop rides session start — guaranteed delivery, once
2872
2988
  // (the zod bench showed ambient skills are read in ~0% of sessions).
@@ -3007,6 +3123,44 @@ function printReviewItem(it) {
3007
3123
  const synthLine = synth.raw ? `\n ↳ ${synth.raw}` : "";
3008
3124
  console.log(` ${d.id} [${d.status}, ${d.provenance.source} ${d.provenance.confidence}]${pruneNote}\n ${d.title}\n ${d.decision.slice(0, 120)}${synthLine}`);
3009
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
+ });
3010
3164
  program
3011
3165
  .command("review")
3012
3166
  .description("Triage drafts: segmented list, accept/reject one, or batch-accept Critic-verified drafts.")
@@ -3100,10 +3254,10 @@ program
3100
3254
  }
3101
3255
  }
3102
3256
  else {
3103
- const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
3257
+ const drafts = decisions().filter(isReviewDraft);
3104
3258
  const { ready, scrutiny } = partitionReview(drafts, minGrounded);
3105
3259
  if (!ready.length && !scrutiny.length) {
3106
- console.log("✓ No low-confidence drafts to review.");
3260
+ console.log("✓ No drafts awaiting review — captured memory auto-trusts (advisory) the moment it lands.");
3107
3261
  }
3108
3262
  else {
3109
3263
  if (ready.length) {
@@ -3154,7 +3308,7 @@ program
3154
3308
  const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
3155
3309
  const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
3156
3310
  // Same draft set `hunch review` / `hunch status` triage.
3157
- const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
3311
+ const drafts = all.filter(isReviewDraft);
3158
3312
  if (!drafts.length) {
3159
3313
  console.log("✓ No drafts to auto-review.");
3160
3314
  return;
@@ -3323,6 +3477,196 @@ program
3323
3477
  store.close();
3324
3478
  }
3325
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
+ });
3326
3670
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
3327
3671
  program
3328
3672
  .command("drift")
@@ -3563,7 +3907,7 @@ program
3563
3907
  for (const r of roadmap)
3564
3908
  console.log(` • ${r.title} (${r.id}${r.topic ? `, ${r.topic}` : ""}, since ${r.date})\n ${r.note}`);
3565
3909
  if (pendingReview > 0)
3566
- 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)`);
3567
3911
  }
3568
3912
  finally {
3569
3913
  store.close();
@@ -349,7 +349,10 @@ const Exp01MetricsSchema = z.object({
349
349
  ctx.addIssue({ code: "custom", path: ["policy_violation"], message: "policy_violation must be present exactly for valid completions" });
350
350
  });
351
351
  const Exp03MetricsSchema = z.object({
352
- decision: z.enum(["accepted_precise", "accepted_edited", "rejected", "uncompilable", "abandoned", "timeout"]),
352
+ // "uncompilable" is the revision-1 vocabulary; "cannot_decide" is its revision-2
353
+ // successor (expreg_ba6aef4ecd counts cannot-decide as its own raw category, so
354
+ // the recorded token must be what the reviewer actually chose — never folded).
355
+ decision: z.enum(["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"]),
353
356
  precise: z.boolean(),
354
357
  proof_inspected: z.boolean(),
355
358
  result_hash: z.string().regex(HASH).nullable(),
@@ -452,6 +455,58 @@ export function compileExperimentOutcome(input, run, opts = {}) {
452
455
  }
453
456
  return parsed;
454
457
  }
458
+ // ---- EXP-03 revision-2 standardized response (dec_0be4fd3717) --------------
459
+ // Revision-2 reviews are SUBMITTED in the same plain-language vocabulary they are
460
+ // PRESENTED in: one choice out of four, the rule text when one is kept, and one
461
+ // plain sentence. The mapper below is the single deterministic translation from
462
+ // that template into the canonical Exp03 metrics vocabulary — the reviewer never
463
+ // hand-crafts metrics, so the presented contract and the recorded outcome cannot
464
+ // drift apart. Revision-1 cases are refused here (their original raw submission
465
+ // path stays byte-identical for the append-only pilot).
466
+ export const EXP03_REVIEW_CHOICES = ["accept", "edit", "reject", "cannot_decide"];
467
+ const CHOICE_TO_DECISION = {
468
+ accept: "accepted_precise",
469
+ edit: "accepted_edited",
470
+ reject: "rejected",
471
+ cannot_decide: "cannot_decide", // its own raw category per expreg_ba6aef4ecd — never folded into uncompilable
472
+ };
473
+ export function compileExp03ReviewResponse(item, arm, response) {
474
+ if (!item.required_relationship) {
475
+ throw new Error(`case ${item.id} is a revision-1 pilot case and keeps its original submission contract; use the raw review submission, not the standardized template`);
476
+ }
477
+ if (!EXP03_REVIEW_CHOICES.includes(response.choice)) {
478
+ throw new Error(`choice must be one of: ${EXP03_REVIEW_CHOICES.join(" | ")}`);
479
+ }
480
+ const reason = response.reason?.trim();
481
+ if (!reason)
482
+ throw new Error("the response requires one plain-language sentence of reasoning");
483
+ const accepted = response.choice === "accept" || response.choice === "edit";
484
+ const rule = response.rule_text?.trim() || null;
485
+ if (accepted && !rule)
486
+ throw new Error(`choice "${response.choice}" requires the rule text it keeps`);
487
+ if (!accepted && rule)
488
+ throw new Error(`choice "${response.choice}" must leave the rule text blank`);
489
+ // Arms B/C present a proposed rule: "use it as written" must be byte-faithful to
490
+ // what was shown, and "after I correct it" must actually change it — the choice
491
+ // and the submitted text can never contradict each other.
492
+ if (arm !== "A" && response.choice === "accept" && rule !== item.compiler_candidate) {
493
+ throw new Error('the submitted rule differs from the one presented; use choice "edit"');
494
+ }
495
+ if (arm !== "A" && response.choice === "edit" && rule === item.compiler_candidate) {
496
+ throw new Error('the submitted rule is unchanged from the one presented; use choice "accept"');
497
+ }
498
+ if (response.inspected_supporting_checks && arm !== "C") {
499
+ throw new Error("supporting checks are only shown in arm C; this review cannot claim to have inspected them");
500
+ }
501
+ return {
502
+ decision: CHOICE_TO_DECISION[response.choice],
503
+ precise: accepted, // schema invariant: accepted outcomes are precise; graded later against the target commitment
504
+ proof_inspected: arm === "C" && !!response.inspected_supporting_checks,
505
+ result: accepted ? rule : null,
506
+ silent_semantic_substitution: false, // graded post-hoc via the append-only correction workflow, never self-declared
507
+ rejection_reason: response.choice === "reject" ? reason : null,
508
+ };
509
+ }
455
510
  export const ExperimentReviewStartSchema = z.object({
456
511
  id: z.string().regex(/^expreview_[a-f0-9]{10}$/),
457
512
  content_hash: z.string().regex(HASH),
@@ -564,6 +619,7 @@ export function compileExperimentStop(input, run, opts = {}) {
564
619
  const contentHash = canonicalHash(body);
565
620
  return ExperimentStopSchema.parse({ id: `expstop_${shortHash(contentHash)}`, content_hash: contentHash, ...body });
566
621
  }
622
+ const EXP03_DECISION_TOKENS = ["accepted_precise", "accepted_edited", "rejected", "uncompilable", "cannot_decide", "abandoned", "timeout"];
567
623
  function wilson(successes, total) {
568
624
  if (!total)
569
625
  return null;
@@ -668,6 +724,9 @@ export function buildExperimentReport(run, bank, outcomes, followups, stops = []
668
724
  bootstrap_95: run.experiment === "EXP-03" ? bootstrapReviewerRate(reviews, `${run.seed}:${arm}:reviewer-rate`) : null,
669
725
  reversals: run.experiment === "EXP-03" ? measuredFollowups.filter((item) => item.reversed === true).length : null,
670
726
  followups_missing: run.experiment === "EXP-03" ? completed.length - measuredFollowups.length : null,
727
+ decisions: run.experiment === "EXP-03"
728
+ ? Object.fromEntries(EXP03_DECISION_TOKENS.map((token) => [token, reviews.filter((item) => item.decision === token).length]))
729
+ : null,
671
730
  };
672
731
  });
673
732
  const caseById = new Map(bank.cases.map((item) => [item.id, item]));