@davesheffer/hunch 0.38.2 β†’ 0.39.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,5 +1,6 @@
1
1
  # 🧠 Hunch β€” Architectural Conformance for AI code
2
2
 
3
+ [![GitHub stars](https://img.shields.io/github/stars/davesheffer/hunch?color=2742ff&label=%E2%98%85%20star)](https://github.com/davesheffer/hunch)
3
4
  [![npm version](https://img.shields.io/npm/v/@davesheffer/hunch?color=2742ff&label=npm)](https://www.npmjs.com/package/@davesheffer/hunch)
4
5
  [![npm downloads](https://img.shields.io/npm/dw/@davesheffer/hunch?color=2742ff)](https://www.npmjs.com/package/@davesheffer/hunch)
5
6
  [![license](https://img.shields.io/npm/l/@davesheffer/hunch?color=2742ff)](LICENSE)
package/dist/cli/index.js CHANGED
@@ -50,6 +50,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
50
50
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
51
51
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
52
52
  import { computeDrift } from "../core/drift.js";
53
+ import { topicCollisions, renderGrounding } from "../core/topics.js";
53
54
  import { compareCandidates } from "../core/compare.js";
54
55
  import { checkConformance } from "../core/conformance.js";
55
56
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
@@ -79,7 +80,8 @@ program
79
80
  .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
80
81
  .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
81
82
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
82
- .option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
83
+ .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
84
+ .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
83
85
  .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
84
86
  .action((opts) => {
85
87
  // Validate --firmness up front, before any side effects (indexing, git hooks,
@@ -109,8 +111,9 @@ program
109
111
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
110
112
  }
111
113
  if (isGitRepo(root)) {
112
- const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync, commit: opts.autoCommit });
113
- console.log(` βœ“ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " β€” syncs to the private overlay" : ""}${opts.autoCommit ? " β€” auto-commit+push on" : ""}`);
114
+ const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
115
+ const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
116
+ console.log(` βœ“ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " β€” syncs to the shared overlay" : ""}${opts.autoCommit ? " β€” auto-commit+push on" : ""}`);
114
117
  const m = installMergeDriver(root, inv.shell);
115
118
  console.log(` βœ“ team merge driver ${m.action}`);
116
119
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -266,7 +269,8 @@ program
266
269
  .option("--from-hook", "invoked by the git hook")
267
270
  .option("--quiet", "minimal output")
268
271
  .option("--force", "re-synthesize even if a decision already exists for the commit")
269
- .option("--private", "write the synthesized decision into the private overlay (HUNCH_PRIVATE_DIR), not the public repo β€” for a repo whose memory is kept private")
272
+ .option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
273
+ .option("--overlay", "alias of --private")
270
274
  .option("--commit", "after a capture, also git add+commit+push the repo the decision landed in (opt-in; best-effort) β€” the private store under --private, else this repo")
271
275
  .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
272
276
  .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
@@ -275,12 +279,13 @@ program
275
279
  const { store, root } = storeFor();
276
280
  if (!isGitRepo(root))
277
281
  return opts.quiet ? undefined : fail("sync needs a git repo");
278
- if (opts.private && !store.hasPrivate) {
282
+ const toOverlay = !!(opts.private || opts.overlay);
283
+ if (toOverlay && !store.hasPrivate) {
279
284
  store.close();
280
- return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
285
+ return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
281
286
  }
282
287
  store.json.ensureDirs();
283
- const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
288
+ const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: toOverlay, deep: opts.deep, verify: opts.verify, samples: parseSamples(opts.samples) });
284
289
  if (r.status === "written") {
285
290
  store.reindex();
286
291
  // Don't rewrite grounding from the hook β€” it would dirty the working tree on
@@ -296,7 +301,7 @@ program
296
301
  // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
297
302
  // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
298
303
  // hook (no recursion, including on a manual `hunch sync --commit`).
299
- const commitTarget = opts.commit ? (opts.private ? store.privateDir : hunchPaths(root).hunch) : undefined;
304
+ const commitTarget = opts.commit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
300
305
  if (commitTarget) {
301
306
  commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
302
307
  if (!opts.quiet)
@@ -310,29 +315,21 @@ program
310
315
  }
311
316
  store.close();
312
317
  });
313
- // ---- private (one-command setup for the private memory overlay) ------------
314
- program
315
- .command("private [dir]")
316
- .description("Enable a PRIVATE memory overlay β€” sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
317
- .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
318
- .option("--no-hook", "don't switch the post-commit hook to private sync")
319
- .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β€” fully automated two-way sync, never push by hand)")
320
- .option("--sync", "flush the configured private store now (git add+commit+push) β€” catches records made via MCP between commits")
321
- .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only β€” untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
322
- .action((dir, opts) => {
318
+ function configureOverlay(dir, opts, mode) {
323
319
  const root = findRoot();
320
+ const paths = hunchPaths(root);
321
+ const commandName = mode === "private" ? "private" : "shared";
324
322
  if (opts.sync) {
325
323
  const s = new HunchStore(hunchPaths(root));
326
324
  const target = s.privateDir;
327
325
  s.close();
328
326
  if (!target)
329
- return fail("no private overlay configured β€” run `hunch private` first");
330
- commitAndPushHunch(target, "hunch: sync private memory");
331
- console.log(`βœ“ flushed private store β†’ ${target}`);
327
+ return fail(`no overlay configured β€” run \`hunch ${commandName}\` first`);
328
+ commitAndPushHunch(target, "hunch: sync overlay memory");
329
+ console.log(`βœ“ flushed overlay store β†’ ${target}`);
332
330
  return;
333
331
  }
334
- const paths = hunchPaths(root);
335
- // 1) resolve the private store's hunch dir (holds decisions/, bugs/, …)
332
+ // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
336
333
  let hunchDir;
337
334
  if (opts.repo) {
338
335
  const dest = join(root, ".hunch-private");
@@ -353,6 +350,13 @@ program
353
350
  // multiple machines/worktrees merge by RECORD ID (no manual conflict resolution) when the
354
351
  // two-way auto-sync pulls before pushing. The overlay repo root is the parent of its .hunch.
355
352
  const overlayRoot = hunchPathsForDir(hunchDir).root;
353
+ // CRITICAL (bug_overlay_clobber): with --auto-commit, the overlay MUST be its own git repo.
354
+ // Otherwise the post-commit auto-commit (commitAndPushHunch) runs `git -C overlayDir …` which
355
+ // walks UP to the PROJECT repo and can commit memory over your code. Initialize a standalone
356
+ // repo when one isn't there (a local repo with no remote just accumulates commits β€” safe).
357
+ if (opts.autoCommit && !isGitRepo(overlayRoot)) {
358
+ spawnSync("git", ["init", "-q", overlayRoot], { stdio: "ignore" });
359
+ }
356
360
  if (isGitRepo(overlayRoot))
357
361
  installMergeDriver(overlayRoot, inv.shell);
358
362
  // 3) record the path in a GITIGNORED local config β€” auto-detected, no env var, and
@@ -371,7 +375,7 @@ program
371
375
  // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
372
376
  let worktreeNote = "";
373
377
  if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
374
- worktreeNote = ` βœ“ registered in the git common dir β€” shared by every worktree of this repo, on any branch\n`;
378
+ worktreeNote = " βœ“ registered in the git common dir β€” shared by every worktree of this repo, on any branch\n";
375
379
  }
376
380
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
377
381
  let hookNote = "";
@@ -405,18 +409,40 @@ program
405
409
  ` next: review, then commit the PUBLIC repo:\n` +
406
410
  ` git add -A && git commit -m "chore: move engineering memory to a private overlay" && git push\n`;
407
411
  }
408
- console.log(`βœ“ private overlay enabled β†’ ${hunchDir}\n` +
409
- ` βœ“ recorded in .hunch/local.json (gitignored) β€” auto-detected, no env var or shell-profile edit\n` +
412
+ const lead = mode === "private"
413
+ ? `βœ“ private overlay enabled β†’ ${hunchDir}\n`
414
+ : `βœ“ shared overlay enabled β†’ ${hunchDir}\n`;
415
+ const tail = mode === "private"
416
+ ? " record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only."
417
+ : " this works for any repo (public or private): one shared memory source across teammates, branches, and worktrees.\n override per-shell with HUNCH_PRIVATE_DIR if needed.";
418
+ console.log(lead +
419
+ " βœ“ recorded in .hunch/local.json (gitignored) β€” auto-detected, no env var or shell-profile edit\n" +
410
420
  worktreeNote +
411
421
  hookNote +
412
422
  migrateNote +
413
- ` record sensitive items with private:true (hunch_record_decision / hunch_record_correction)\n` +
414
- ` override per-shell with HUNCH_PRIVATE_DIR; CI / public PR comments stay public-only.`);
415
- });
423
+ tail);
424
+ }
425
+ program
426
+ .command("private [dir]")
427
+ .description("Enable a PRIVATE memory overlay β€” sensitive decisions/bugs/constraints kept in a separate location, unioned into local queries, never committed here. Writes a gitignored .hunch/local.json so it's auto-detected (no env var needed).")
428
+ .option("--repo <url>", "clone a private git repo to use as the store (into ./.hunch-private)")
429
+ .option("--no-hook", "don't switch the post-commit hook to private sync")
430
+ .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β€” fully automated two-way sync, never push by hand)")
431
+ .option("--sync", "flush the configured private store now (git add+commit+push) β€” catches records made via MCP between commits")
432
+ .option("--migrate", "ONE-TIME: move this repo's EXISTING public .hunch memory into the overlay, then make the public repo code-only β€” untrack + gitignore the memory tree and regenerate grounding so no memory is published here")
433
+ .action((dir, opts) => configureOverlay(dir, opts, "private"));
434
+ program
435
+ .command("shared [dir]")
436
+ .description("Enable a SHARED memory overlay repo for this project (works for any repo: private or public). Memory stays in one location, shared across teammates, branches, and worktrees.")
437
+ .option("--repo <url>", "clone a git repo to use as the shared memory store (into ./.hunch-private)")
438
+ .option("--no-hook", "don't switch the post-commit hook to overlay sync")
439
+ .option("--no-auto-commit", "DON'T auto commit+push the overlay after each capture (default: ON β€” fully automated two-way sync)")
440
+ .option("--sync", "flush the configured overlay store now (git add+commit+push)")
441
+ .action((dir, opts) => configureOverlay(dir, opts, "shared"));
416
442
  // ---- worktree (one-command worktree wired into Hunch) ----------------------
417
443
  program
418
444
  .command("worktree <path>")
419
- .description("Create a git worktree already wired into Hunch β€” it shares this repo's memory (the private overlay), with zero per-worktree setup.")
445
+ .description("Create a git worktree already wired into Hunch β€” it shares this repo's memory overlay, with zero per-worktree setup.")
420
446
  .option("-b, --branch <name>", "create the worktree on a NEW branch")
421
447
  .option("--no-share", "don't register the overlay at the git common dir (the worktree won't see private memory)")
422
448
  .option("--no-index", "don't build the new worktree's code graph (skip if you'll index later)")
@@ -448,7 +474,7 @@ program
448
474
  shareNote = ` Β· could not register the shared overlay pointer (no git common dir?)`;
449
475
  }
450
476
  else {
451
- shareNote = ` Β· no private overlay configured β€” run \`hunch private\` to share memory across worktrees`;
477
+ shareNote = ` Β· no overlay configured β€” run \`hunch shared\` (or \`hunch private\`) to share memory across worktrees`;
452
478
  }
453
479
  // 3) build the new worktree's CODE GRAPH (symbols/edges β†’ blast-radius / dependents).
454
480
  // Indexed IN-PROCESS (uses THIS install's tree-sitter, so the worktree needs no
@@ -696,7 +722,7 @@ program
696
722
  const id = decisionId(`inline:${it.file}:${it.text}`);
697
723
  const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
698
724
  const rec = {
699
- id, title: it.text, status: "accepted",
725
+ id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
700
726
  context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
701
727
  decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
702
728
  related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
@@ -1509,6 +1535,11 @@ program
1509
1535
  const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
1510
1536
  text += `\n\n⚠ Deliberately RETIRED from this file β€” do not re-introduce without cause: ${items}.`;
1511
1537
  }
1538
+ // Decision-grounding (Β§3): for topic-anchored decisions governing this file, state
1539
+ // the current decision assertively (graph over any stale doc) + what it rejected.
1540
+ const grounding = renderGrounding(ctx.decisions);
1541
+ if (grounding)
1542
+ text += `\n\n${grounding}`;
1512
1543
  emitContext("PreToolUse", text);
1513
1544
  }
1514
1545
  catch {
@@ -1655,6 +1686,80 @@ program
1655
1686
  }
1656
1687
  store.close();
1657
1688
  });
