@davesheffer/hunch 1.4.1 → 1.5.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";
@@ -52,6 +52,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
52
52
  import { injectionMode } from "../core/hookcache.js";
53
53
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
54
54
  import { draftDuplicateOf } from "../core/dupdetect.js";
55
+ import { planAutoReview, planMutations } from "../core/autoreview.js";
55
56
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
56
57
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
57
58
  import { computeDrift } from "../core/drift.js";
@@ -68,6 +69,7 @@ import { mergeHunchJson } from "../store/merge.js";
68
69
  import { movePublicMemoryToPrivate } from "../store/privateMigrate.js";
69
70
  import { ENTITY_KINDS } from "../core/types.js";
70
71
  import { planCompaction } from "../store/compact.js";
72
+ import { repairDecisionReference } from "../core/refrepair.js";
71
73
  import { resolveInvocation } from "./invocation.js";
72
74
  const program = new Command();
73
75
  program.name("hunch").description("Hunch — an Engineering Memory OS: a git-native reasoning graph for your codebase.").version(HUNCH_VERSION);
@@ -138,7 +140,7 @@ program
138
140
  }
139
141
  if (isGitRepo(root)) {
140
142
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
141
- const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
143
+ const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit, localOnly: syncToOverlay });
142
144
  console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
143
145
  const m = installMergeDriver(root, inv.shell);
144
146
  console.log(` ✓ team merge driver ${m.action}`);
@@ -320,7 +322,16 @@ program
320
322
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
321
323
  }
322
324
  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) });
325
+ const r = await syncCommit(store, root, sha ?? headSha(root), {
326
+ force: opts.force,
327
+ private: toOverlay,
328
+ // A split-private overlay is sensitive/local by definition. A shared store
329
+ // is an explicit team policy and may keep its configured synthesis provider.
330
+ localOnly: toOverlay && store.mode === "private",
331
+ deep: opts.deep,
332
+ verify: opts.verify,
333
+ samples: parseSamples(opts.samples),
334
+ });
324
335
  if (r.status === "written") {
325
336
  store.reindex();
326
337
  // Don't rewrite grounding from the hook — it would dirty the working tree on
@@ -455,7 +466,7 @@ function configureOverlay(dir, opts, mode) {
455
466
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
456
467
  let hookNote = "";
457
468
  if (opts.hook && isGitRepo(root)) {
458
- const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit });
469
+ const h = installPostCommitHook(root, inv.shell, { private: true, commit: opts.autoCommit, localOnly: mode === "private" });
459
470
  hookNote = ` ✓ post-commit hook ${h.action} — captured decisions route here${opts.autoCommit ? " (auto-commit+push on)" : ""}\n`;
460
471
  }
461
472
  // 5) one-time migration: MOVE existing public memory INTO the overlay, then make
@@ -1047,16 +1058,24 @@ program
1047
1058
  .description("Capture a Bug from a failing test (symptom + suspect ranking).")
1048
1059
  .requiredOption("--test <id>", "failing test id/name")
1049
1060
  .requiredOption("--message <msg>", "failure message / stack")
