@davesheffer/hunch 1.4.2 → 1.6.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/dist/cli/index.js CHANGED
@@ -29,7 +29,7 @@ import { indexRepo } from "../extractors/indexer.js";
29
29
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
30
30
  import { parseTestReport } from "../extractors/testreport.js";
31
31
  import { selectProvider } from "../synthesis/provider.js";
32
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, pullHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.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
33
  import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
34
34
  import { runbookId, decisionId } from "../core/ids.js";
35
35
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
@@ -50,8 +50,10 @@ import { formatContext, formatStructure } from "../core/format.js";
50
50
  import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
51
51
  import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpolicy.js";
52
52
  import { injectionMode } from "../core/hookcache.js";
53
+ import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
53
54
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
54
55
  import { draftDuplicateOf } from "../core/dupdetect.js";
56
+ import { planAutoReview, planMutations } from "../core/autoreview.js";
55
57
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
56
58
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
57
59
  import { computeDrift } from "../core/drift.js";
@@ -68,6 +70,7 @@ import { mergeHunchJson } from "../store/merge.js";
68
70
  import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
69
71
  import { ENTITY_KINDS } from "../core/types.js";
70
72
  import { planCompaction } from "../store/compact.js";
73
+ import { repairDecisionReference } from "../core/refrepair.js";
71
74
  import { resolveInvocation } from "./invocation.js";
72
75
  const program = new Command();
73
76
  program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
@@ -81,12 +84,12 @@ function storeFor() {
81
84
  // ---- init -----------------------------------------------------------------
82
85
  program
83
86
  .command("init")
84
- .description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Codex).")
87
+ .description("Scaffold .hunch/, index the repo, install the git hook, and wire up your coding assistants (Claude Code, Cursor, VS Code, Windsurf, Antigravity, Codex).")
85
88
  .option("--no-index", "skip the initial repo index")
86
89
  .option("--no-enforce", "do not install the advisory pre-commit constraint guard")
87
90
  .option("--enforce-strict", "make the pre-commit guard FAIL the commit on a direct, high-confidence, non-stale blocking invariant")
88
- .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
89
- .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
91
+ .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Windsurf / Antigravity / Codex / AGENTS.md)")
92
+ .option("--no-agent-hooks", "skip installing all assistant lifecycle hooks (MCP + grounding are still configured)")
90
93
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
91
94
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
92
95
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
@@ -138,7 +141,7 @@ program
138
141
  }
