@davesheffer/hunch 1.4.2 → 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/README.md CHANGED
@@ -1,44 +1,136 @@
1
- # 🧠 Hunch — Architectural Conformance for AI code
1
+ # Hunch
2
+
3
+ ## Your AI can write code. Hunch makes it remember the consequences.
2
4
 
3
5
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
4
6
  [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
5
7
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
6
8
 
7
- > **Your repo remembers *what* changed. Hunch makes it remember *why*** and keeps every
8
- > AI coding session consistent with the decisions, trade-offs, and bugs you already paid for.
9
- > Local-first, git-native, works with Claude Code, Cursor, Copilot, Windsurf & Codex.
9
+ **Hunch is engineering memory and a deterministic Change Gate for AI-assisted codebases.**
10
+ It captures the decisions, rejected approaches, and bug history behind your code—then gives every
11
+ assistant the same evidence before it changes anything.
10
12
 
11
- ## Install
13
+ Not another chat history. Not a wiki your team must remember to update. A git-native reasoning
14
+ graph that turns “we already learned this the hard way” into something your tools can actually use.
12
15
 
13
16
  ```bash
14
17
  npm i -g @davesheffer/hunch
15
- cd your-repo && hunch init # 2 minutes; advisory by default — nothing blocks until you say so
16
- hunch backfill --since 90d # optional: seed memory from recent git history
18
+ cd your-repo
19
+ hunch init
17
20
  ```
18
21
 
19
- Reload your assistant and ask: *"why is X built this way?"* — it answers from the graph, with receipts.
22
+ Then ask any connected assistant: **“Why is this built this way?”**
23
+
24
+ ## The problem Hunch solves
25
+
26
+ AI can read your current code. It does not know the decision you made six months ago, the incident
27
+ that made it necessary, or the approach you deliberately rejected yesterday.
28
+
29
+ That gap is where architectural drift starts:
30
+
31
+ | Without Hunch | With Hunch |
32
+ | --- | --- |
33
+ | A refactor passes tests but bypasses a hard-won service boundary. | The change is checked against the decision, its constraint, and the incident behind it. |
34
+ | A new coding session starts from scratch. | Claude Code, Cursor, Copilot, Windsurf, and Codex retrieve the same project memory over MCP. |
35
+ | A correction disappears into a chat transcript. | “Never do that again” becomes a scoped, auditable guard. |
36
+ | Code review sees a diff, not the reason behind it. | Change Gate produces a PASS / WARN / BLOCK receipt with causal evidence. |
37
+
38
+ ## What you get in five minutes
39
+
40
+ ```bash
41
+ hunch init # index code + wire supported assistants
42
+ hunch backfill --since 90d # optional: seed memory from recent history
43
+ hunch check --working --strict # review the whole working tree before a commit
44
+ ```
45
+
46
+ Hunch creates a local graph of:
47
+
48
+ - **Decisions** — what was chosen, why, and what alternatives were rejected.
49
+ - **Constraints** — the invariants a change must not violate.
50
+ - **Bug lineage** — the root cause behind fixes, recurrences, and regression guards.
51
+ - **Architecture** — symbols, components, dependencies, blast radius, and fragility.
52
+
53
+ It then puts that context where work happens: MCP tools, the CLI, a VS Code Change Gate, git hooks,
54
+ and an optional pull-request guard.
55
+
56
+ ## One graph. Every assistant. No lock-in.
57
+
58
+ Hunch is agent-agnostic by design. It scaffolds MCP and grounding for Claude Code, Cursor, VS Code
59
+ / Copilot, Windsurf, Codex, and any agent that can read `AGENTS.md`.
60
+
61
+ Your memory is plain JSON that you own. Hunch adds a SQLite index only as a rebuildable derived
62
+ layer—your decisions never disappear into a proprietary hosted memory system.
63
+
64
+ ```text
65
+ Claude Code ─┐
66
+ Cursor ├── MCP ──> .hunch/ reasoning graph ──> deterministic checks
67
+ Copilot ┤
68
+ Codex ┤
69
+ Windsurf ─┘
70
+ ```
71
+
72
+ ## The Change Gate: review intent, not just code
73
+
74
+ Before you commit, ask Hunch to review staged files, your working tree, or a branch against its
75
+ base. It returns a receipt your human reviewer or any coding agent can understand:
76
+
77
+ ```text
78
+ BLOCK src/payments/charge.ts
79
+
80
+ [blocking] Controllers must not reach the database directly
81
+ why: dec_service_boundary → bug_n_plus_one_2025
82
+ evidence: charge() now imports dbQuery
83
+ ```
84
+
85
+ The gate is deterministic: no prompt-quality lottery and no model call in the enforcement path.
86
+ Start advisory. Turn on strictness only when the rules have earned it.
87
+
88
+ ```bash
89
+ hunch firmness strict
90
+ hunch check --staged --strict
91
+ hunch conform --strict
92
+ ```
93
+
94
+ ## Private when the reasoning is sensitive
95
+
96
+ Open-source the code without open-sourcing the reasoning.
97
+
98
+ ```bash
99
+ hunch private ~/work/hunch-private/.hunch
100
+ hunch record-bug --private --test "billing regression" --message "…"
101
+ hunch review --private
102
+ ```
103
+
104
+ Private decisions, bugs, constraints, and wiki pages live in a separate overlay you control.
105
+ Local checks enforce them; public CI reports use `--public-only`, so private memory never appears in
106
+ a pull-request comment or committed grounding file. Private captures default to deterministic local
107
+ synthesis, keeping sensitive diffs and failure messages out of subscription-model drafting.
108
+
109
+ ## A workflow your team can trust
20
110
 
21
- ## The moment it earns its keep
111
+ Hunch is deliberately conservative:
22
112
 
23
- An AI "optimizes" your controller to query the DB directly. Linters stay green — no bad
24
- pattern to match. Hunch flags it: *"listOrders now reaches dbQuery · why: the Mar-2025 N+1
25
- incident · protects against bug_0317."* You decide what happens next advisory shows the
26
- note; **strict** (opt-in) holds the change.
113
+ - **Human-confirmed rules get the teeth.** Drafted memory advises; confirmed, precise invariants can block.
114
+ - **Every result carries receipts.** Decisions, constraints, bugs, confidence, and evidence are connected.
115
+ - **Drift is visible.** `hunch doctor` catches stale references, stale generated docs, and broken overlay pointers.
116
+ - **Public surfaces are public-only.** Private overlay data stays local unless you explicitly choose to share it.
117
+ - **No magic rewrite bot.** Hunch proposes and checks; you decide what becomes truth.
27
118
 
28
- Measured ([`bench/`](bench/architectural-conformance.md), n=90, three models): recorded rules
29
- in context cut architectural drift **58% → 16%**. The deterministic check catches the rest —
30
- no model in the loop.
119
+ ## Try the moment it earns its keep
31
120
 
32
- ## What you get
121
+ Imagine an assistant “simplifies” a controller by querying the database directly. Linters are green.
122
+ Unit tests pass. The architecture is still wrong.
33
123
 
34
- - **Memory as a byproduct of work** every commit becomes a decision, failing tests become bug lineage; no documentation chore
35
- - **Every answer with receipts** decisions cite the why, the rejected alternatives, and the bug they protect against
36
- - **Guards that hold the line** corrections become permanent rules; drift and re-opened bugs get caught deterministically
37
- - **Verification pipeline** (v1.4) — the agent can't end a turn claiming success on unverified edits
38
- - **One graph, every assistant** — plain git-tracked JSON in `.hunch/`, served over MCP; no SaaS, $0, works offline
124
+ Hunch can answer with the actual context: this boundary exists because of the N+1 incident, the
125
+ service layer was the chosen repair, and the direct import violates a confirmed constraint. That is
126
+ the missing layer between fast code generation and durable engineering judgment.
39
127
 
40
128
  ## Learn more
41
129
 
42
- **[Documentation](https://hunch-pi.vercel.app/docs)** · **[Cookbook](https://hunch-pi.vercel.app/cookbook)** · **[Changelog](https://hunch-pi.vercel.app/changelog)** · **[15-second demo](demo/architectural-conformance.sh)**
130
+ - [Full documentation](https://hunch-pi.vercel.app/docs)
131
+ - [Interactive product site](https://hunch-pi.vercel.app)
132
+ - [VS Code extension](vscode-extension/README.md)
133
+ - [Architecture benchmark](bench/architectural-conformance.md)
134
+ - [15-second demo](demo/architectural-conformance.sh)
43
135
 
44
136
  Apache-2.0
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
@@ -2,7 +2,7 @@
2
2
  * No LLM here — just parsing what git already knows. */
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { isAbsolute, resolve, join, basename, dirname } from "node:path";
5
- import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
5
+ import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
6
6
  function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
7
7
  // stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
8
8
  return execFileSync("git", args, {
@@ -377,6 +377,14 @@ export function stagedFiles(cwd) {
377
377
  const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
378
378
  return out ? out.split("\n").filter(Boolean) : [];
379
379
  }
380
+ /** Files changed anywhere in the working tree compared with HEAD: both staged
381
+ * and unstaged tracked files, plus untracked files. This powers the local,
382
+ * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
383
+ export function workingFiles(cwd) {
384
+ const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
385
+ const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
386
+ return [...new Set([...changed, ...untracked])].sort();
387
+ }
380
388
  /** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
381
389
  * unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
382
390
  * CI pass), since the diff helpers below swallow git errors to "". */
@@ -408,6 +416,28 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
408
416
  const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
409
417
  return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
410
418
  }
419
+ /** Unified diff of the complete local working tree vs HEAD. Git's normal diff
420
+ * includes both staged and unstaged tracked edits; untracked text files are
421
+ * appended as synthetic additions so guards can also see their added symbols.
422
+ * Binary/unreadable files remain in workingFiles (scope checks still apply) but
423
+ * intentionally contribute no synthetic content to regression analysis. */
424
+ export function workingDiff(cwd, maxBytes = 60_000) {
425
+ let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
426
+ const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
427
+ const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
428
+ for (const file of untracked) {
429
+ try {
430
+ const text = readFileSync(join(cwd, file), "utf8");
431
+ if (text.includes("\0"))
432
+ continue;
433
+ const lines = text.split("\n");
434
+ const add = lines.map((line) => `+${line}`).join("\n");
435
+ out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
436
+ }
437
+ catch { /* unreadable / directory / binary: scope-only is still safe */ }
438
+ }
439
+ return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
440
+ }
411
441
  /** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
412
442
  * date of that commit — the instant valid-time windows are filtered against.
413
443
  * Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
@@ -20,6 +20,10 @@ function block(invocation, opts = {}) {
20
20
  MARK,
21
21
  'if [ -z "$HUNCH_SYNC" ]; then',
22
22
  " export HUNCH_SYNC=1",
23
+ // A split-private capture must not make a storage-private promise and then
24
+ // ship the commit diff to a subscription CLI. Shared overlays are a separate
25
+ // team policy, so only the explicit local-only mode forces deterministic.
26
+ ...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
23
27
  ` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
24
28
  "fi",
25
29
  ENDMARK,
@@ -16,7 +16,7 @@ import { decisionId } from "../core/ids.js";
16
16
  import { buildCorrectionConstraint } from "../core/correction.js";
17
17
  import { knownRepoDeps } from "../synthesis/tripwires.js";
18
18
  import { refreshExistingGrounding } from "../integrations/providers.js";
19
- import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
19
+ import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunch } from "../extractors/git.js";
20
20
  import { flushCapture } from "../integrations/sync.js";
21
21
  import { ensureTeamOverlay } from "../integrations/team.js";
22
22
  import { formatContext, formatStructure } from "../core/format.js";
@@ -446,11 +446,11 @@ export function buildServer(root) {
446
446
  const resolved = decision.commit ? revParse(decision.commit, root) : null;
447
447
  const fullSha = resolved && /^[0-9a-f]{40}$/.test(resolved) ? resolved : null;
448
448
  const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
449
- // Preserve the ADR lineage: upgrading an auto-draft yields the composite
450
- // provenance the design specifies.
451
- // Public-only lookup; skip it for a private write so a private decision never
452
- // inherits fields from a same-id PUBLIC record (and vice-versa).
453
- const existing = decision.private ? undefined : store.json.get("decisions", id);
449
+ // Preserve the ADR lineage from the SAME home this write will use. A private
450
+ // re-record must retain its own optional fields, but must never inherit a
451
+ // same-id public record (and vice versa).
452
+ const home = store.captureHome(!!decision.private);
453
+ const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
454
454
  const source = existing && existing.provenance.source.includes("llm_draft")
455
455
  ? "llm_draft+human_confirmed"
456
456
  : "human_confirmed";
@@ -482,7 +482,6 @@ export function buildServer(root) {
482
482
  // private:false, so the guard must key its incumbent lookup on HOME, not on
483
483
  // the flag — keying on the flag let a shared-mode supersede of a public
484
484
  // incumbent pass the guard and then no-op the close (two live decisions).
485
- const home = store.captureHome(!!decision.private);
486
485
  // Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
487
486
  // live decision for one topic. Exclude ONLY the incumbent this write will
488
487
  // actually close — one resolvable in the SAME store the write lands in. A
@@ -571,7 +570,7 @@ export function buildServer(root) {
571
570
  // Private corrections go to the overlay (enforced locally via the merged read,
572
571
  // never rendered into the public CI comment, which is public-only by construction).
573
572
  const home = store.captureHome(!!input.private);
574
- const existing = home === "private" ? undefined : store.json.get("constraints", rec.id);
573
+ const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
575
574
  if (home === "private")
576
575
  store.putPrivate("constraints", rec);
577
576
  else
@@ -600,24 +599,25 @@ export function buildServer(root) {
600
599
  // -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
601
600
  server.registerTool("hunch_merge_verdict", {
602
601
  title: "Causal merge verdict: is this change safe against the recorded WHY?",
603
- description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base AND commit to check STAGED changes; pass base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
602
+ description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
604
603
  inputSchema: {
605
604
  base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
606
605
  commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
606
+ working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
607
607
  },
608
- }, async ({ base, commit }) => {
608
+ }, async ({ base, commit, working }) => {
609
609
  try {
610
- if (base && commit)
611
- return err("Pass at most one of base/commit (omit both to check staged changes).");
610
+ if ([base, commit, working].filter(Boolean).length > 1)
611
+ return err("Pass at most one of base/commit/working (omit all to check staged changes).");
612
612
  if (base && !revExists(base, root))
613
613
  return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
614
614
  if (commit && !revExists(commit, root))
615
615
  return err(`commit "${commit}" does not resolve.`);
616
- const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
617
- const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
616
+ const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
617
+ const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
618
618
  if (!files.length)
619
619
  return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
620
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
620
+ const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
621
621
  const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
622
622
  const v = verdict(report);
623
623
  const head = v === "block"
@@ -642,24 +642,25 @@ export function buildServer(root) {
642
642
  // -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
643
643
  server.registerTool("hunch_pr_impact", {
644
644
  title: "PR impact: the dependency + memory surface of a change",
645
- description: "Given a change (staged, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base AND commit for staged changes.",
645
+ description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes.",
646
646
  inputSchema: {
647
647
  base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
648
648
  commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
649
+ working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
649
650
  },
650
- }, async ({ base, commit }) => {
651
+ }, async ({ base, commit, working }) => {
651
652
  try {
652
- if (base && commit)
653
- return err("Pass at most one of base/commit (omit both for staged changes).");
653
+ if ([base, commit, working].filter(Boolean).length > 1)
654
+ return err("Pass at most one of base/commit/working (omit all for staged changes).");
654
655
  if (base && !revExists(base, root))
655
656
  return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
656
657
  if (commit && !revExists(commit, root))
657
658
  return err(`commit "${commit}" does not resolve.`);
658
- const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
659
- const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
659
+ const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
660
+ const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
660
661
  if (!files.length)
661
662
  return ok(`No changed files in ${scope}.`);
662
- const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
663
+ const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
663
664
  return ok(renderImpact(store.prImpact(files, diff), scope));
664
665
  }
665
666
  catch (e) {
@@ -98,6 +98,12 @@ export class HunchStore {
98
98
  getRec(kind, id) {
99
99
  return this.privateJson?.get(kind, id) ?? this.json.get(kind, id);
100
100
  }
101
+ /** Read a record only from the configured private overlay. Callers that must
102
+ * preserve privacy boundaries (for example, an explicit `--private` repair)
103
+ * should use this instead of overlay-first `getRec`. */
104
+ getPrivateRec(kind, id) {
105
+ return this.privateJson?.get(kind, id);
106
+ }
101
107
  /** Update an EXISTING record in the store that holds it — an overlay record must never
102
108
  * fork a public copy on update (and vice versa). Falls back to captureHome routing for
103
109
  * a record that exists nowhere yet. */
@@ -109,6 +115,14 @@ export class HunchStore {
109
115
  return this.json.put(kind, record);
110
116
  return this.putCapture(kind, record);
111
117
  }
118
+ /** Delete an existing record from its actual home. The review/curation path
119
+ * uses this so rejecting a private draft cannot silently leave it behind or
120
+ * accidentally target a public record with the same id. */
121
+ deleteWhereItLives(kind, id) {
122
+ if (this.privateJson?.get(kind, id))
123
+ return this.privateJson.delete(kind, id);
124
+ return this.json.delete(kind, id);
125
+ }
112
126
  /** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
113
127
  * never committed). Tolerant: returns {} on missing/invalid so reads never crash.
114
128
  * `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
@@ -161,6 +175,12 @@ export class HunchStore {
161
175
  byId.set(r.id, r);
162
176
  return [...byId.values()];
163
177
  }
178
+ /** Records from exactly one storage home (no public/private union). Capture
179
+ * paths use this for identity/lineage checks so a private record can never
180
+ * inherit or disclose relationships from an identically-shaped public record. */
181
+ recsInHome(kind, home) {
182
+ return home === "private" ? (this.privateJson?.loadAll(kind) ?? []) : this.json.loadAll(kind);
183
+ }
164
184
  /** Whether a private overlay store is configured (HUNCH_PRIVATE_DIR is set). */
165
185
  get hasPrivate() {
166
186
  return !!this.privateJson;
@@ -883,13 +903,14 @@ export class HunchStore {
883
903
  * lineage.spawned_constraint, else the source decision's caused_by_bug). Read-only. */
884
904
  causalChain(constraintId) {
885
905
  const out = { constraint_id: constraintId };
886
- const c = this.json.get("constraints", constraintId);
906
+ const get = (kind, id) => this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
907
+ const c = get("constraints", constraintId);
887
908
  if (!c)
888
909
  return out;
889
- const dec = c.source_decision ? this.json.get("decisions", c.source_decision) : null;
910
+ const dec = c.source_decision ? get("decisions", c.source_decision) : null;
890
911
  if (dec)
891
912
  out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
892
- const bugs = this.recs("bugs");
913
+ const bugs = this.suppressPrivate ? this.json.loadAll("bugs") : this.recs("bugs");
893
914
  // Deterministic when several bugs link one constraint (the verdict claims to be
894
915
  // deterministic): highest severity first, then lowest id — never filesystem order.
895
916
  const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
@@ -1201,7 +1222,7 @@ export class HunchStore {
1201
1222
  /** Resolve a veto's causal citation: the bug whose root cause spawned the decision
1202
1223
  * (decision → caused_by_bug). Distinct from causalChain, which is constraint-keyed. */
1203
1224
  vetoWhy(bugId) {
1204
- const bug = this.json.get("bugs", bugId);
1225
+ const bug = this.suppressPrivate ? this.json.get("bugs", bugId) : this.getRec("bugs", bugId);
1205
1226
  return bug ? { bug: { id: bug.id, title: bug.title, root_cause: bug.root_cause } } : undefined;
1206
1227
  }
1207
1228
  /** Veto check for a LIVE edit (the agent pre-edit hook): no diff exists yet, so
@@ -1284,7 +1305,7 @@ export class HunchStore {
1284
1305
  /** Convenience: load a single entity from JSON by id (any kind). */
1285
1306
  resolve(id) {
1286
1307
  for (const kind of ENTITY_KINDS) {
1287
- const rec = this.json.get(kind, id);
1308
+ const rec = this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
1288
1309
  if (rec)
1289
1310
  return { kind, record: rec };
1290
1311
  }
@@ -131,6 +131,20 @@ const BUG_TOOL = {
131
131
  required: ["title", "symptom", "root_cause", "severity"],
132
132
  },
133
133
  };
134
+ const RELEVANCE_TOOL = {
135
+ name: "emit_relevance",
136
+ description: "Judge whether an auto-drafted decision is worth keeping in the memory graph.",
137
+ input_schema: {
138
+ type: "object",
139
+ properties: {
140
+ relevant: { type: "boolean", description: "true if this records a REAL, reusable design choice worth keeping. false if it is noise: a mechanical restatement of the diff, a trivial/obvious change, or content unsupported by the evidence." },
141
+ confidence: { type: "number", description: "0..1 confidence in the relevant call. Be honest; low when unsure." },
142
+ duplicate_of: { type: ["string", "null"], description: "id (dec_...) of an existing decision this merely restates, from the EXISTING DECISIONS list. null if none." },
143
+ reason: { type: "string", description: "one short line justifying the call." },
144
+ },
145
+ required: ["relevant", "confidence", "duplicate_of", "reason"],
146
+ },
147
+ };
134
148
  const VERIFY_TOOL = {
135
149
  name: "emit_verdict",
136
150
  description: "Emit a skeptical audit of a synthesized decision against its commit.",
@@ -217,6 +231,16 @@ class CliSynthProvider {
217
231
  throw new Error(`${this.name}: no usable verdict JSON in output`);
218
232
  return verdict;
219
233
  }
234
+ /** Judge whether an auto-drafted decision is worth keeping (for auto-review).
235
+ * Same subscription-only run() path (API keys stripped). Throws on unusable
236
+ * output so the caller can degrade to a keep-for-human verdict. */
237
+ async judgeDraft(draft, existing) {
238
+ const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`);
239
+ const verdict = relevanceFromText(text);
240
+ if (!verdict)
241
+ throw new Error(`${this.name}: no usable relevance JSON in output`);
242
+ return verdict;
243
+ }
220
244
  }
221
245
  // A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
222
246
  // on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
@@ -607,6 +631,40 @@ function verifyPrompt(input, draft) {
607
631
  `\nReturn grounded (0..1) and the VERBATIM alternatives_rejected / consequences the evidence does NOT support.`,
608
632
  ].filter(Boolean).join("\n\n");
609
633
  }
634
+ const RELEVANCE_SYSTEM = `You are a strict curator for an Engineering Memory OS. You are given ONE auto-drafted
635
+ decision and a list of decisions ALREADY in the graph. Decide if the draft is worth keeping:
636
+ a REAL, reusable design choice (an architectural or policy decision a future engineer would
637
+ want to know). Mark it NOT relevant if it merely restates what the diff mechanically did, is
638
+ trivial/obvious, or is a near-duplicate of an existing decision (name that decision's id in
639
+ duplicate_of). When genuinely unsure, keep it (relevant=true, low confidence) — deletion is
640
+ destructive.`;
641
+ function relevancePrompt(draft, existing) {
642
+ const ex = existing.length
643
+ ? existing.map((e) => ` ${e.id}: ${e.title} — ${e.decision.slice(0, 160)}`).join("\n")
644
+ : " (none)";
645
+ return [
646
+ `DRAFT UNDER REVIEW (id ${draft.id}):`,
647
+ ` title: ${draft.title}`,
648
+ ` decision: ${(draft.decision ?? "").slice(0, 800)}`,
649
+ (draft.alternatives_rejected ?? []).length ? ` alternatives_rejected:\n${(draft.alternatives_rejected ?? []).map((a) => ` - ${a}`).join("\n")}` : "",
650
+ (draft.related_files ?? []).length ? ` related_files: ${(draft.related_files ?? []).join(", ")}` : "",
651
+ `\nEXISTING DECISIONS (candidates for duplicate_of):\n${ex}`,
652
+ `\nReturn relevant, confidence (0..1), duplicate_of (an existing id or null), and a one-line reason.`,
653
+ ].filter(Boolean).join("\n\n");
654
+ }
655
+ /** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the
656
+ * caller keeps the draft for a human). Tolerant of missing/loose fields. */
657
+ export function relevanceFromText(text) {
658
+ for (const obj of extractJsonObjects(text)) {
659
+ if (typeof obj.relevant !== "boolean")
660
+ continue; // the one required signal
661
+ const dup = typeof obj.duplicate_of === "string" && obj.duplicate_of.trim() ? obj.duplicate_of.trim() : null;
662
+ const conf = typeof obj.confidence === "number" ? clamp01(obj.confidence) : 0.5;
663
+ const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
664
+ return { relevant: obj.relevant, confidence: conf, duplicate_of: dup, reason };
665
+ }
666
+ return null;
667
+ }
610
668
  /** Map model text → VerifyVerdict, or null when nothing usable parses (→ the caller
611
669
  * keeps the un-audited draft). Tolerant of arrays-as-strings and missing fields. */
612
670
  export function verdictFromText(text) {
@@ -45,7 +45,11 @@ export async function syncCommit(store, root, sha, opts = {}) {
45
45
  // Seed the id from the COMMIT (stable across runs), not the LLM-generated title
46
46
  // (which varies) — so re-syncing a commit updates rather than dupes.
47
47
  const id = decisionId(meta.sha);
48
- const existing = store.json.get("decisions", id);
48
+ // Check the store this capture WILL write to. Looking only in the public store
49
+ // made private/shared re-syncs re-draft the same commit and let `--force`
50
+ // overwrite a human-confirmed overlay decision.
51
+ const home = store.captureHome(!!opts.private);
52
+ const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
49
53
  // Never clobber a human-confirmed decision with a low-confidence auto-draft —
50
54
  // even under --force. Skip BEFORE synthesizing so we never pay for a draft we'd
51
55
  // throw away (the old order drafted first, then discarded it here).
@@ -85,12 +89,19 @@ export async function syncCommit(store, root, sha, opts = {}) {
85
89
  // back to the normal single-provider path when no CLI is available. Opt-in only.
86
90
  // --verify forces the LLM provider (auditing a deterministic draft is pointless) and,
87
91
  // like --deep, runs the Critic pass below. Subscription-only throughout (con_2ce3f2a547).
88
- const wantVerify = !!(opts.verify || opts.deep);
89
- const provider = opts.deep
90
- ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
91
- : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
92
- ? await selectProvider()
93
- : new DeterministicProvider();
92
+ // An explicit private capture is storage-private AND local-only by default:
93
+ // never send a sensitive diff to a subscription CLI just to create a draft.
94
+ // Shared mode remains an explicit team policy and keeps its existing provider
95
+ // behavior unless the caller asked for a private capture.
96
+ const localOnly = opts.localOnly ?? !!opts.private;
97
+ const wantVerify = !localOnly && !!(opts.verify || opts.deep);
98
+ const provider = localOnly
99
+ ? new DeterministicProvider()
100
+ : opts.deep
101
+ ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
102
+ : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
103
+ ? await selectProvider()
104
+ : new DeterministicProvider();
94
105
  const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
95
106
  let draft = await draftDecisionSafe(provider, input);
96
107
  // The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
@@ -177,7 +188,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
177
188
  return { status: "written", decision, provider: provider.name };
178
189
  }
179
190
  /** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
180
- export async function recordFailure(store, root, failure) {
191
+ export async function recordFailure(store, root, failure, opts = {}) {
181
192
  const symbols = store.json.loadAll("symbols");
182
193
  const ranked = rankSuspects(symbols, failure.message);
183
194
  // Prefer symbols actually named in the failure — so unrelated failures don't
@@ -185,7 +196,10 @@ export async function recordFailure(store, root, failure) {
185
196
  const msg = failure.message.toLowerCase();
186
197
  const mentioned = ranked.filter((s) => msg.includes(s.name.toLowerCase()));
187
198
  const suspects = (mentioned.length ? mentioned : ranked).slice(0, 6);
188
- const provider = await selectProvider();
199
+ // A private bug may contain a stack trace, customer data, or secrets. Keep the
200
+ // whole capture local unless the caller deliberately routes it through a shared
201
+ // (non-private) workflow.
202
+ const provider = opts.private ? new DeterministicProvider() : await selectProvider();
189
203
  const input = {
190
204
  test: failure.test,
191
205
  message: failure.message,
@@ -198,7 +212,8 @@ export async function recordFailure(store, root, failure) {
198
212
  const id = bugId(failure.test);
199
213
  // recurrence = a DIFFERENT prior bug with a similar symptom (not this same one).
200
214
  // Query text mirrors the corpus side (title+symptom+root_cause) for symmetry.
201
- const prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id);
215
+ const home = store.captureHome(!!opts.private);
216
+ const prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id, home);
202
217
  const affectedFiles = [...new Set(suspects.map((s) => s.file))];
203
218
  const bug = {
204
219
  id,
@@ -223,12 +238,12 @@ export async function recordFailure(store, root, failure) {
223
238
  evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
224
239
  },
225
240
  };
226
- store.putCapture("bugs", bug);
241
+ store.putCapture("bugs", bug, opts.private);
227
242
  // Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
228
243
  // a regression Constraint to stop it coming back, and bumps fragility.
229
244
  let constraint;
230
245
  if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
231
- constraint = promoteConstraint(store, bug);
246
+ constraint = promoteConstraint(store, bug, opts.private);
232
247
  bug.lineage.spawned_constraint = constraint.id;
233
248
  store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
234
249
  }
@@ -253,7 +268,7 @@ export async function captureTestRun(store, root, input) {
253
268
  }
254
269
  const results = [];
255
270
  for (const f of failures) {
256
- const r = await recordFailure(store, root, f);
271
+ const r = await recordFailure(store, root, f, { private: input.private });
257
272
  results.push({ bug: r.bug, constraint: r.constraint });
258
273
  }
259
274
  let sha = null;
@@ -284,7 +299,7 @@ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
284
299
  return severe && rootCause.trim().length > 0;
285
300
  }
286
301
  /** Turn a bug into an advisory regression constraint scoped to its files. */
287
- function promoteConstraint(store, bug) {
302
+ function promoteConstraint(store, bug, isPrivate = false) {
288
303
  const scope = bug.affected_files.length ? bug.affected_files : ["**"];
289
304
  const statement = `Regression guard: "${bug.title}" must not recur.`;
290
305
  const con = {
@@ -304,7 +319,7 @@ function promoteConstraint(store, bug) {
304
319
  valid_to: null,
305
320
  provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
306
321
  };
307
- return store.putCapture("constraints", con);
322
+ return store.putCapture("constraints", con, isPrivate);
308
323
  }
309
324
  /** Bump fragility on components owning the affected files. */
310
325
  function raiseFragility(store, files) {
@@ -366,13 +381,13 @@ export function salientTerms(text) {
366
381
  /** Recurrence = a DIFFERENT prior bug whose salient terms overlap strongly with
367
382
  * this one (in-memory, no FTS/reindex dependency, threshold-gated to avoid the
368
383
  * over-broad OR false positives). Returns the best match above threshold. */
369
- function findRecurrence(store, text, excludeId) {
384
+ function findRecurrence(store, text, excludeId, home) {
370
385
  const want = salientTerms(text);
371
386
  if (want.size === 0)
372
387
  return undefined;
373
388
  let best;
374
389
  let bestScore = 0;
375
- for (const b of store.json.loadAll("bugs")) {
390
+ for (const b of store.recsInHome("bugs", home)) {
376
391
  if (b.id === excludeId)
377
392
  continue;
378
393
  // symmetric with the query side (which now also includes root_cause)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",