1061
+ .option("--private", "keep the bug and its failure text in the private overlay; uses deterministic local synthesis")
1050
1062
  .action(async (opts) => {
1051
1063
  const { store, root } = storeFor();
1064
+ if (opts.private && !store.hasPrivate) {
1065
+ store.close();
1066
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1067
+ }
1052
1068
  store.json.ensureDirs();
1053
- const r = await recordFailure(store, root, { test: opts.test, message: opts.message });
1069
+ const r = await recordFailure(store, root, { test: opts.test, message: opts.message }, { private: opts.private });
1054
1070
  store.reindex();
1055
- console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"`);
1071
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${r.bug.id}`);
1072
+ console.log(`✓ recorded bug ${r.bug.id} via ${r.provider}: "${r.bug.title}"${opts.private ? " [private overlay; local-only synthesis]" : ""}`);
1056
1073
  if (r.bug.lineage.recurrence_of)
1057
1074
  console.log(` ↳ recurrence of ${r.bug.lineage.recurrence_of}`);
1058
1075
  if (r.constraint)
1059
1076
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
1077
+ if (flush === "pushed")
1078
+ console.log(" ↳ private memory committed + pushed");
1060
1079
  store.close();
1061
1080
  });
1062
1081
  // ---- record-constraint (human-authored invariant) -------------------------
@@ -1073,11 +1092,16 @@ program
1073
1092
  .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
1093
  .option("--forbid-symbol <names>", "comma-sep identifier names that break the rule")
1075
1094
  .option("--match <regex>", "textual line regex (lint-grade last resort; prefer --forbid-dep/--forbid-symbol)")
1095
+ .option("--private", "write the invariant into the private overlay (local enforcement only; never included in public CI output)")
1076
1096
  .action((statement, opts) => {
1077
1097
  const SEV = ["advisory", "warning", "blocking"];
1078
1098
  if (!SEV.includes(opts.severity))
1079
1099
  return fail(`--severity must be one of: ${SEV.join(", ")}`);
1080
1100
  const { store, root } = storeFor();
1101
+ if (opts.private && !store.hasPrivate) {
1102
+ store.close();
1103
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1104
+ }
1081
1105
  store.json.ensureDirs();
1082
1106
  const scope = opts.scope.split(",").map((s) => toPosixTarget(s.trim())).filter(Boolean);
1083
1107
  const csv = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : []);
@@ -1107,10 +1131,14 @@ program
1107
1131
  valid_from: new Date().toISOString(),
1108
1132
  valid_to: null,
1109
1133
  provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
1110
- });
1134
+ }, opts.private);
1111
1135
  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"})`);
1136
+ // Public grounding is a publishable artifact. Private rules stay local and
1137
+ // are surfaced by local checks/MCP, never copied into committed agent docs.
1138
+ if (store.captureHome(!!opts.private) === "public")
1139
+ refreshExistingGrounding(root, store);
1140
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${c.id}`);
1141
+ console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})${opts.private ? " [private overlay]" : ""}`);
1114
1142
  if (derived && c.forbids?.deps.length)
1115
1143
  console.log(` ↳ matcher: forbids import of ${c.forbids.deps.join(", ")} (precise, immune to staleness)`);
1116
1144
  if (c.severity === "blocking" && !effectiveForbids(c)) {
@@ -1119,6 +1147,8 @@ program
1119
1147
  console.log(` ⚠ scope-only — this will downgrade to advisory once a file in scope is changed after today.`);
1120
1148
  console.log(` To block the actual violation across the file's life, add --forbid-dep <pkg> (or --forbid-symbol / --match).`);
1121
1149
  }
1150
+ if (flush === "pushed")
1151
+ console.log(" ↳ private memory committed + pushed");
1122
1152
  store.close();
1123
1153
  });
1124
1154
  // ---- test (failure-learning loop) -----------------------------------------
@@ -1127,8 +1157,13 @@ program
1127
1157
  .description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
1128
1158
  .argument("[cmd...]", "test command to run (default: `npm test`)")
1129
1159
  .option("--dry-run", "show what would be captured without writing")