1689
+ // ---- reconcile-topics (decision-grounding Β§4 Enforcement) -----------------
1690
+ program
1691
+ .command("reconcile-topics")
1692
+ .description("Find topics with more than one live decision (the invariant a git merge can violate) and surface them for human resolution. Exits non-zero if any collision exists β€” wire into a post-merge hook or CI.")
1693
+ .action(() => {
1694
+ const { store } = storeFor();
1695
+ try {
1696
+ const collisions = topicCollisions(store.recs("decisions"));
1697
+ if (collisions.size === 0) {
1698
+ console.log("βœ“ No topic collisions β€” every topic has at most one live decision.");
1699
+ return;
1700
+ }
1701
+ console.error(`⚠ ${collisions.size} topic(s) have more than one live decision β€” the graph cannot say which is current. Resolve each (supersede one, or split the topic):\n`);
1702
+ for (const [topic, decs] of collisions) {
1703
+ console.error(` topic "${topic}":`);
1704
+ for (const d of decs)
1705
+ console.error(` - ${d.id} β€” "${d.title}" (${d.status})`);
1706
+ }
1707
+ console.error(`\nResolve: re-record one with supersedes:<other-id> to link it over the other, or give one a distinct topic to split.`);
1708
+ process.exitCode = 1;
1709
+ }
1710
+ finally {
1711
+ store.close();
1712
+ }
1713
+ });
1714
+ // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
1715
+ program
1716
+ .command("drift")
1717
+ .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, and doc≠graph anchor-stale (a file still anchored to a superseded decision). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
1718
+ .action(() => {
1719
+ const { store, root } = storeFor();
1720
+ try {
1721
+ const { findings } = computeDrift(store, root);
1722
+ const collisions = topicCollisions(store.recs("decisions"));
1723
+ if (!findings.length && collisions.size === 0) {
1724
+ console.log("βœ“ No drift β€” memory is in sync with the code/docs.");
1725
+ return;
1726
+ }
1727
+ for (const f of findings.slice(0, 50))
1728
+ console.log(`Β· [${f.kind}] ${f.id} β€” ${f.detail}`);
1729
+ for (const [topic, decs] of collisions)
1730
+ console.log(`Β· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} β€” run \`hunch reconcile-topics\``);
1731
+ const anchor = findings.filter((f) => f.kind === "anchor-stale").length;
1732
+ console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
1733
+ if (anchor || collisions.size)
1734
+ process.exitCode = 1;
1735
+ }
1736
+ finally {
1737
+ store.close();
1738
+ }
1739
+ });
1740
+ // ---- heal (decision-grounded drift reconciliation front door) -------------
1741
+ program
1742
+ .command("heal")
1743
+ .description("Decision-grounded drift reconciliation: report doc≠graph anchor-stale sections with the current decision to reconcile toward. Read-only — proposes, never rewrites. Escalate to /capture only if the DECISION (not the doc) is stale.")
1744
+ .action(() => {
1745
+ const { store, root } = storeFor();
1746
+ try {
1747
+ const anchor = computeDrift(store, root).findings.filter((f) => f.kind === "anchor-stale");
1748
+ if (!anchor.length) {
1749
+ console.log("βœ“ No docβ‰ graph drift to heal β€” every anchored view matches its current decision.");
1750
+ return;
1751
+ }
1752
+ console.log(`${anchor.length} anchored section(s) drifted from the graph:\n`);
1753
+ for (const f of anchor)
1754
+ console.log(`Β· ${f.detail}`);
1755
+ console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision β€” a prose fix.`);
1756
+ console.log(`Heal B (decision stale): only if the DECISION is wrong now, run /capture (hunch_capture_decision) to supersede it, then re-derive the doc.`);
1757
+ console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
1758
+ }
1759
+ finally {
1760
+ store.close();
1761
+ }
1762
+ });
1658
1763
  // ---- compact (bound Hunch growth) -----------------------------------------
1659
1764
  program
1660
1765
  .command("compact")
@@ -1753,7 +1858,7 @@ program
1753
1858
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1754
1859
  console.log(store.privateDir
1755
1860
  ? `private: on β†’ ${store.privateDir} (local overlay β€” unioned into queries; never committed or posted publicly)`
1756
- : dim(`private: off β€” run \`hunch private\` to keep sensitive memory in a separate repo (or set HUNCH_PRIVATE_DIR)`));
1861
+ : dim(`private: off β€” run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1757
1862
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1758
1863
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
1759
1864
  // "memory missing here" symptom has an obvious cause + fix.
@@ -1762,7 +1867,7 @@ program
1762
1867
  const sharedPtr = !!common && existsSync(join(common, "hunch", "local.json"));
1763
1868
  console.log(store.privateDir
1764
1869
  ? `worktree: linked β€” sharing the repo's memory${sharedPtr ? " via the git common dir" : ""}`
1765
- : dim(`worktree: linked, but no overlay resolved here β€” run \`hunch private\` once (any worktree) so all worktrees share it`));
1870
+ : dim(`worktree: linked, but no overlay resolved here β€” run \`hunch shared\` (or \`hunch private\`) once (any worktree) so all worktrees share it`));
1766
1871
  }