139
142
  if (isGitRepo(root)) {
140
143
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
141
- const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
144
+ const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
142
145
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
143
146
  const m = installMergeDriver(root, inv.shell);
144
147
  console.log(` ✓ team merge driver ${m.action}`);
@@ -172,16 +175,16 @@ program
172
175
  // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
173
176
  // level even before the user runs `hunch firmness` (--firmness validated above).
174
177
  const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
175
- // Agent hooks: ground the assistant in Hunch automatically (PreToolUse injects
176
- // context before edits; UserPromptSubmit reminds). Reads firmness at run time.
178
+ // Claude's native hooks run alongside provider-specific hooks below. Every
179
+ // adapter reads firmness at run time, so changing it needs no config rewrite.
177
180
  if (opts.agentHooks !== false) {
178
181
  const a = installClaudeHooks(root, `${inv.shell} hook`);
179
182
  console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
180
183
  }
181
- // Multi-assistant compatibility: the MCP server is client-agnostic, so wire up
182
- // Cursor / VS Code (Copilot) / Codex / AGENTS.md to the same .hunch/ graph.
184
+ // Multi-assistant compatibility: MCP + grounding + lifecycle adapters share
185
+ // the same .hunch/ graph across Cursor / VS Code / Windsurf / Antigravity.
183
186
  if (opts.providers !== false) {
184
- const ps = scaffoldProviders(root, inv.mcp, store);
187
+ const ps = scaffoldProviders(root, inv.mcp, store, { agentHooks: opts.agentHooks !== false });
185
188
  const ok = ps.filter((p) => !p.error);
186
189
  const total = ok.reduce((a, p) => a + p.files.length, 0);
187
190
  console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
@@ -320,7 +323,16 @@ program
320
323
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
321
324
  }
322
325
  store.json.ensureDirs();
323
- const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: toOverlay, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
326
+ const r = await syncCommit(store, root, sha ?? headSha(root), {
327
+ force: opts.force,
328
+ private: toOverlay,
329
+ // A split-private overlay is sensitive/local by definition. A shared store
330
+ // is an explicit team policy and may keep its configured synthesis provider.
331
+ localOnly: toOverlay && store.mode === "private",
332
+ deep: opts.deep,
333
+ verify: opts.verify,
334
+ samples: parseSamples(opts.samples),
335
+ });
324
336
  if (r.status === "written") {
325
337
  store.reindex();
326
338
  // Don't rewrite grounding from the hook — it would dirty the working tree on
@@ -455,7 +467,7 @@ function configureOverlay(dir, opts, mode) {
455
467
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
456
468
  let hookNote = "";
457
469
  if (opts.hook && isGitRepo(root)) {
458
- const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
470
+ const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
459
471
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
460
472
  }
461
473
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
@@ -1047,16 +1059,24 @@ program
1047
1059
  .description("Capture a Bug from a failing test (symptom + suspect ranking).")
1048
1060
  .requiredOption("--test <id>", "failing test id/name")
1049
1061
  .requiredOption("--message <msg>", "failure message / stack")
1062
+ .option("--private", "keep the bug and its failure text in the private overlay; uses deterministic local synthesis")
1050
1063
  .action(async (opts) => {
1051
1064
  const { store, root } = storeFor();
1065
+ if (opts.private && !store.hasPrivate) {
1066
+ store.close();
1067
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1068
+ }
1052
1069
  store.json.ensureDirs();
1053
- const r = await recordFailure(store, root, { test: opts.test, message: opts.message });
1070
+ const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
1054
1071
  store.reindex();
1055
- console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"`);
1072
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${r.bug.id}`);
1073
+ console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
1056
1074
  if (r.bug.lineage.recurrence_of)
1057
1075
  console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
1058
1076
  if (r.constraint)
1059
1077
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
1078
+ if (flush === "pushed")
1079
+ console.log(" ↳ private memory committed + pushed");
1060
1080
  store.close();
1061
1081
  });
1062
1082
  // ---- record-constraint (human-authored invariant) -------------------------
@@ -1073,11 +1093,16 @@ program
1073
1093
  .option("--forbid-dep <names>", "comma-sep imports that BREAK the rule (parsed-import precise; e.g. lodash) — blocks the real violation, immune to staleness")
1074
1094
  .option("--forbid-symbol <names>", "comma-sep identifier names that break the rule")
1075
1095
  .option("--match <regex>", "textual line regex (lint-grade last resort; prefer --forbid-dep/--forbid-symbol)")
1096
+ .option("--private", "write the invariant into the private overlay (local enforcement only; never included in public CI output)")
1076
1097
  .action((statement, opts) => {
1077
1098
  const SEV = ["advisory", "warning", "blocking"];
1078
1099
  if (!SEV.includes(opts.severity))
1079
1100
  return fail(`--severity must be one of: ${SEV.join(", ")}`);
1080
1101
  const { store, root } = storeFor();
1102
+ if (opts.private && !store.hasPrivate) {
1103
+ store.close();
1104
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1105
+ }
1081
1106
  store.json.ensureDirs();
1082
1107
  const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
1083
1108
  const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
@@ -1107,10 +1132,14 @@ program
1107
1132
  valid_from: new Date().toISOString(),
1108
1133
  valid_to: null,
1109
1134
  provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
1110
- });
1135
+ }, opts.private);
1111
1136
  store.reindex();
1112
- refreshExistingGrounding(root, store); // keep EVERY assistant's grounding current, not just CLAUDE.md
1113
- console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
1137
+ // Public grounding is a publishable artifact. Private rules stay local and
1138
+ // are surfaced by local checks/MCP, never copied into committed agent docs.
1139
+ if (store.captureHome(!!opts.private) === "public")
1140
+ refreshExistingGrounding(root, store);
1141
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
1142
+ console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
1114
1143
  if (derived && c.forbids?.deps.length)
1115
1144
  console.log(` ↳ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
1116
1145
  if (c.severity === "blocking" && !effectiveForbids(c)) {
@@ -1119,6 +1148,8 @@ program
1119
1148
  console.log(` ⚠ scope-only — this will downgrade to advisory once a file in scope is changed after today.`);
1120
1149
  console.log(` To block the actual violation across the file's life, add --forbid-dep <pkg> (or --forbid-symbol / --match).`);
1121
1150
  }
1151
+ if (flush === "pushed")
1152
+ console.log(" ↳ private memory committed + pushed");
1122
1153
  store.close();
1123
1154
  });
1124
1155
  // ---- test (failure-learning loop) -----------------------------------------
@@ -1127,8 +1158,13 @@ program
1127
1158
  .description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
1128
1159
  .argument("[cmd...]", "test command to run (default: `npm test`)")
1129
1160
  .option("--dry-run", "show what would be captured without writing")
1161
+ .option("--private", "keep captured test failures in the private overlay and use deterministic local synthesis")
1130
1162
  .action(async (cmd, opts) => {
1131
1163
  const { store, root } = storeFor();
1164
+ if (opts.private && !store.hasPrivate) {
1165
+ store.close();
1166
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1167
+ }
1132
1168
  store.json.ensureDirs();
1133
1169
  // Run as a shell string (not argv) so the npm/test-runner shim resolves on
1134
1170
  // Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
@@ -1155,7 +1191,7 @@ program
1155
1191
  store.close();
1156
1192
  return;
1157
1193
  }
1158
- const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
1194
+ const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output, private: opts.private });
1159
1195
  for (const { bug, constraint } of cap.results) {
1160
1196
  if (constraint)
1161
1197
  console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
@@ -1165,6 +1201,8 @@ program
1165
1201
  for (const b of cap.fixed)
1166
1202
  console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
1167
1203
  store.reindex();
1204
+ if (cap.results.length || cap.fixed.length)
1205
+ flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture test results`);
1168
1206
  store.close();
1169
1207
  const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
1170
1208
  const promoted = cap.results.filter((r) => r.constraint).length;
@@ -1229,6 +1267,7 @@ program
1229
1267
  .command("check")
1230
1268
  .description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
1231
1269
  .option("--staged", "check git staged files (default)")
1270
+ .option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
1232
1271
  .option("--commit <sha>", "check a specific commit's files")
1233
1272
  .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
1234
1273
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
@@ -1236,9 +1275,9 @@ program
1236
1275
  .option("--blast", "also print the dependency blast radius of the changed files")
1237
1276
  .option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
1238
1277
  .action((opts) => {
1239
- const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
1278
+ const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged", opts.working && "--working"].filter(Boolean);
1240
1279
  if (sources.length > 1)
1241
- return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
1280
+ return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
1242
1281
  const markdown = opts.format === "markdown";
1243
1282
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
1244
1283
  const { store, root } = storeFor();
@@ -1251,7 +1290,8 @@ program
1251
1290
  store.reindex(); // blast radius walks the edge graph — make the index current
1252
1291
  const files = opts.commit ? commitFiles(opts.commit, root)
1253
1292
  : opts.base ? rangeFiles(opts.base, root)
1254
- : stagedFiles(root);
1293
+ : opts.working ? workingFiles(root)
1294
+ : stagedFiles(root);
1255
1295
  if (!files.length) {
1256
1296
  console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
1257
1297
  store.close();
@@ -1261,7 +1301,7 @@ program
1261
1301
  // code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
1262
1302
  // hardened strict gate + causal `why` citations — all assembled by the shared
1263
1303
  // store.buildCheckReport (also used by the hunch_merge_verdict tool).
1264
- const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
1304
+ const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
1265
1305
  const report = store.buildCheckReport(files, diff, {
1266
1306
  strict: !!opts.strict,
1267
1307
  lastChange: (f) => lastChangeDate(f, root),
@@ -1488,7 +1528,7 @@ program
1488
1528
  // ---- firmness (agent-hook enforcement level) ------------------------------
1489
1529
  program
1490
1530
  .command("firmness")
1491
- .description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
1531
+ .description("Get or set how firmly agent lifecycle hooks enforce Hunch before edits.")
1492
1532
  .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
1493
1533
  .action((level) => {
1494
1534
  const paths = hunchPaths(findRoot());
@@ -1501,7 +1541,7 @@ program
1501
1541
  return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
1502
1542
  }
1503
1543
  const next = writeConfig(paths, { firmness: level }).firmness;
1504
- console.log(`✓ firmness set to ${next} (takes effect on the next edit — no Claude Code restart needed).`);
1544
+ console.log(`✓ firmness set to ${next} (takes effect on the next agent edit — no restart needed).`);
1505
1545
  });
1506
1546
  // ---- status (enforcement readiness at a glance) ---------------------------
1507
1547
  program
@@ -1542,16 +1582,22 @@ program
1542
1582
  console.log("");
1543
1583
  store.close();
1544
1584
  });
1545
- // ---- hook (Claude Code agent-hook handler) --------------------------------
1585
+ // ---- hook (multi-agent lifecycle hook handler) ----------------------------
1546
1586
  program
1547
1587
  .command("hook")
1548
- .description("Claude Code hook handler: inject relevant Hunch context before edits (and, at strict firmness, deny edits that hit a blocking invariant). Reads the hook event JSON on stdin.")
1549
- .action(async () => {
1588
+ .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.")
1589
+ .option("--provider <provider>", "hook event dialect: claude | vscode | cursor | windsurf | antigravity", "claude")
1590
+ .action(async (opts) => {
1550
1591
  // A hook MUST NEVER break the agent: on ANY error or unrecognized input we
1551
1592
  // emit nothing and exit 0 (the action defers to Claude Code's normal flow).
1552
1593
  let store = null;
1553
1594
  try {
1554
- const evt = JSON.parse(await readStdin());
1595
+ const provider = hookProvider(opts.provider);
1596
+ if (!provider)
1597
+ return;
1598
+ const evt = normalizeHookEvent(JSON.parse(await readStdin()), provider);
1599
+ if (!evt)
1600
+ return;
1555
1601
  const root = findRoot();
1556
1602
  const paths = hunchPaths(root);
1557
1603
  const firmness = readConfig(paths).firmness;
@@ -1581,7 +1627,7 @@ program
1581
1627
  const verdict = stopVerdict(st, firmness);
1582
1628
  if (verdict.block) {
1583
1629
  savePipelineState(evt.session_id, verdict.state);
1584
- process.stdout.write(JSON.stringify({ decision: "block", reason: verdict.reason }));
1630
+ emitStop(provider, verdict.reason);
1585
1631
  }
1586
1632
  return;
1587
1633
  }
@@ -1603,7 +1649,7 @@ program
1603
1649
  // different content, so it always comes through (dec_244397d920).
1604
1650
  if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
1605
1651
  return;
1606
- emitContext("UserPromptSubmit", text);
1652
+ emitContext(provider, "UserPromptSubmit", text);
1607
1653
  return;
1608
1654
  }
1609
1655
  if (evt.hook_event_name === "SessionStart") {
@@ -1619,7 +1665,7 @@ program
1619
1665
  if (!decisions.length) {
1620
1666
  // Fresh graph: nothing to orient on, but the operating loop still ships.
1621
1667
  if (pipelineEnabled())
1622
- emitContext("SessionStart", PIPELINE_LOOP);
1668
+ emitContext(provider, "SessionStart", PIPELINE_LOOP);
1623
1669
  return;
1624
1670
  }
1625
1671
  const L = [];
@@ -1639,7 +1685,13 @@ program
1639
1685
  // (the zod bench showed ambient skills are read in ~0% of sessions).
1640
1686
  if (pipelineEnabled())
1641
1687
  L.push("", PIPELINE_LOOP);
1642
- emitContext("SessionStart", L.join("\n"));
1688
+ const orientation = L.join("\n");
1689
+ // Antigravity's nearest equivalent is PreInvocation, which can fire
1690
+ // repeatedly in one conversation. Deduplicate it just like edit
1691
+ // grounding so it remains an orientation, not a context flood.
1692
+ if (provider === "antigravity" && injectionMode(evt.session_id, "orientation", orientation) === "delta")
1693
+ return;
1694
+ emitContext(provider, "SessionStart", orientation);
1643
1695
  }
1644
1696
  finally {
1645
1697
  s.close();
@@ -1671,7 +1723,7 @@ program
1671
1723
  const proposedLines = proposedEditLines(evt.tool_input);
1672
1724
  const deny = blockingInScope(store, target, proposedLines);
1673
1725
  if (deny) {
1674
- emitDeny(deny.reason);
1726
+ emitDeny(provider, deny.reason);
1675
1727
  return;
1676
1728
  }
1677
1729
  // Veto Guard (live): the proposed edit text re-introduces an approach an
@@ -1679,7 +1731,7 @@ program
1679
1731
  // only human-confirmed tripwires deny.
1680
1732
  const vetoDeny = proposedLines.length ? vetoInScope(store, target, proposedLines) : null;
1681
1733
  if (vetoDeny) {
1682
- emitDeny(vetoDeny.reason);
1734
+ emitDeny(provider, vetoDeny.reason);
1683
1735
  return;
1684
1736
  }
1685
1737
  }
@@ -1722,10 +1774,10 @@ program
1722
1774
  // the full 10-16KB block. Any record change re-sends the full text; the
1723
1775
  // strict-gate deny path above never routes through this (dec_244397d920).
1724
1776
  if (injectionMode(evt.session_id, `pre:${target}`, text) === "delta") {
1725
- emitContext("PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
1777
+ emitContext(provider, "PreToolUse", `Hunch grounding for ${target}: unchanged this session (${ctx.decisions.length} decision(s), ${ctx.constraints.length} invariant(s) shown earlier — still current; hunch_why("${target}") to re-expand).`);
1726
1778
  return;
1727
1779
  }
1728
- emitContext("PreToolUse", text);
1780
+ emitContext(provider, "PreToolUse", text);
1729
1781
  }
1730
1782
  catch {
1731
1783
  // swallow — never block an edit on a hook failure
@@ -1774,29 +1826,40 @@ program
1774
1826
  .option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
1775
1827
  .option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
1776
1828
  .option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
1829
+ .option("--private", "include local private/shared-overlay drafts; terminal output may contain private memory")
1777
1830
  .action((opts) => {
1778
1831
  const { store, root } = storeFor();
1779
1832
  const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
1833
+ if (opts.private && !store.hasPrivate) {
1834
+ store.close();
1835
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1836
+ }
1837
+ const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
1838
+ let publicGroundingChanged = false;
1780
1839
  if (opts.accept) {
1781
- const d = store.json.get("decisions", opts.accept);
1840
+ const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
1782
1841
  if (!d) {
1783
1842
  store.close();
1784
1843
  return fail(`decision ${opts.accept} not found`);
1785
1844
  }
1845
+ const inPrivate = !!store.getPrivateRec("decisions", d.id);
1786
1846
  const { source, armed } = acceptDecision(store, d);
1787
1847
  store.reindex();
1788
- refreshExistingGrounding(root, store); // confirming a rule must reach EVERY assistant's grounding
1848
+ if (!inPrivate) {
1849
+ refreshExistingGrounding(root, store);
1850
+ publicGroundingChanged = true;
1851
+ }
1789
1852
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
1790
1853
  }
1791
1854
  else if (opts.reject) {
1792
- const ok2 = store.json.delete("decisions", opts.reject);
1855
+ const ok2 = opts.private ? store.deleteWhereItLives("decisions", opts.reject) : store.json.delete("decisions", opts.reject);
1793
1856
  store.reindex();
1794
1857
  console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
1795
1858
  }
1796
1859
  else if (opts.rejectDuplicates) {
1797
1860
  // Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
1798
1861
  // only drafts, only against ACCEPTED records, conservative threshold.
1799
- const all = store.json.loadAll("decisions");
1862
+ const all = decisions();
1800
1863
  const drafts = all.filter((d) => d.status === "proposed" && !d.provenance.source.includes("human_confirmed"));
1801
1864
  const dupes = drafts
1802
1865
  .map((d) => ({ d, m: draftDuplicateOf(d, all) }))
@@ -1807,7 +1870,7 @@ program
1807
1870
  else {
1808
1871
  let removed = 0;
1809
1872
  for (const { d, m } of dupes) {
1810
- if (store.json.delete("decisions", d.id))
1873
+ if ((opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id)))
1811
1874
  removed++;
1812
1875
  console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
1813
1876
  }
@@ -1818,24 +1881,28 @@ program
1818
1881
  else if (opts.acceptVerified) {
1819
1882
  // Batch path: only Critic-verified, well-grounded drafts qualify — still the
1820
1883
  // human-driven accept gate (the operator runs this), just over a safe subset.
1821
- const proposed = store.json.loadAll("decisions").filter((d) => d.status === "proposed");
1884
+ const proposed = decisions().filter((d) => d.status === "proposed");
1822
1885
  const { ready } = partitionReview(proposed, minGrounded);
1823
1886
  if (!ready.length) {
1824
1887
  console.log(`✓ No Critic-verified drafts at grounded ≥ ${minGrounded} to batch-accept.`);
1825
1888
  }
1826
1889
  else {
1827
1890
  let armedTotal = 0;
1828
- for (const it of ready)
1891
+ for (const it of ready) {
1892
+ if (!store.getPrivateRec("decisions", it.d.id))
1893
+ publicGroundingChanged = true;
1829
1894
  armedTotal += acceptDecision(store, it.d).armed;
1895
+ }
1830
1896
  store.reindex();
1831
- refreshExistingGrounding(root, store); // batch-confirm must reach EVERY assistant's grounding
1897
+ if (publicGroundingChanged)
1898
+ refreshExistingGrounding(root, store); // committed grounding stays public-only
1832
1899
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
1833
1900
  for (const it of ready)
1834
1901
  console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
1835
1902
  }
1836
1903
  }
1837
1904
  else {
1838
- const drafts = store.json.loadAll("decisions").filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1905
+ const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1839
1906
  const { ready, scrutiny } = partitionReview(drafts, minGrounded);
1840
1907
  if (!ready.length && !scrutiny.length) {
1841
1908
  console.log("✓ No low-confidence drafts to review.");
@@ -1849,7 +1916,7 @@ program
1849
1916
  }
1850
1917
  if (scrutiny.length) {
1851
1918
  console.log(`⚠ ${scrutiny.length} need scrutiny — unverified / low-grounded (lowest confidence first):\n`);
1852
- const all = store.json.loadAll("decisions");
1919
+ const all = decisions();
1853
1920
  let dupCount = 0;
1854
1921
  for (const it of scrutiny) {
1855
1922
  printReviewItem(it);
@@ -1867,6 +1934,109 @@ program
1867
1934
  }
1868
1935
  store.close();
1869
1936
  });
1937
+ // ---- auto-review (harness-driven triage) ----------------------------------
1938
+ /** One line per plan entry. */
1939
+ function printAutoEntry(e) {
1940
+ console.log(` ${e.d.id} ${e.d.title.slice(0, 66)}\n ${dim(e.reason)}`);
1941
+ }
1942
+ program
1943
+ .command("auto-review")
1944
+ .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.")
1945
+ .option("--apply", "execute the plan (accept/delete). Without it, print the plan and change nothing.")
1946
+ .option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
1947
+ .option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
1948
+ .option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
1949
+ .option("--private", "include local private/shared-overlay drafts; private drafts are never sent to an LLM judge")
1950
+ .action(async (opts) => {
1951
+ const { store, root } = storeFor();
1952
+ try {
1953
+ if (opts.private && !store.hasPrivate)
1954
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1955
+ const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
1956
+ const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
1957
+ const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
1958
+ // Same draft set `hunch review` / `hunch status` triage.
1959
+ const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1960
+ if (!drafts.length) {
1961
+ console.log("✓ No drafts to auto-review.");
1962
+ return;
1963
+ }
1964
+ // Delegate relevance to the harness (subscription CLI) — feature-detected,
1965
+ // and any per-draft failure degrades to "not judged" (kept for a human).
1966
+ const verdicts = new Map();
1967
+ if (opts.llm !== false && !opts.private) {
1968
+ const provider = await selectProvider();
1969
+ if (provider.judgeDraft) {
1970
+ // The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
1971
+ const existing = all
1972
+ .filter((d) => d.provenance.source.includes("human_confirmed") && d.status !== "superseded" && d.status !== "rejected")
1973
+ .map((d) => ({ id: d.id, title: d.title, decision: d.decision }));
1974
+ console.log(`Judging ${drafts.length} draft(s) via ${provider.name} (subscription)…`);
1975
+ for (const d of drafts) {
1976
+ try {
1977
+ verdicts.set(d.id, await provider.judgeDraft(d, existing.filter((e) => e.id !== d.id)));
1978
+ }
1979
+ catch {
1980
+ /* transient / unparseable — leave unjudged, planner keeps it for a human */
1981
+ }
1982
+ }
1983
+ }
1984
+ else {
1985
+ console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
1986
+ }
1987
+ }
1988
+ else if (opts.private && opts.llm !== false) {
1989
+ console.log(dim("Private review stays local — harness judgment skipped (dedup + grounding only)."));
1990
+ }
1991
+ const plan = planAutoReview(drafts, all, verdicts, { minGrounded, minRejectConfidence });
1992
+ printAutoReviewPlan(plan);
1993
+ if (!opts.apply) {
1994
+ const n = planMutations(plan);
1995
+ console.log(`\n${dim(`Dry run — nothing changed. Re-run with --apply to ${n ? `apply ${n} change(s)` : "confirm (no changes)"}.`)}`);
1996
+ return;
1997
+ }
1998
+ // Apply: accept the verified+relevant, delete duplicates + irrelevant.
1999
+ let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
2000
+ for (const e of plan.accept) {
2001
+ if (!store.getPrivateRec("decisions", e.d.id))
2002
+ publicAccepted = true;
2003
+ armedTotal += acceptDecision(store, e.d).armed;
2004
+ accepted++;
2005
+ }
2006
+ for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
2007
+ if ((opts.private ? store.deleteWhereItLives("decisions", e.d.id) : store.json.delete("decisions", e.d.id)))
2008
+ deleted++;
2009
+ }
2010
+ if (accepted || deleted) {
2011
+ store.reindex();
2012
+ if (publicAccepted)
2013
+ refreshExistingGrounding(root, store); // committed grounding stays public-only
2014
+ }
2015
+ console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${deleted} deleted, ${plan.keep.length} kept for review.`);
2016
+ }
2017
+ finally {
2018
+ store.close();
2019
+ }
2020
+ });
2021
+ /** Print the four buckets of an auto-review plan (skipping empty ones). */
2022
+ function printAutoReviewPlan(plan) {
2023
+ if (plan.accept.length) {
2024
+ console.log(`\n✓ ACCEPT — verified, grounded, harness-relevant (${plan.accept.length}):`);
2025
+ plan.accept.forEach(printAutoEntry);
2026
+ }
2027
+ if (plan.rejectDuplicate.length) {
2028
+ console.log(`\n✗ DELETE (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
2029
+ plan.rejectDuplicate.forEach(printAutoEntry);
2030
+ }
2031
+ if (plan.rejectIrrelevant.length) {
2032
+ console.log(`\n✗ DELETE (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
2033
+ plan.rejectIrrelevant.forEach(printAutoEntry);
2034
+ }
2035
+ if (plan.keep.length) {
2036
+ console.log(`\n⏳ KEEP for human review (${plan.keep.length}):`);
2037
+ plan.keep.forEach(printAutoEntry);
2038
+ }
2039
+ }
1870
2040
  // ---- mcp ------------------------------------------------------------------
1871
2041
  program
1872
2042
  .command("mcp")
@@ -2014,23 +2184,24 @@ program
2014
2184
  .description("PR impact: the dependency + memory surface of a change — dependent files reached, invariants direct/near, and the decisions concerned. Read-only, advisory (gating is `hunch check`). Omit base and --commit to inspect staged changes.")
2015
2185
  .argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
2016
2186
  .option("--commit <sha>", "impact of a single commit")
2187
+ .option("--working", "impact all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
2017
2188
  .action((base, opts) => {
2018
2189
  const { store, root } = storeFor();
2019
2190
  try {
2020
- if (base && opts.commit)
2021
- return fail("Pass at most one of [base] / --commit.");
2191
+ if ((base && opts.commit) || (opts.working && (base || opts.commit)))
2192
+ return fail("Pass exactly one of [base] / --commit / --working (or omit all for staged changes).");
2022
2193
  if (base && !revExists(base, root))
2023
2194
  return fail(`base ref "${base}" does not resolve.`);
2024
2195
  if (opts.commit && !revExists(opts.commit, root))
2025
2196
  return fail(`commit "${opts.commit}" does not resolve.`);
2026
2197
  store.reindex(); // reflect out-of-band JSON edits before reading the graph
2027
- const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
2028
- const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : "staged changes";
2198
+ const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : opts.working ? workingFiles(root) : stagedFiles(root);
2199
+ const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : opts.working ? "working changes" : "staged changes";
2029
2200
  if (!files.length) {
2030
2201
  console.log(`No changed files in ${scope}.`);
2031
2202
  return;
2032
2203
  }
2033
- const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
2204
+ const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
2034
2205
  console.log(renderImpact(store.prImpact(files, diff), scope));
2035
2206
  }
2036
2207
  finally {
@@ -2238,6 +2409,38 @@ program
2238
2409
  store.close();
2239
2410
  }
2240
2411
  });
2412
+ // ---- repair-ref (atomic decision reference correction) --------------------
2413
+ program
2414
+ .command("repair-ref")
2415
+ .description("Atomically repair one exact file reference in a decision's scope and provenance evidence (never changes the decision itself).")
2416
+ .argument("<decision>", "decision id containing the stale reference")
2417
+ .requiredOption("--from <path>", "exact stale path to replace")
2418
+ .requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
2419
+ .option("--private", "require the decision to be in the configured private overlay")
2420
+ .action((id, opts) => {
2421
+ const { store } = storeFor();
2422
+ try {
2423
+ if (opts.from === opts.to)
2424
+ return fail("--from and --to must be different paths");
2425
+ if (opts.private && !store.hasPrivate)
2426
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
2427
+ // `getRec` is deliberately overlay-first. An explicit --private prevents a
2428
+ // same-id public record from being silently amended instead of private memory.
2429
+ const d = opts.private ? store.getPrivateRec("decisions", id) : store.getRec("decisions", id);
2430
+ if (!d)
2431
+ return fail(`decision "${id}" not found${opts.private ? " in the private overlay" : ""}`);
2432
+ const repaired = repairDecisionReference(d, opts.from, opts.to);
2433
+ if (!repaired)
2434
+ return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
2435
+ store.putWhereItLives("decisions", repaired.decision);
2436
+ store.reindex();
2437
+ console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
2438
+ console.log(` ${opts.from} → ${opts.to}`);
2439
+ }
2440
+ finally {
2441
+ store.close();
2442
+ }
2443
+ });
2241
2444
  // ---- compact (bound Hunch growth) -----------------------------------------
2242
2445
  program
2243
2446
  .command("compact")
@@ -2467,13 +2670,22 @@ function realpathNorm(p) {
2467
2670
  function toRepoRel(root, abs) {
2468
2671
  return relative(realpathNorm(root), realpathNorm(abs)).split("\\").join("/");
2469
2672
  }
2470
- function emitContext(event, text) {
2471
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
2673
+ function emitContext(provider, event, text) {
2674
+ const output = contextHookOutput(provider, event, text);
2675
+ if (output)
2676
+ process.stdout.write(JSON.stringify(output));
2472
2677
  }
2473
- function emitDeny(reason) {
2474
- process.stdout.write(JSON.stringify({
2475
- hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
2476
- }));
2678
+ function emitDeny(provider, reason) {
2679
+ const result = denyHookOutput(provider, reason);
2680
+ if (result.output)
2681
+ process.stdout.write(JSON.stringify(result.output));
2682
+ if (result.stderr)
2683
+ process.stderr.write(`${result.stderr}\n`);
2684
+ if (result.exitCode !== undefined)
2685
+ process.exitCode = result.exitCode;
2686
+ }
2687
+ function emitStop(provider, reason) {
2688
+ process.stdout.write(JSON.stringify(stopHookOutput(provider, reason)));
2477
2689
  }
2478
2690
  program.parseAsync().catch((e) => {
2479
2691
  try {