1160
+ .option("--private", "keep captured test failures in the private overlay and use deterministic local synthesis")
1130
1161
  .action(async (cmd, opts) => {
1131
1162
  const { store, root } = storeFor();
1163
+ if (opts.private && !store.hasPrivate) {
1164
+ store.close();
1165
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1166
+ }
1132
1167
  store.json.ensureDirs();
1133
1168
  // Run as a shell string (not argv) so the npm/test-runner shim resolves on
1134
1169
  // Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
@@ -1155,7 +1190,7 @@ program
1155
1190
  store.close();
1156
1191
  return;
1157
1192
  }
1158
- const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
1193
+ const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output, private: opts.private });
1159
1194
  for (const { bug, constraint } of cap.results) {
1160
1195
  if (constraint)
1161
1196
  console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
@@ -1165,6 +1200,8 @@ program
1165
1200
  for (const b of cap.fixed)
1166
1201
  console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
1167
1202
  store.reindex();
1203
+ if (cap.results.length || cap.fixed.length)
1204
+ flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture test results`);
1168
1205
  store.close();
1169
1206
  const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
1170
1207
  const promoted = cap.results.filter((r) => r.constraint).length;
@@ -1229,6 +1266,7 @@ program
1229
1266
  .command("check")
1230
1267
  .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
1268
  .option("--staged", "check git staged files (default)")
1269
+ .option("--working", "check all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
1232
1270
  .option("--commit <sha>", "check a specific commit's files")
1233
1271
  .option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
1234
1272
  .option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
@@ -1236,9 +1274,9 @@ program
1236
1274
  .option("--blast", "also print the dependency blast radius of the changed files")
1237
1275
  .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
1276
  .action((opts) => {
1239
- const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
1277
+ const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged", opts.working && "--working"].filter(Boolean);
1240
1278
  if (sources.length > 1)
1241
- return fail(`pick one of --staged / --commit / --base (got ${sources.join(", ")})`);
1279
+ return fail(`pick one of --staged / --working / --commit / --base (got ${sources.join(", ")})`);
1242
1280
  const markdown = opts.format === "markdown";
1243
1281
  const emptyReport = { fileCount: 0, strict: !!opts.strict, direct: [], near: [], regressions: [], vetoes: [], redundant: [], strictBlockers: 0, regBlocking: 0, vetoBlocking: 0 };
1244
1282
  const { store, root } = storeFor();
@@ -1251,7 +1289,8 @@ program
1251
1289
  store.reindex(); // blast radius walks the edge graph — make the index current
1252
1290
  const files = opts.commit ? commitFiles(opts.commit, root)
1253
1291
  : opts.base ? rangeFiles(opts.base, root)
1254
- : stagedFiles(root);
1292
+ : opts.working ? workingFiles(root)
1293
+ : stagedFiles(root);
1255
1294
  if (!files.length) {
1256
1295
  console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
1257
1296
  store.close();
@@ -1261,7 +1300,7 @@ program
1261
1300
  // code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
1262
1301
  // hardened strict gate + causal `why` citations — all assembled by the shared
1263
1302
  // 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);
1303
+ const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
1265
1304
  const report = store.buildCheckReport(files, diff, {
1266
1305
  strict: !!opts.strict,
1267
1306
  lastChange: (f) => lastChangeDate(f, root),
@@ -1774,29 +1813,40 @@ program
1774
1813
  .option("--accept-verified", "batch-accept every Critic-verified, well-grounded draft (>= --min-grounded)")
1775
1814
  .option("--reject-duplicates", "batch-reject drafts that near-duplicate an accepted record (deterministic term+file similarity — hygiene, not judgment)")
1776
1815
  .option("--min-grounded <n>", "grounded-ness threshold for the ready group / --accept-verified", String(READY_MIN_GROUNDED))
1816
+ .option("--private", "include local private/shared-overlay drafts; terminal output may contain private memory")
1777
1817
  .action((opts) => {
1778
1818
  const { store, root } = storeFor();
1779
1819
  const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
1820
+ if (opts.private && !store.hasPrivate) {
1821
+ store.close();
1822
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1823
+ }
1824
+ const decisions = () => opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
1825
+ let publicGroundingChanged = false;
1780
1826
  if (opts.accept) {
1781
- const d = store.json.get("decisions", opts.accept);
1827
+ const d = opts.private ? store.getRec("decisions", opts.accept) : store.json.get("decisions", opts.accept);
1782
1828
  if (!d) {
1783
1829
  store.close();
1784
1830
  return fail(`decision ${opts.accept} not found`);
1785
1831
  }
1832
+ const inPrivate = !!store.getPrivateRec("decisions", d.id);
1786
1833
  const { source, armed } = acceptDecision(store, d);
1787
1834
  store.reindex();
1788
- refreshExistingGrounding(root, store); // confirming a rule must reach EVERY assistant's grounding
1835
+ if (!inPrivate) {
1836
+ refreshExistingGrounding(root, store);
1837
+ publicGroundingChanged = true;
1838
+ }
1789
1839
  console.log(`✓ accepted ${opts.accept} (now ${source}, confidence 0.95${armed ? `, ${armed} tripwire(s) now blocking` : ""})`);
1790
1840
  }
1791
1841
  else if (opts.reject) {
1792
- const ok2 = store.json.delete("decisions", opts.reject);
1842
+ const ok2 = opts.private ? store.deleteWhereItLives("decisions", opts.reject) : store.json.delete("decisions", opts.reject);
1793
1843
  store.reindex();
1794
1844
  console.log(ok2 ? `✓ rejected and removed ${opts.reject}` : `decision ${opts.reject} not found`);
1795
1845
  }
1796
1846
  else if (opts.rejectDuplicates) {
1797
1847
  // Deterministic hygiene, not a trust decision (dec_a466655539 stays intact):
1798
1848
  // only drafts, only against ACCEPTED records, conservative threshold.
1799
- const all = store.json.loadAll("decisions");
1849
+ const all = decisions();
1800
1850
  const drafts = all.filter((d) => d.status === "proposed" && !d.provenance.source.includes("human_confirmed"));
1801
1851
  const dupes = drafts
1802
1852
  .map((d) => ({ d, m: draftDuplicateOf(d, all) }))
@@ -1807,7 +1857,7 @@ program
1807
1857
  else {
1808
1858
  let removed = 0;
1809
1859
  for (const { d, m } of dupes) {
1810
- if (store.json.delete("decisions", d.id))
1860
+ if ((opts.private ? store.deleteWhereItLives("decisions", d.id) : store.json.delete("decisions", d.id)))
1811
1861
  removed++;
1812
1862
  console.log(` ✗ ${d.id} — "${d.title}"\n duplicate of ${m.of.id} — "${m.of.title}" (${Math.round(m.score * 100)}%)`);
1813
1863
  }
@@ -1818,24 +1868,28 @@ program
1818
1868
  else if (opts.acceptVerified) {
1819
1869
  // Batch path: only Critic-verified, well-grounded drafts qualify — still the
1820
1870
  // 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");
1871
+ const proposed = decisions().filter((d) => d.status === "proposed");
1822
1872
  const { ready } = partitionReview(proposed, minGrounded);
1823
1873
  if (!ready.length) {
1824
1874
  console.log(`✓ No Critic-verified drafts at grounded ≥ ${minGrounded} to batch-accept.`);
1825
1875
  }
1826
1876
  else {
1827
1877
  let armedTotal = 0;
1828
- for (const it of ready)
1878
+ for (const it of ready) {
1879
+ if (!store.getPrivateRec("decisions", it.d.id))
1880
+ publicGroundingChanged = true;
1829
1881
  armedTotal += acceptDecision(store, it.d).armed;
1882
+ }
1830
1883
  store.reindex();
1831
- refreshExistingGrounding(root, store); // batch-confirm must reach EVERY assistant's grounding
1884
+ if (publicGroundingChanged)
1885
+ refreshExistingGrounding(root, store); // committed grounding stays public-only
1832
1886
  console.log(`✓ accepted ${ready.length} verified draft(s); ${armedTotal} tripwire(s) now blocking.`);
1833
1887
  for (const it of ready)
1834
1888
  console.log(` ${it.d.id} grounded=${it.synth.grounded ?? "?"} ${it.d.title}`);
1835
1889
  }
1836
1890
  }
1837
1891
  else {
1838
- const drafts = store.json.loadAll("decisions").filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1892
+ const drafts = decisions().filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1839
1893
  const { ready, scrutiny } = partitionReview(drafts, minGrounded);
1840
1894
  if (!ready.length && !scrutiny.length) {
1841
1895
  console.log("✓ No low-confidence drafts to review.");
@@ -1849,7 +1903,7 @@ program
1849
1903
  }
1850
1904
  if (scrutiny.length) {
1851
1905
  console.log(`⚠ ${scrutiny.length} need scrutiny — unverified / low-grounded (lowest confidence first):\n`);
1852
- const all = store.json.loadAll("decisions");
1906
+ const all = decisions();
1853
1907
  let dupCount = 0;
1854
1908
  for (const it of scrutiny) {
1855
1909
  printReviewItem(it);
@@ -1867,6 +1921,109 @@ program
1867
1921
  }
1868
1922
  store.close();
1869
1923
  });
1924
+ // ---- auto-review (harness-driven triage) ----------------------------------
1925
+ /** One line per plan entry. */
1926
+ function printAutoEntry(e) {
1927
+ console.log(` ${e.d.id} ${e.d.title.slice(0, 66)}\n ${dim(e.reason)}`);
1928
+ }
1929
+ program
1930
+ .command("auto-review")
1931
+ .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.")
1932
+ .option("--apply", "execute the plan (accept/delete). Without it, print the plan and change nothing.")
1933
+ .option("--min-grounded <n>", "grounded-ness threshold for the auto-accept gate", String(READY_MIN_GROUNDED))
1934
+ .option("--min-reject-confidence <n>", "minimum harness confidence to DELETE an irrelevant draft (else kept for a human)", "0.7")
1935
+ .option("--no-llm", "skip the harness judgment (dedup + grounding only — no relevance deletion)")
1936
+ .option("--private", "include local private/shared-overlay drafts; private drafts are never sent to an LLM judge")
1937
+ .action(async (opts) => {
1938
+ const { store, root } = storeFor();
1939
+ try {
1940
+ if (opts.private && !store.hasPrivate)
1941
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
1942
+ const minGrounded = Number.isFinite(Number(opts.minGrounded)) ? Number(opts.minGrounded) : READY_MIN_GROUNDED;
1943
+ const minRejectConfidence = Number.isFinite(Number(opts.minRejectConfidence)) ? Number(opts.minRejectConfidence) : 0.7;
1944
+ const all = opts.private ? store.recs("decisions") : store.json.loadAll("decisions");
1945
+ // Same draft set `hunch review` / `hunch status` triage.
1946
+ const drafts = all.filter((d) => d.status === "proposed" || d.provenance.confidence < 0.6);
1947
+ if (!drafts.length) {
1948
+ console.log("✓ No drafts to auto-review.");
1949
+ return;
1950
+ }
1951
+ // Delegate relevance to the harness (subscription CLI) — feature-detected,
1952
+ // and any per-draft failure degrades to "not judged" (kept for a human).
1953
+ const verdicts = new Map();
1954
+ if (opts.llm !== false && !opts.private) {
1955
+ const provider = await selectProvider();
1956
+ if (provider.judgeDraft) {
1957
+ // The candidate pool for duplicate_of / restatement: the LIVE, vouched records.
1958
+ const existing = all
1959
+ .filter((d) => d.provenance.source.includes("human_confirmed") && d.status !== "superseded" && d.status !== "rejected")
1960
+ .map((d) => ({ id: d.id, title: d.title, decision: d.decision }));
1961
+ console.log(`Judging ${drafts.length} draft(s) via ${provider.name} (subscription)…`);
1962
+ for (const d of drafts) {
1963
+ try {
1964
+ verdicts.set(d.id, await provider.judgeDraft(d, existing.filter((e) => e.id !== d.id)));
1965
+ }
1966
+ catch {
1967
+ /* transient / unparseable — leave unjudged, planner keeps it for a human */
1968
+ }
1969
+ }
1970
+ }
1971
+ else {
1972
+ console.log(dim("No subscription CLI available — relevance judgment skipped (dedup + grounding only)."));
1973
+ }
1974
+ }
1975
+ else if (opts.private && opts.llm !== false) {
1976
+ console.log(dim("Private review stays local — harness judgment skipped (dedup + grounding only)."));
1977
+ }
1978
+ const plan = planAutoReview(drafts, all, verdicts, { minGrounded, minRejectConfidence });
1979
+ printAutoReviewPlan(plan);
1980
+ if (!opts.apply) {
1981
+ const n = planMutations(plan);
1982
+ console.log(`\n${dim(`Dry run — nothing changed. Re-run with --apply to ${n ? `apply ${n} change(s)` : "confirm (no changes)"}.`)}`);
1983
+ return;
1984
+ }
1985
+ // Apply: accept the verified+relevant, delete duplicates + irrelevant.
1986
+ let accepted = 0, deleted = 0, armedTotal = 0, publicAccepted = false;
1987
+ for (const e of plan.accept) {
1988
+ if (!store.getPrivateRec("decisions", e.d.id))
1989
+ publicAccepted = true;
1990
+ armedTotal += acceptDecision(store, e.d).armed;
1991
+ accepted++;
1992
+ }
1993
+ for (const e of [...plan.rejectDuplicate, ...plan.rejectIrrelevant]) {
1994
+ if ((opts.private ? store.deleteWhereItLives("decisions", e.d.id) : store.json.delete("decisions", e.d.id)))
1995
+ deleted++;
1996
+ }
1997
+ if (accepted || deleted) {
1998
+ store.reindex();
1999
+ if (publicAccepted)
2000
+ refreshExistingGrounding(root, store); // committed grounding stays public-only
2001
+ }
2002
+ console.log(`\n✓ auto-review applied: ${accepted} accepted${armedTotal ? ` (${armedTotal} tripwire(s) now blocking)` : ""}, ${deleted} deleted, ${plan.keep.length} kept for review.`);
2003
+ }
2004
+ finally {
2005
+ store.close();
2006
+ }
2007
+ });
2008
+ /** Print the four buckets of an auto-review plan (skipping empty ones). */
2009
+ function printAutoReviewPlan(plan) {
2010
+ if (plan.accept.length) {
2011
+ console.log(`\n✓ ACCEPT — verified, grounded, harness-relevant (${plan.accept.length}):`);
2012
+ plan.accept.forEach(printAutoEntry);
2013
+ }
2014
+ if (plan.rejectDuplicate.length) {
2015
+ console.log(`\n✗ DELETE (duplicate) — restates an accepted record (${plan.rejectDuplicate.length}):`);
2016
+ plan.rejectDuplicate.forEach(printAutoEntry);
2017
+ }
2018
+ if (plan.rejectIrrelevant.length) {
2019
+ console.log(`\n✗ DELETE (irrelevant) — harness judged not worth keeping (${plan.rejectIrrelevant.length}):`);
2020
+ plan.rejectIrrelevant.forEach(printAutoEntry);
2021
+ }
2022
+ if (plan.keep.length) {
2023
+ console.log(`\n⏳ KEEP for human review (${plan.keep.length}):`);
2024
+ plan.keep.forEach(printAutoEntry);
2025
+ }
2026
+ }
1870
2027
  // ---- mcp ------------------------------------------------------------------
1871
2028
  program
1872
2029
  .command("mcp")
@@ -2014,23 +2171,24 @@ program
2014
2171
  .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
2172
  .argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
2016
2173
  .option("--commit <sha>", "impact of a single commit")
2174
+ .option("--working", "impact all working-tree edits vs HEAD (staged, unstaged, and untracked files)")
2017
2175
  .action((base, opts) => {
2018
2176
  const { store, root } = storeFor();
2019
2177
  try {
2020
- if (base && opts.commit)
2021
- return fail("Pass at most one of [base] / --commit.");
2178
+ if ((base && opts.commit) || (opts.working && (base || opts.commit)))
2179
+ return fail("Pass exactly one of [base] / --commit / --working (or omit all for staged changes).");
2022
2180
  if (base && !revExists(base, root))
2023
2181
  return fail(`base ref "${base}" does not resolve.`);
2024
2182
  if (opts.commit && !revExists(opts.commit, root))
2025
2183
  return fail(`commit "${opts.commit}" does not resolve.`);
2026
2184
  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";
2185
+ const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : opts.working ? workingFiles(root) : stagedFiles(root);
2186
+ const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : opts.working ? "working changes" : "staged changes";
2029
2187
  if (!files.length) {
2030
2188
  console.log(`No changed files in ${scope}.`);
2031
2189
  return;
2032
2190
  }
2033
- const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
2191
+ const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
2034
2192
  console.log(renderImpact(store.prImpact(files, diff), scope));
2035
2193
  }
2036
2194
  finally {
@@ -2238,6 +2396,38 @@ program
2238
2396
  store.close();
2239
2397
  }
2240
2398
  });
2399
+ // ---- repair-ref (atomic decision reference correction) --------------------
2400
+ program
2401
+ .command("repair-ref")
2402
+ .description("Atomically repair one exact file reference in a decision's scope and provenance evidence (never changes the decision itself).")
2403
+ .argument("<decision>", "decision id containing the stale reference")
2404
+ .requiredOption("--from <path>", "exact stale path to replace")
2405
+ .requiredOption("--to <path>", "exact current path (use private:<path> for a private-overlay file)")
2406
+ .option("--private", "require the decision to be in the configured private overlay")
2407
+ .action((id, opts) => {
2408
+ const { store } = storeFor();
2409
+ try {
2410
+ if (opts.from === opts.to)
2411
+ return fail("--from and --to must be different paths");
2412
+ if (opts.private && !store.hasPrivate)
2413
+ return fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
2414
+ // `getRec` is deliberately overlay-first. An explicit --private prevents a
2415
+ // same-id public record from being silently amended instead of private memory.
2416
+ const d = opts.private ? store.getPrivateRec("decisions", id) : store.getRec("decisions", id);
2417
+ if (!d)
2418
+ return fail(`decision "${id}" not found${opts.private ? " in the private overlay" : ""}`);
2419
+ const repaired = repairDecisionReference(d, opts.from, opts.to);
2420
+ if (!repaired)
2421
+ return fail(`decision "${id}" does not contain the exact reference "${opts.from}"`);
2422
+ store.putWhereItLives("decisions", repaired.decision);
2423
+ store.reindex();
2424
+ console.log(`✓ repaired ${id}: ${repaired.relatedFiles} related file reference(s) + ${repaired.evidence} provenance evidence reference(s).`);
2425
+ console.log(` ${opts.from} → ${opts.to}`);
2426
+ }
2427
+ finally {
2428
+ store.close();
2429
+ }
2430
+ });
2241
2431
  // ---- compact (bound Hunch growth) -----------------------------------------
2242
2432
  program
2243
2433
  .command("compact")
@@ -0,0 +1,52 @@
1
+ import { draftDuplicateOf } from "./dupdetect.js";
2
+ import { parseSynth, isReady, READY_MIN_GROUNDED } from "./reviewqueue.js";
3
+ const DEFAULT_MIN_REJECT_CONFIDENCE = 0.7;
4
+ /** Build the plan. `verdicts` maps draft id → harness verdict (absent → the draft
5
+ * was not judged, e.g. no CLI available; it can still be dup-rejected or kept). */
6
+ export function planAutoReview(drafts, allDecisions, verdicts, cfg = {}) {
7
+ const minGrounded = cfg.minGrounded ?? READY_MIN_GROUNDED;
8
+ const minReject = cfg.minRejectConfidence ?? DEFAULT_MIN_REJECT_CONFIDENCE;
9
+ const plan = { accept: [], rejectDuplicate: [], rejectIrrelevant: [], keep: [] };
10
+ for (const d of drafts) {
11
+ const verdict = verdicts.get(d.id);
12
+ const synth = parseSynth(d.provenance?.evidence);
13
+ const grounded = synth.grounded;
14
+ const base = { d, verdict, grounded };
15
+ // 1) Duplicate — deterministic match against accepted records, or the harness
16
+ // naming an existing decision. Deterministic wins first (cheapest, surest).
17
+ const detDup = draftDuplicateOf(d, allDecisions);
18
+ if (detDup) {
19
+ plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `near-duplicate of ${detDup.of.id} "${detDup.of.title}" (${Math.round(detDup.score * 100)}%)` });
20
+ continue;
21
+ }
22
+ if (verdict?.duplicate_of && verdict.duplicate_of !== d.id && allDecisions.some((x) => x.id === verdict.duplicate_of)) {
23
+ plan.rejectDuplicate.push({ ...base, action: "rejectDuplicate", reason: `harness: restates ${verdict.duplicate_of} — ${verdict.reason}` });
24
+ continue;
25
+ }
26
+ // 2) Confidently-irrelevant — delete only on a strong harness "no".
27
+ if (verdict && !verdict.relevant && verdict.confidence >= minReject) {
28
+ plan.rejectIrrelevant.push({ ...base, action: "rejectIrrelevant", reason: `harness: not relevant (conf ${verdict.confidence}) — ${verdict.reason}` });
29
+ continue;
30
+ }
31
+ // 3) Accept — ONLY when the Critic verified + grounded it (isReady) AND the
32
+ // harness judged it relevant. The harness can VETO an accept, never create
33
+ // one on its own (dec_a466655539: the human vouch / Critic gate is the floor).
34
+ const ready = isReady(d, synth, minGrounded);
35
+ if (ready && verdict?.relevant) {
36
+ plan.accept.push({ ...base, action: "accept", reason: `verified + grounded ${grounded ?? "?"} ≥ ${minGrounded}, harness-relevant — ${verdict.reason}` });
37
+ continue;
38
+ }
39
+ // 4) Keep for a human — the safe default (unverified, ungrounded, unjudged, or
40
+ // a low-confidence irrelevant call).
41
+ const why = !verdict ? "not judged (no harness)"
42
+ : !ready ? (verdict.relevant ? "relevant but not Critic-verified/grounded — needs human confirm" : `irrelevant but low confidence (${verdict.confidence})`)
43
+ : "kept";
44
+ plan.keep.push({ ...base, action: "keep", reason: why });
45
+ }
46
+ return plan;
47
+ }
48
+ /** Total drafts the plan would mutate (accept + both delete buckets). */
49
+ export function planMutations(plan) {
50
+ return plan.accept.length + plan.rejectDuplicate.length + plan.rejectIrrelevant.length;
51
+ }
52
+ //# sourceMappingURL=autoreview.js.map
@@ -13,7 +13,7 @@
13
13
  * `hunch wiki --heal`, never a gate.
14
14
  */
15
15
  import { existsSync, readFileSync } from "node:fs";
16
- import { join } from "node:path";
16
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
17
17
  import { toPosixTarget } from "./paths.js";
18
18
  import { currentForTopic, isLive } from "./topics.js";
19
19
  import { parseDocAnchors } from "./docanchors.js";
@@ -36,7 +36,7 @@ export function computeDrift(store, root) {
36
36
  for (const f of d.related_files ?? []) {
37
37
  if (!f || f.includes("*"))
38
38
  continue; // skip globs / empties
39
- if (!existsSync(join(root, f))) {
39
+ if (!referenceExists(store, root, d.id, f)) {
40
40
  findings.push({ kind: "dead-ref", id: d.id, detail: `references missing file "${f}"` });
41
41
  }
42
42
  }
@@ -66,7 +66,7 @@ export function computeDrift(store, root) {
66
66
  for (const f of d.related_files ?? []) {
67
67
  if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
68
68
  continue;
69
- if (!existsSync(join(root, f)))
69
+ if (!referenceExists(store, root, d.id, f))
70
70
  continue; // missing file is history → dead-ref's job
71
71
  findings.push({
72
72
  kind: "anchor-stale",
@@ -120,6 +120,28 @@ export function computeDrift(store, root) {
120
120
  findings.push(...computeWikiDrift(store, root));
121
121
  return { findings };
122
122
  }
123
+ /** Resolve a decision file reference without making private-memory paths depend on
124
+ * the current machine's overlay location. Normal references are code-repo-relative.
125
+ * A `private:<path>` reference is valid only when the decision itself is in the
126
+ * private overlay and resolves from that overlay repo's root. This lets a private
127
+ * decision cite private docs while preventing a public record from silently
128
+ * depending on unsharable local files. */
129
+ function referenceExists(store, root, decisionId, ref) {
130
+ const prefix = "private:";
131
+ if (!ref.startsWith(prefix))
132
+ return existsSync(join(root, ref));
133
+ const privatePath = ref.slice(prefix.length);
134
+ if (!privatePath || isAbsolute(privatePath) || !store.privateDir || !store.getPrivateRec("decisions", decisionId))
135
+ return false;
136
+ const privateRoot = dirname(store.privateDir);
137
+ const candidate = resolve(privateRoot, privatePath);
138
+ // A private-scoped reference is an overlay-repo-relative path, not an escape
139
+ // hatch into arbitrary local files.
140
+ const rel = relative(privateRoot, candidate);
141
+ if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`) || isAbsolute(rel))
142
+ return false;
143
+ return existsSync(candidate);
144
+ }
123
145
  function safeRead(path) {
124
146
  try {
125
147
  return readFileSync(path, "utf8");
@@ -0,0 +1,33 @@
1
+ function replaceExact(values, from, to) {
2
+ let changed = 0;
3
+ const replaced = values.map((value) => {
4
+ if (value !== from)
5
+ return value;
6
+ changed++;
7
+ return to;
8
+ });
9
+ // A decision may already cite the destination. Keep the reference list a set
10
+ // after the repair so a correction cannot create duplicate scope/evidence.
11
+ return { values: [...new Set(replaced)], changed };
12
+ }
13
+ /**
14
+ * Return a corrected copy of a decision, or `null` when the source reference is
15
+ * not present. The decision's semantic content and verification timestamp are
16
+ * intentionally preserved: this repairs a locator, it does not re-approve intent.
17
+ */
18
+ export function repairDecisionReference(decision, from, to) {
19
+ const files = replaceExact(decision.related_files, from, to);
20
+ const evidence = replaceExact(decision.provenance.evidence, from, to);
21
+ if (!files.changed && !evidence.changed)
22
+ return null;
23
+ return {
24
+ decision: {
25
+ ...decision,
26
+ related_files: files.values,
27
+ provenance: { ...decision.provenance, evidence: evidence.values },
28
+ },
29
+ relatedFiles: files.changed,
30
+ evidence: evidence.changed,
31
+ };
32
+ }
33
+ //# sourceMappingURL=refrepair.js.map