1767
1872
  // Semantic search is opt-in and local. Report availability + coverage without
1768
1873
  // loading the model (selectEmbedder only probes; embeddingStats just counts rows).
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Capture-session tokens (decision-grounding, DESIGN Β§5 Stage 1 / Β§9.3).
3
+ *
4
+ * hunch_capture_decision issues a short-lived token; the commit path consumes it, so a
5
+ * decision written through the capture front door is provably the tail of an interview
6
+ * β€” the identity-principle guard against a silent, un-interviewed write. In-memory (the
7
+ * MCP server is long-lived); tokens are one-time-use and expire so an abandoned
8
+ * interview can't leak. Absence of a token never BLOCKS a write yet (staged
9
+ * deprecation Β§9.3) β€” the caller decides how to treat an un-gated write.
10
+ */
11
+ const CAPTURE_TOKEN_TTL_MS = 30 * 60 * 1000; // 30 min
12
+ const sessions = new Map(); // token -> issuedAt (epoch ms)
13
+ /** Issue a token stamped `now` (epoch ms). Prunes expired tokens first so the map can't
14
+ * grow unbounded across a long server life. `mint` supplies the random id (injectable
15
+ * for tests); the call site passes crypto.randomUUID. */
16
+ export function issueCaptureToken(mint, now) {
17
+ for (const [tok, at] of sessions)
18
+ if (now - at > CAPTURE_TOKEN_TTL_MS)
19
+ sessions.delete(tok);
20
+ const token = mint();
21
+ sessions.set(token, now);
22
+ return token;
23
+ }
24
+ /** Consume a token iff it is a live, unexpired capture session. One-time use: a second
25
+ * consume of the same token returns false. */
26
+ export function consumeCaptureToken(token, now) {
27
+ if (!token)
28
+ return false;
29
+ const at = sessions.get(token);
30
+ if (at === undefined)
31
+ return false;
32
+ sessions.delete(token);
33
+ return now - at <= CAPTURE_TOKEN_TTL_MS;
34
+ }
35
+ export { CAPTURE_TOKEN_TTL_MS };
36
+ //# sourceMappingURL=capturetoken.js.map
@@ -10,12 +10,19 @@
10
10
  */
11
11
  import { existsSync, readFileSync, readdirSync } from "node:fs";
12
12
  import { join, extname } from "node:path";
13
+ import { toPosixTarget } from "./paths.js";
14
+ import { currentForTopic, isLive } from "./topics.js";
13
15
  const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
14
16
  const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
15
17
  export function computeDrift(store, root) {
16
18
  const findings = [];
17
19
  const decisions = store.recs("decisions");
18
20
  const byId = new Map(decisions.map((d) => [d.id, d]));
21
+ // Files any LIVE decision (any topic) still claims. A file governed by a live decision
22
+ // is NOT orphaned to a stale one β€” only a file listed solely by superseded decisions is
23
+ // anchor-stale. Keeps the doc≠graph gate's false-positive rate ~zero: a routine
24
+ // narrowing supersession (successor lists fewer files) never flags files still governed.
25
+ const liveFiles = new Set(decisions.filter(isLive).flatMap((d) => (d.related_files ?? []).map(toPosixTarget)));
19
26
  for (const d of decisions) {
20
27
  // 1. DEAD-REFERENCE β€” only for in-force decisions; a superseded one referencing
21
28
  // a since-deleted file is legitimate history, not drift.
@@ -44,6 +51,26 @@ export function computeDrift(store, root) {
44
51
  });
45
52
  }
46
53
  }
54
+ // 4. ANCHOR-STALE (doc≠graph, decision-grounding) — a derived view still anchored
55
+ // to a SUPERSEDED decision while a current one exists for the same topic. Fully
56
+ // deterministic: fires only on the explicit topic anchor + a live successor
57
+ // (never a semantic guess), and only for a file NO live decision claims. Advisory.
58
+ if (d.topic && (d.status === "superseded" || d.superseded_by)) {
59
+ const current = currentForTopic(decisions, d.topic);
60
+ if (current && current.id !== d.id) {
61
+ for (const f of d.related_files ?? []) {
62
+ if (!f || f.includes("*") || liveFiles.has(toPosixTarget(f)))
63
+ continue;
64
+ if (!existsSync(join(root, f)))
65
+ continue; // missing file is history β†’ dead-ref's job
66
+ findings.push({
67
+ kind: "anchor-stale",
68
+ id: d.id,
69
+ detail: `"${f}" is anchored to superseded decision ${d.id} (topic "${d.topic}"); the current decision is ${current.id} β€” "${current.title}". Reconcile the file with the current decision.`,
70
+ });
71
+ }
72
+ }
73
+ }
47
74
  }
48
75
  // 3. DOC-STALE β€” a doc that still advertises "proposed / not implemented" while
49
76
  // referencing code that exists. Heuristic + advisory; scoped to the repo's own
@@ -0,0 +1,81 @@
1
+ /** A decision is "live" for a topic when it is the accepted, non-superseded,
2
+ * still-in-force entry: the status gate plus both closure links open. Matches the
3
+ * in-force predicate used across the veto/regression guards. */
4
+ export function isLive(d) {
5
+ return d.status === "accepted" && d.superseded_by === null && d.valid_to === null;
6
+ }
7
+ /** Every live decision anchored to `topic`. In a healthy graph this is length 0 or 1;
8
+ * length > 1 is a topic collision the Β§4 resolution must settle. */
9
+ export function liveForTopic(decisions, topic) {
10
+ return decisions.filter((d) => d.topic === topic && isLive(d));
11
+ }
12
+ /** current(topic): the single live decision for a topic, or null. Null when there is
13
+ * none β€” AND when the topic is in an unresolved collision (>1 live), because an
14
+ * ambiguous current must never be injected as authoritative truth. */
15
+ export function currentForTopic(decisions, topic) {
16
+ const live = liveForTopic(decisions, topic);
17
+ return live.length === 1 ? live[0] : null;
18
+ }
19
+ /** history(topic): the full chain for a topic, newest first (by effect-time). */
20
+ export function historyForTopic(decisions, topic) {
21
+ return decisions
22
+ .filter((d) => d.topic === topic)
23
+ .sort((a, b) => (b.valid_from ?? b.date).localeCompare(a.valid_from ?? a.date));
24
+ }
25
+ /** rejected(topic): the alternatives the current decision ruled out β€” what Veto/drift
26
+ * check a derived view against. Empty when there is no unambiguous current decision. */
27
+ export function rejectedForTopic(decisions, topic) {
28
+ const cur = currentForTopic(decisions, topic);
29
+ return cur ? [...cur.alternatives_rejected] : [];
30
+ }
31
+ /** The live decisions that would COLLIDE if an `accepted` decision `selfId` is written
32
+ * on `topic` while superseding `willCloseId` (or null if it supersedes nothing). The
33
+ * self record and the incumbent this write will actually close are excluded; anything
34
+ * left is a second live decision the write must not create (the capture guard refuses
35
+ * when this is non-empty). `willCloseId` MUST be an incumbent the write can truly close
36
+ * (same store) β€” a cross-store supersede that will no-op must be passed as null so the
37
+ * incumbent stays counted and the write is refused. */
38
+ export function captureConflicts(decisions, topic, selfId, willCloseId) {
39
+ return liveForTopic(decisions, topic).filter((d) => d.id !== selfId && d.id !== willCloseId);
40
+ }
41
+ /** Read-time grounding block (Β§3): for the topic-anchored decisions governing an edited
42
+ * file, state the CURRENT decision assertively ("the graph overrides any doc that says
43
+ * otherwise") plus what it rejected. Input is the file-scoped IN-FORCE decisions from
44
+ * assembleContext, so no freshness re-check is needed here β€” a superseded-only-anchored
45
+ * file is caught by the anchor-stale drift check, and the commit-time staleness gate
46
+ * applies the age-downgrade. Returns "" when no anchored decision governs the file. */
47
+ export function renderGrounding(fileDecisions) {
48
+ const anchored = fileDecisions.filter((d) => d.topic && isLive(d));
49
+ if (!anchored.length)
50
+ return "";
51
+ const lines = anchored.map((d) => {
52
+ const rej = d.alternatives_rejected.length ? ` (rejected: ${d.alternatives_rejected.join("; ")})` : "";
53
+ return `β€’ "${d.topic}": ${d.decision || d.title} [${d.id}]${rej}`;
54
+ });
55
+ return `🧭 Hunch grounding β€” this file is anchored to recorded decisions; follow the graph, not a stale doc:\n${lines.join("\n")}`;
56
+ }
57
+ /** Every topic with MORE THAN ONE live decision β€” the invariant violations a post-merge
58
+ * reconcile pass surfaces for human resolution. This is the distributed half of Β§4
59
+ * Enforcement: the content merge driver merges by id and is NOT invoked for cross-file
60
+ * ADD/ADD, so two branches each adding an `accepted` decision for one topic land both
61
+ * files with no collision. This scan catches them after the merge. Keyed by topic;
62
+ * value is the colliding live set (length >= 2), each sorted by id for stable output. */
63
+ export function topicCollisions(decisions) {
64
+ const byTopic = new Map();
65
+ for (const d of decisions) {
66
+ if (!d.topic || !isLive(d))
67
+ continue;
68
+ const arr = byTopic.get(d.topic);
69
+ if (arr)
70
+ arr.push(d);
71
+ else
72
+ byTopic.set(d.topic, [d]);
73
+ }
74
+ const collisions = new Map();
75
+ for (const [topic, arr] of byTopic) {
76
+ if (arr.length >= 2)
77
+ collisions.set(topic, [...arr].sort((a, b) => a.id.localeCompare(b.id)));
78
+ }
79
+ return collisions;
80
+ }
81
+ //# sourceMappingURL=topics.js.map
@@ -109,6 +109,13 @@ export const ConformancePredicateSchema = z.object({
109
109
  export const DecisionSchema = z.object({
110
110
  id: z.string().describe("dec_*"),
111
111
  title: z.string(),
112
+ // Decision-grounding anchor: the join key that relates a doc section, a decision,
113
+ // and a code region for drift detection. Exactly one topic per decision; null =
114
+ // un-anchored (still valid, just invisible to doc≠graph detection until tagged —
115
+ // honest and bounded). Optional-with-default, so every legacy record validates with
116
+ // no migration (Zod fills null on read); grounding freshness reuses the existing
117
+ // valid-time / last_verified signals rather than a separate clock.
118
+ topic: z.string().nullable().default(null).describe("decision-grounding anchor; one topic per decision, null = un-anchored"),
112
119
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).default("proposed"),
113
120
  context: z.string().default(""),
114
121
  decision: z.string().default(""),
@@ -43,13 +43,30 @@ export function commitAndPushHunch(hunchDir, message) {
43
43
  catch { /* best-effort: nothing staged / not a repo / offline */ }
44
44
  };
45
45
  run(["add", "--", "."]);
46
- run(["commit", "-m", message]);
47
- // Two-way sync: MERGE the remote BEFORE pushing, so a push can never be rejected
48
- // non-fast-forward (the cause of memory piling up unpushed across machines). The .hunch
49
- // merge driver resolves same-record conflicts by id; non-overlapping records merge cleanly
50
- // without it. On an unresolved conflict / offline, mergeRemote aborts to a clean tree and we
51
- // skip the push β€” the local commit stays and syncs on the next write (never lost).
52
- if (mergeRemote(hunchDir, env))
46
+ // SAFETY BACKSTOP (critical β€” bug_overlay_clobber): a memory sync is PURELY ADDITIVE small
47
+ // JSON. If the staged set contains a DELETION, rename, or any non-.json file, hunchDir is NOT
48
+ // a clean overlay store β€” most dangerously, the overlay was never its own git repo so `git -C`
49
+ // walked UP to the PROJECT repo. Committing/pushing there would overwrite/delete the user's
50
+ // code (we shipped exactly this). Refuse hard: unstage and bail without committing or pushing.
51
+ if (!stagedIsMemoryOnly(hunchDir, env)) {
52
+ try {
53
+ execFileSync("git", ["-C", hunchDir, "reset", "-q", "--", "."], { stdio: "ignore", env });
54
+ }
55
+ catch { /* best-effort unstage */ }
56
+ console.error(`hunch: refusing to auto-commit memory at "${hunchDir}" β€” the staged change includes deletions or non-memory files, so this is not a clean overlay repo. Nothing was committed or pushed. (Use \`hunch shared --repo <url>\` so the overlay is its OWN git repo.)`);
57
+ return;
58
+ }
59
+ // Only sync+push when a memory commit was actually created β€” never run pull/push against the
60
+ // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
61
+ // can't be rejected non-fast-forward; the .hunch merge driver resolves same-record conflicts
62
+ // by id. On conflict/offline, mergeRemote aborts to a clean tree and we skip the push.
63
+ let committed = false;
64
+ try {
65
+ execFileSync("git", ["-C", hunchDir, "commit", "-m", message], { stdio: "ignore", env });
66
+ committed = true;
67
+ }
68
+ catch { /* nothing staged / not a repo */ }
69
+ if (committed && mergeRemote(hunchDir, env))
53
70
  run(["push"]);
54
71
  }
55
72
  finally {
@@ -59,6 +76,35 @@ export function commitAndPushHunch(hunchDir, message) {
59
76
  catch { /* released best-effort */ }
60
77
  }
61
78
  }
79
+ /** Is the staged set a clean, MEMORY-ONLY change β€” only JSON record adds/updates, nothing else?
80
+ * The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
81
+ * is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
82
+ * clean overlay repo (e.g. it resolved to the project repo), so committing there would clobber
83
+ * code. Empty stage β‡’ false (nothing to commit). The transient mkdir lock is ignored. */
84
+ function stagedIsMemoryOnly(hunchDir, env) {
85
+ let out = "";
86
+ try {
87
+ out = execFileSync("git", ["-C", hunchDir, "diff", "--cached", "--name-status"], { encoding: "utf8", env });
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ const lines = out.split("\n").map((l) => l.trim()).filter(Boolean);
93
+ if (!lines.length)
94
+ return false;
95
+ for (const line of lines) {
96
+ const parts = line.split("\t");
97
+ const status = (parts[0] ?? "").trim();
98
+ const path = (parts[parts.length - 1] ?? "").trim();
99
+ if (path.includes(".hunch-commit.lock"))
100
+ continue; // transient lock dir, never a record
101
+ if (!/^[AM]$/.test(status))
102
+ return false; // only Add / Modify β€” any D/R/C/T β†’ not a memory sync
103
+ if (!path.endsWith(".json"))
104
+ return false; // the store is entirely JSON records
105
+ }
106
+ return true;
107
+ }
62
108
  /** Merge the overlay's remote into the local branch (pull, no rebase), leaving a CLEAN tree
63
109
  * whether it succeeds or not. Returns true when the branch is safe to push (merged, or there's
64
110
  * no upstream to merge), false when a conflict was aborted (caller skips the push and retries
@@ -55,6 +55,28 @@ then produce a **fragility report with evidence**: the specific files/functions,
55
55
  the bug history behind them, their churn and fan-in, and any missing guards.
56
56
  Avoid generic advice β€” every claim must cite a Hunch record or metric.
57
57
  `;
58
+ const CAPTURE_CMD = `---
59
+ description: Capture an engineering decision into Hunch's graph via a grilling interview (topic, rationale, rejected alternatives)
60
+ ---
61
+ Capture the decision for **$ARGUMENTS** into Hunch's graph.
62
+
63
+ 1. Call \`hunch_capture_decision(topic?, seed?)\` β€” it returns the grilling protocol and a capture-session token.
64
+ 2. Run the GRILLING LOOP: one focused question at a time. Push back on hand-wavy answers. Resolve every branch before committing β€” an unexamined decision poisons the graph.
65
+ 3. Confirm the TOPIC anchor with me before committing. One topic per decision; if it spans two, split into two captures.
66
+ 4. Capture REJECTED alternatives explicitly (what, and why not) β€” this is what makes the decision enforceable (Veto/drift check against it).
67
+ 5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
68
+ 6. On CONFLICT for the topic, do NOT auto-supersede β€” Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
69
+ `;
70
+ const HEAL_CMD = `---
71
+ description: Reconcile docs/code with Hunch's decision graph (doc≠graph drift), never rewriting prose silently
72
+ ---
73
+ Reconcile decision-grounding drift for **$ARGUMENTS** (or the whole repo).
74
+
75
+ 1. Run \`hunch drift\` (or \`hunch heal\`) to list doc≠graph **anchor-stale** sections — a file still anchored to a superseded decision while a current one exists. Only explicit topic anchors fire; never a semantic guess.
76
+ 2. For each, assume the DOC is stale first (Heal A). Propose an edit bringing the file to the CURRENT decision; show it as a diff and wait for my confirm. Never rewrite prose silently.
77
+ 3. Only if I explicitly say "the DECISION is stale, not the doc" (Heal B): run /capture to record a superseding decision, then return to step 2 β€” the prose re-derives from the new decision as a separate confirm.
78
+ 4. Report: healed (Heal A), superseded (Heal B), skipped. Never touch the graph except via an explicit Heal B capture.
79
+ `;
58
80
  /** A settings.json hook entry is Hunch's if any of its commands ends with the
59
81
  * Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
60
82
  * command TAIL β€” not the absolute path β€” makes re-init idempotent AND survives a
@@ -119,6 +141,8 @@ export function writeSlashCommands(root) {
119
141
  ["hunch-why.md", WHY_CMD],
120
142
  ["hunch-fix.md", FIX_CMD],
121
143
  ["hunch-fragile.md", FRAGILE_CMD],
144
+ ["capture.md", CAPTURE_CMD],
145
+ ["heal.md", HEAL_CMD],
122
146
  ];
123
147
  for (const [name, body] of files) {
124
148
  const p = join(dir, name);
@@ -22,6 +22,9 @@ import { compareCandidates } from "../core/compare.js";
22
22
  import { checkConformance } from "../core/conformance.js";
23
23
  import { renderMarkdown, verdict } from "../core/checkreport.js";
24
24
  import { HUNCH_VERSION } from "../core/version.js";
25
+ import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
26
+ import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
27
+ import { randomUUID } from "node:crypto";
25
28
  const ok = (text) => ({ content: [{ type: "text", text }] });
26
29
  const err = (text) => ({ content: [{ type: "text", text }], isError: true });
27
30
  // Read-side token budgets: every tool result is injected into a Claude Code
@@ -34,6 +37,25 @@ const QUERY_HITS = 8; // hunch_query matches (was 12)
34
37
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
35
38
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
36
39
  const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` β€” ${hint}` : ""})` : "";
40
+ // Capture-session tokens live in src/core/capturetoken.ts (pure + testable). These
41
+ // thin wrappers bind the process clock and id source at the call site (Β§5 Stage 1).
42
+ const issueCaptureToken = () => issueToken(randomUUID, Date.now());
43
+ const consumeCaptureToken = (token) => consumeToken(token, Date.now());
44
+ /** The interrogation protocol returned by hunch_capture_decision. */
45
+ function grillingProtocol(topic, token) {
46
+ return [
47
+ "You are capturing an engineering decision into Hunch's graph. Run the GRILLING LOOP, then commit.",
48
+ "",
49
+ "RULES:",
50
+ "1. Grill ONE focused question at a time. Push back on hand-wavy answers. Resolve every branch of the decision tree before committing β€” an unexamined decision poisons the graph.",
51
+ `2. Confirm the TOPIC anchor with the human before committing${topic ? ` (proposed: "${topic}")` : ""}. Exactly one topic per decision; if it spans two, split into two captures.`,
52
+ "3. Capture REJECTED alternatives explicitly β€” for each, what it was and why not. This is what makes the decision enforceable (Veto/drift check against it).",
53
+ `4. Commit with hunch_record_decision, passing capture_token:"${token}" and the confirmed topic. The artifact is the graph write, not prose.`,
54
+ "5. On CONFLICT with an existing live decision for the topic, do NOT auto-supersede β€” Hunch refuses and presents both; let the human choose to supersede (link), split the topic, or discard.",
55
+ "",
56
+ "Required before commit: topic, title, decision, context (the rationale/why), alternatives_rejected. Missing any β†’ keep grilling.",
57
+ ].join("\n");
58
+ }
37
59
  /** Resolve a free-form target (symbol id / name / file path) to symbol records. */
38
60
  function resolveSymbols(store, target) {
39
61
  target = toPosixTarget(target);
@@ -252,6 +274,39 @@ export function buildServer(root) {
252
274
  });
253
275
  return ok(`Decision timeline for "${target}" (newest first):\n${lines.join("\n")}`);
254
276
  });
277
+ // -- hunch_capture_decision (decision-grounding: the grilling front door) --
278
+ server.registerTool("hunch_capture_decision", {
279
+ title: "Capture a decision (grilling interview)",
280
+ description: "Start a decision-capture interview: returns the grilling protocol (interrogate ONE question at a time until the decision tree is resolved) plus a capture-session token. Grill the human, then commit via hunch_record_decision with the token + confirmed topic. Use for '/capture', 'record this decision', 'grill me on this'. The token proves the write is the tail of an interview, not a silent guess.",
281
+ inputSchema: {
282
+ topic: z.string().optional().describe("proposed topic anchor (confirm with the human before committing)"),
283
+ seed: z.string().optional().describe("what the decision is about, to focus the first question"),
284
+ },
285
+ }, async ({ topic, seed }) => {
286
+ const token = issueCaptureToken();
287
+ return ok(`${grillingProtocol(topic, token)}${seed ? `\n\nSeed: ${seed}` : ""}`);
288
+ });
289
+ // -- hunch_current_decision (decision-grounding: current(topic)) ----------
290
+ server.registerTool("hunch_current_decision", {
291
+ title: "Current decision for a topic",
292
+ description: "Decision-grounding: return the single CURRENT (accepted, non-superseded) decision anchored to a topic β€” the authoritative answer a doc or diff is checked against, plus what it rejected. If a topic has NO current decision, or an unresolved collision (>1 live), it says so and injects nothing (fail-safe).",
293
+ inputSchema: { topic: z.string().describe("the decision anchor, e.g. 'auth-transport'") },
294
+ }, async ({ topic }) => {
295
+ const decs = store.recs("decisions");
296
+ const live = liveForTopic(decs, topic);
297
+ if (live.length === 0)
298
+ return ok(`No current decision for topic "${topic}". (Un-anchored, or never captured.)`);
299
+ if (live.length > 1) {
300
+ const list = live.map((d) => `${d.id} ("${d.title}")`).join(", ");
301
+ return ok(`Topic "${topic}" has an UNRESOLVED collision (${live.length} live decisions): ${list}.\nGrounding injects nothing until this is resolved β€” supersede one, or split the topic.`);
302
+ }
303
+ const d = live[0];
304
+ const rejected = rejectedForTopic(decs, topic);
305
+ const rej = rejected.length ? `\n rejected: ${rejected.join("; ")}` : "";
306
+ const hist = historyForTopic(decs, topic);
307
+ const chain = hist.length > 1 ? `\n history: ${hist.length} decisions on this topic (current is newest)` : "";
308
+ return ok(`Current decision for "${topic}": ${d.id} β€” "${d.title}" (${d.status}).\n ${d.decision}${rej}${chain}${provLine(d)}`);
309
+ });
255
310
  // -- hunch_record_decision (write-back) -----------------------------------
256
311
  server.registerTool("hunch_record_decision", {
257
312
  title: "Record a decision (write-back)",
@@ -265,13 +320,15 @@ export function buildServer(root) {
265
320
  alternatives_rejected: z.array(z.string()).optional(),
266
321
  related_files: z.array(z.string()).optional(),
267
322
  related_components: z.array(z.string()).optional(),
323
+ topic: z.string().optional().describe("decision-grounding anchor — one topic per decision; enables doc≠graph drift detection for it. Omit to leave un-anchored."),
268
324
  status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
269
325
  commit: z.string().optional(),
270
326
  supersedes: z.string().optional().describe("id of a decision this one replaces β€” closes its valid-time window (invalidate, don't delete)"),
271
327
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo β€” for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
272
328
  }),
329
+ capture_token: z.string().optional().describe("token from hunch_capture_decision β€” proves this write is the tail of a grilling interview. Omit only for a quick manual record (a deprecation nudge is returned)."),
273
330
  },
274
- }, async ({ decision }) => {
331
+ }, async ({ decision, capture_token }) => {
275
332
  try {
276
333
  // Commit-keyed on the CANONICAL full sha (resolved via git rev-parse), so a
277
334
  // human passing the short sha they see in `commit` produces the SAME id as
@@ -294,6 +351,7 @@ export function buildServer(root) {
294
351
  const rec = {
295
352
  id,
296
353
  title: decision.title,
354
+ topic: decision.topic ?? existing?.topic ?? null,
297
355
  status: decision.status ?? "accepted",
298
356
  context: decision.context ?? existing?.context ?? "",
299
357
  decision: decision.decision ?? existing?.decision ?? "",
@@ -312,6 +370,27 @@ export function buildServer(root) {
312
370
  provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
313
371
  date: now,
314
372
  };
373
+ // Decision-grounding uniqueness guard (Β§4 Enforcement): never create a SECOND
374
+ // live decision for one topic. Exclude ONLY the incumbent this write will
375
+ // actually close β€” one resolvable in the SAME store the write lands in. A
376
+ // cross-store supersede (public write vs a private incumbent, or vice-versa)
377
+ // would no-op and leave two live decisions, so it is treated as unresolved
378
+ // (willClose=null) β†’ the guard fires and refuses. Same-id re-record is allowed.
379
+ if (rec.topic && rec.status === "accepted") {
380
+ const willClose = decision.supersedes && store.decisionInStore(decision.supersedes, !!decision.private)
381
+ ? decision.supersedes
382
+ : null;
383
+ const others = captureConflicts(store.recs("decisions"), rec.topic, id, willClose);
384
+ if (others.length) {
385
+ const list = others.map((d) => `${d.id} ("${d.title}")`).join(", ");
386
+ const crossStore = decision.supersedes && !willClose
387
+ ? ` (note: supersedes:"${decision.supersedes}" is not in the ${decision.private ? "private" : "public"} store, so it can't be closed from here)`
388
+ : "";
389
+ return err(`Topic "${rec.topic}" already has a live decision: ${list}.${crossStore} ` +
390
+ `Hunch will not create a second current decision for one topic. Resolve it: ` +
391
+ `re-record with supersedes:<id> to replace it (linked, same store), pick a distinct topic to split, or discard this capture.`);
392
+ }
393
+ }
315
394
  // Route the write: private records go to the HUNCH_PRIVATE_DIR overlay (never
316
395
  // the committed repo); everything else to the public store. putPrivate throws
317
396
  // if no private store is configured, so "private" can't silently fall public.
@@ -335,10 +414,20 @@ export function buildServer(root) {
335
414
  commitAndPushHunch(store.privateDir, `hunch: capture ${id}`);
336
415
  flushed = " (committed + pushed to the private repo)";
337
416
  }
417
+ // Capture-session gate (staged deprecation, Β§9.3): a token proves an interview
418
+ // preceded the write. No token still writes (non-breaking), but returns a nudge
419
+ // toward /capture so the un-interviewed bypass is visible, not silent. A token
420
+ // presented but unknown to THIS process (server restart/expiry) is not shamed.
421
+ const gated = consumeCaptureToken(capture_token);
422
+ const captureNote = gated
423
+ ? " [via capture front door]"
424
+ : capture_token
425
+ ? ""
426
+ : "\n\n⚠ Recorded WITHOUT a capture interview. Prefer /capture (hunch_capture_decision), which grills the decision to a resolved state before writing β€” the graph should hold a well-examined decision, not a guess. (A future major version will require a capture token here.)";
338
427
  const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
339
428
  const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved β€” recorded as a standalone decision, not linked to a commit)` : "";
340
429
  const where = decision.private ? ` [PRIVATE overlay β€” not committed to this repo]${flushed}` : "";
341
- return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}`);
430
+ return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}${captureNote}`);
342
431
  }
343
432
  catch (e) {
344
433
  return err(`Failed to record decision: ${e.message}`);
@@ -767,6 +767,15 @@ export class HunchStore {
767
767
  supersede(oldId, by) {
768
768
  return this.supersedeIn(this.json, oldId, by);
769
769
  }
770
+ /** Look up a decision by id in a SPECIFIC store β€” the public store, or the private
771
+ * overlay when `priv` is true β€” NOT the union. The capture guard uses this to know
772
+ * whether a supersede will actually close its target: `supersede`/`supersedePrivate`
773
+ * each look in only one store, so a cross-store supersede silently no-ops and would
774
+ * leave two live decisions on one topic. Returns undefined if absent (or no overlay). */
775
+ decisionInStore(id, priv) {
776
+ const store = priv ? this.privateJson : this.json;
777
+ return store?.get("decisions", id);
778
+ }
770
779
  /** Private-overlay counterpart of `supersede`: close + link the old decision inside
771
780
  * the HUNCH_PRIVATE_DIR store, so a PRIVATE decision can supersede another private
772
781
  * one (the MCP record path is privateβ†’private). A private write never mutates the
@@ -121,6 +121,9 @@ export async function syncCommit(store, root, sha, opts = {}) {
121
121
  const decision = {
122
122
  id,
123
123
  title: draft.title,
124
+ // Auto-synthesized decisions are un-anchored (topic null) β€” a topic is a human
125
+ // act, never a machine guess. Preserve one an earlier human capture attached.
126
+ topic: existing?.topic ?? null,
124
127
  status: existing?.status === "accepted" ? "accepted" : "proposed",
125
128
  context: draft.context + constraintNote,
126
129
  decision: draft.decision,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.38.2",
3
+ "version": "0.39.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).",