@davesheffer/hunch 0.38.3 → 0.40.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
@@ -98,6 +98,45 @@ combination is the moat:
98
98
  The short version: **git tracks *what* changed; Hunch tracks *why*** — locally, durably, and under
99
99
  your control, with guards that actually hold the line instead of just suggesting.
100
100
 
101
+ ## Decision-grounding: memory that stays true to the doc
102
+
103
+ Architectural Conformance keeps the *code* honest to the graph (**graph ≠ code**). Decision-grounding
104
+ is its complement — it keeps your *docs* honest to the graph (**doc ≠ graph**). A comment or a README
105
+ says one thing; the decision that actually governs the file says another. Both are "memory that stays
106
+ true"; you want both.
107
+
108
+ The anchor is one optional field. A decision can carry a **`topic`** — the thing it's the current answer
109
+ for (e.g. `"auth.session"`) — and topic gives you a query contract: **current** (the one live answer),
110
+ **history** (the supersede trail), and **rejected** (what was ruled out and why). It's fully
111
+ backward-compatible: `topic` defaults to `null`, there's **no schema bump**, and existing graphs load
112
+ unchanged.
113
+
114
+ - **Read-time grounding.** The pre-edit (PreToolUse) hook now surfaces a file's topic-anchored decisions
115
+ *before* the AI writes — with doc-precedence framing ("follow the graph, not a stale doc") and what each
116
+ decision **rejected**, so the model doesn't happily re-add the approach you already ruled out.
117
+ - **`anchor-stale` drift — deterministic, no guessing.** A new drift kind fires when a file is still
118
+ anchored to a **superseded** decision while a **current** one exists for its topic. It shows up in
119
+ `hunch doctor` and in a CI-gateable `hunch drift`:
120
+
121
+ ```bash
122
+ hunch drift # ⛔ exits non-zero on anchor-stale drift or a topic collision (>1 live decision)
123
+ ```
124
+
125
+ It only fires on **explicit** topic anchors — no semantic guessing, no false positives on prose it can't
126
+ read.
127
+ - **Capture, gated.** `hunch_record_decision` now enforces a store-scoped **uniqueness guard**: it refuses
128
+ a *second* live decision for a topic (you're never silently governed by two). The richer path is the new
129
+ **`hunch_capture_decision`** tool — it returns a one-question-at-a-time grilling protocol plus a
130
+ capture-session token; `record_decision` accepts an optional `capture_token`. Un-token'd writes still
131
+ work, they just get a nudge toward `/capture`. **`hunch_current_decision(topic)`** returns the one answer
132
+ that currently governs a topic.
133
+ - **`hunch reconcile-topics`.** A git merge is the one thing that can create two live decisions for a
134
+ topic. This scans for it and exits non-zero — wire it into a post-merge hook or CI.
135
+ - **`hunch heal`** + the **`/capture`** and **`/heal`** slash commands (scaffolded by `hunch init`) do
136
+ **read-only** doc↔graph reconciliation — they surface the mismatch and never rewrite your prose silently.
137
+
138
+ → [docs](https://hunch-pi.vercel.app/docs#grounding)
139
+
101
140
  ## Getting started
102
141
 
103
142
  ```bash
@@ -182,6 +221,10 @@ defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag
182
221
  existing location. **Advisory** — it never blocks, and it's tuned to stay quiet so a refactor
183
222
  that just moves code isn't mistaken for a duplicate. → [docs](https://hunch-pi.vercel.app/docs#redundancy)
184
223
 
224
+ See also **[Decision-grounding](#decision-grounding-memory-that-stays-true-to-the-doc)** — the doc ≠ graph
225
+ complement: topic anchors, read-time grounding in the pre-edit hook, and a deterministic `anchor-stale`
226
+ drift check (`hunch drift`) that fails CI when a file still points at a superseded decision.
227
+
185
228
  Plus the **Regression Guard** (re-adding deliberately-retired code) and the
186
229
  **[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
187
230
  comments the affected `con_`/`dec_` ids and fails on a blocking one).
@@ -207,9 +250,16 @@ Windows / macOS / Linux teammates share one memory with no per-machine fixups.
207
250
  Memory follows you across every branch and **git worktree**, with no per-worktree setup — a
208
251
  fresh `git worktree add` on any branch sees the same decisions, bugs, and invariants. Create one
209
252
  already wired in with **`hunch worktree <path> [-b <branch>]`**, or just run `hunch init` / `hunch
210
- private` once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
253
+ shared` (or `hunch private`) once and every worktree picks it up. Parallel worktrees never corrupt or lose memory, and
211
254
  `hunch doctor` confirms a worktree is sharing.
212
255
 
256
+ Need one **single source of truth** for memory in any repo (private or public)?
257
+ Use **`hunch shared --repo <url>`**. Every capture — decisions, bugs, constraints, runbooks —
258
+ routes to one shared overlay repo and, by default, auto-commits + pushes so teammates/other
259
+ worktrees stay in sync automatically. It also publishes a committed **`.hunch/team.json`**
260
+ pointing at the store, so a fresh clone auto-connects on `hunch init` (agents and CI wire up
261
+ the same way via the MCP server) — everyone, on every branch, resolves the same memory.
262
+
213
263
  ## Private memory (public repo, private context)
214
264
 
215
265
  Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
@@ -220,8 +270,10 @@ env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-sh
220
270
  default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
221
271
  the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
222
272
  sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
223
- post-commit synthesis can route there too, and `hunch private --auto-commit` (opt-in)
224
- auto-commits + pushes each capture to the private repo recursion-safe, staging only `.hunch/`.
273
+ post-commit synthesis can route there too. Every capture is **auto-committed by default** to the
274
+ store it lands in the private repo is committed + pushed; a public capture is committed to
275
+ `.hunch/` only and rides your next push (Hunch never pushes or merges your code branch) —
276
+ recursion-safe, staging only `.hunch/`. Opt out with `--no-auto-commit`.
225
277
 
226
278
  Already published a repo *with* its `.hunch/` memory and want it private after the fact?
227
279
  `hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
package/dist/cli/index.js CHANGED
@@ -28,7 +28,8 @@ import { indexRepo } from "../extractors/indexer.js";
28
28
  import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
29
29
  import { parseTestReport } from "../extractors/testreport.js";
30
30
  import { selectProvider } from "../synthesis/provider.js";
31
- import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, rangeSubjects, revExists, commitAndPushHunch, gitUntrackCached, gitCommonDir, isLinkedWorktree } from "../extractors/git.js";
31
+ 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 { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrations/team.js";
32
33
  import { runbookId, decisionId } from "../core/ids.js";
33
34
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
34
35
  import { extractInlineIntent } from "../extractors/comments.js";
@@ -36,7 +37,7 @@ import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkrepo
36
37
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
37
38
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
38
39
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
39
- import { flushPrivate } from "../integrations/sync.js";
40
+ import { flushCapture } from "../integrations/sync.js";
40
41
  import { installMergeDriver } from "../integrations/mergeDriver.js";
41
42
  import { ensureGitignore, ignoreHunchMemory, HUNCH_MEMORY_DIRS } from "../integrations/gitignore.js";
42
43
  import { writeCiWorkflow } from "../integrations/ciAction.js";
@@ -50,6 +51,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
50
51
  import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
51
52
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
52
53
  import { computeDrift } from "../core/drift.js";
54
+ import { topicCollisions, renderGrounding } from "../core/topics.js";
53
55
  import { compareCandidates } from "../core/compare.js";
54
56
  import { checkConformance } from "../core/conformance.js";
55
57
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
@@ -81,7 +83,7 @@ program
81
83
  .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
82
84
  .option("--private-sync", "post-commit synthesis writes captured decisions into the overlay repo (HUNCH_PRIVATE_DIR), never the public store")
83
85
  .option("--shared-sync", "alias of --private-sync (for teams using one shared overlay repo for any code repo)")
84
- .option("--auto-commit", "opt-in: the post-commit hook also git add+commit+pushes the captured decision (the repo it landed in)")
86
+ .option("--no-auto-commit", "DON'T auto-commit captures (default: ON in every mode — the overlay repo is committed+pushed; the public .hunch/ is committed only and rides your next push)")
85
87
  .action((opts) => {
86
88
  // Validate --firmness up front, before any side effects (indexing, git hooks,
87
89
  // .mcp.json) or opening the store — a bad value must not leave a half-init.
@@ -90,12 +92,18 @@ program
90
92
  }
91
93
  const root = findRoot();
92
94
  const paths = hunchPaths(root);
95
+ // Team auto-discovery FIRST: a committed .hunch/team.json advertises the shared
96
+ // store — a fresh clone wires itself to it before anything reads memory, so every
97
+ // teammate/agent resolves the same single source of truth with zero manual setup.
98
+ const teamWired = ensureTeamOverlay(root);
93
99
  const store = new HunchStore(paths);
94
100
  openStore = store; // so the top-level error handler closes it on failure
95
101
  const inv = resolveInvocation();
96
102
  console.log(`🧠 Initializing Hunch at ${root}`);
97
103
  store.json.ensureDirs(); // stamps the manifest at the current version when fresh
98
104
  console.log(` ✓ .hunch/ scaffolded (schema v${readManifest(paths).schema_version})`);
105
+ if (teamWired)
106
+ console.log(` ✓ connected to the team's shared memory store (from .hunch/team.json) → ${teamWired}`);
99
107
  // Exclude the derived SQLite index BEFORE it's written, so the working tree
100
108
  // never goes dirty on the MCP server's index writes (which blocks branch
101
109
  // switches). The .hunch/*.json graph stays tracked.
@@ -109,10 +117,22 @@ program
109
117
  if (res.skipped)
110
118
  console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
111
119
  }
120
+ // Auto-commit is ON by default in every mode; --no-auto-commit persists the opt-out in
121
+ // the gitignored local.json (merge — never clobber an existing overlay pointer).
122
+ if (opts.autoCommit === false) {
123
+ const localFile = join(paths.hunch, "local.json");
124
+ let existing = {};
125
+ try {
126
+ existing = JSON.parse(readFileSync(localFile, "utf8"));
127
+ }
128
+ catch { /* absent/invalid → fresh */ }
129
+ writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
130
+ console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
131
+ }
112
132
  if (isGitRepo(root)) {
113
133
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
114
134
  const h = installPostCommitHook(root, inv.shell, { private: syncToOverlay, commit: opts.autoCommit });
115
- console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit+push on" : ""}`);
135
+ console.log(` ✓ post-commit hook ${h.action} (learning loop)${syncToOverlay ? " — syncs to the shared overlay" : ""}${opts.autoCommit ? " — auto-commit on" : ""}`);
116
136
  const m = installMergeDriver(root, inv.shell);
117
137
  console.log(` ✓ team merge driver ${m.action}`);
118
138
  // Auto-install the pre-commit guard by default (advisory: flags invariants
@@ -163,7 +183,7 @@ program
163
183
  // Worktree-seamless: register any configured overlay at the git common dir so EVERY
164
184
  // worktree of this repo auto-discovers it (also backfills pre-0.32 single-worktree setups),
165
185
  // and note when we're initializing inside a linked worktree (memory is shared, not separate).
166
- if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit)) {
186
+ if (ensureSharedOverlayPointer(root, store.privateDir, store.privateAutoCommit, store.mode === "shared" ? "shared" : "private")) {
167
187
  console.log(` ✓ private overlay registered at the git common dir — shared by every worktree of this repo`);
168
188
  }
169
189
  if (isLinkedWorktree(root)) {
@@ -270,7 +290,8 @@ program
270
290
  .option("--force", "re-synthesize even if a decision already exists for the commit")
271
291
  .option("--private", "write the synthesized decision into the configured overlay (HUNCH_PRIVATE_DIR), not the public store")
272
292
  .option("--overlay", "alias of --private")
273
- .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")
293
+ .option("--commit", "after a capture, also git add+commit the repo the decision landed in (default: follows auto-commit, ON unless opted out) — the overlay is also pushed; the public .hunch/ rides your next push")
294
+ .option("--no-commit", "skip the auto-commit for this capture even when auto-commit is on")
274
295
  .option("--deep", "Deep Synthesis: ensemble every available subscription CLI and reconcile their drafts (agreement-weighted, advisory). Slower; subscription-only")
275
296
  .option("--verify", "Critic pass: audit the draft against its commit, prune unsupported alternatives/consequences, down-weight weak grounding (extra subscription call; advisory)")
276
297
  .option("--samples <n>", "self-consistency depth when only one CLI is installed: sample it n times and reconcile (default 2 under --deep)")
@@ -278,7 +299,9 @@ program
278
299
  const { store, root } = storeFor();
279
300
  if (!isGitRepo(root))
280
301
  return opts.quiet ? undefined : fail("sync needs a git repo");
281
- const toOverlay = !!(opts.private || opts.overlay);
302
+ // In unified ("shared") mode every capture routes to the overlay — the sync path
303
+ // must agree with captureHome so all writers home records identically.
304
+ const toOverlay = !!(opts.private || opts.overlay || store.unified);
282
305
  if (toOverlay && !store.hasPrivate) {
283
306
  store.close();
284
307
  return opts.quiet ? undefined : fail("--private/--overlay needs HUNCH_PRIVATE_DIR set to an overlay store");
@@ -295,16 +318,20 @@ program
295
318
  if (healed.length && !opts.quiet)
296
319
  console.log(` ↳ grounding refreshed: ${healed.join(", ")}`);
297
320
  }
298
- // Opt-in: persist the captured decision in the repo it landed in (private store
299
- // under --private, else this repo). Best-effort a non-repo dir / offline push
321
+ // Persist the captured decision in the repo it landed in (private store under
322
+ // --private, else this repo). ON by default (follows auto-commit; --no-commit or
323
+ // `--no-auto-commit` at setup opts out). Best-effort — a non-repo dir / offline push
300
324
  // just no-ops. Stage ONLY the hunch dir (never sweep unrelated working-tree
301
325
  // changes), and set HUNCH_SYNC=1 so the commit we create can't re-trigger this
302
- // hook (no recursion, including on a manual `hunch sync --commit`).
303
- const commitTarget = opts.commit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
326
+ // hook (no recursion). The overlay is pushed; the public .hunch/ is committed
327
+ // WITHOUT pushing auto-pushing the user's code branch would publish their
328
+ // unpushed commits (bug_overlay_clobber lineage).
329
+ const doCommit = opts.commit ?? store.autoCommit;
330
+ const commitTarget = doCommit ? (toOverlay ? store.privateDir : hunchPaths(root).hunch) : undefined;
304
331
  if (commitTarget) {
305
- commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`);
332
+ commitAndPushHunch(commitTarget, `hunch: capture ${r.decision?.id ?? "decision"}`, { push: toOverlay });
306
333
  if (!opts.quiet)
307
- console.log(` ↳ committed + pushed ${r.decision?.id} (${commitTarget})`);
334
+ console.log(` ↳ committed ${toOverlay ? "+ pushed " : ""}${r.decision?.id} (${commitTarget}${toOverlay ? "" : " — rides your next push"})`);
308
335
  }
309
336
  if (!opts.quiet)
310
337
  console.log(`✓ captured decision ${r.decision?.id} via ${r.provider}: "${r.decision?.title}"`);
@@ -330,17 +357,44 @@ function configureOverlay(dir, opts, mode) {
330
357
  }
331
358
  // 1) resolve the overlay store's hunch dir (holds decisions/, bugs/, …)
332
359
  let hunchDir;
360
+ // Anchor the default store at the MAIN worktree root: a linked worktree can be
361
+ // `git worktree remove`d, which would take the store (and every other worktree's
362
+ // absolute pointer to it) down with it. An explicit [dir] still resolves from here.
363
+ const anchor = mainWorktreeRoot(root);
333
364
  if (opts.repo) {
334
- const dest = join(root, ".hunch-private");
365
+ const dest = join(anchor, ".hunch-private");
335
366
  if (!existsSync(dest)) {
336
367
  const r = spawnSync("git", ["clone", opts.repo, dest], { stdio: "inherit" });
337
368
  if (r.status !== 0)
338
369
  return fail(`git clone failed for ${opts.repo}`);
339
370
  }
371
+ else {
372
+ // NEVER silently ignore --repo when the store dir already exists: same remote →
373
+ // freshen; no remote → attach + converge; different remote → refuse loudly.
374
+ const cur = spawnSync("git", ["-C", dest, "remote", "get-url", "origin"], { encoding: "utf8" });
375
+ const existingUrl = cur.status === 0 ? cur.stdout.trim() : "";
376
+ if (existingUrl === opts.repo) {
377
+ pullHunch(join(dest, ".hunch"));
378
+ console.log(` · ${dest} already tracks ${opts.repo} — pulled the latest memory`);
379
+ }
380
+ else if (!existingUrl) {
381
+ if (!isGitRepo(dest))
382
+ spawnSync("git", ["init", "-q", dest], { stdio: "ignore" });
383
+ spawnSync("git", ["-C", dest, "remote", "add", "origin", opts.repo], { stdio: "ignore" });
384
+ spawnSync("git", ["-C", dest, "fetch", "-q", "origin"], { stdio: "ignore" });
385
+ spawnSync("git", ["-C", dest, "merge", "-q", "--no-edit", "--allow-unrelated-histories", "FETCH_HEAD"], { stdio: "ignore" });
386
+ spawnSync("git", ["-C", dest, "push", "-q", "-u", "origin", "HEAD"], { stdio: "ignore" });
387
+ console.log(` · attached the existing local store ${dest} to ${opts.repo} (merged + pushed, best-effort)`);
388
+ }
389
+ else {
390
+ return fail(`${dest} already tracks a DIFFERENT remote:\n current: ${existingUrl}\n requested: ${opts.repo}\n` +
391
+ `Refusing to silently re-point your memory. Move that directory aside, or pass an explicit dir: \`hunch ${commandName} <dir> --repo <url>\`.`);
392
+ }
393
+ }
340
394
  hunchDir = join(dest, ".hunch");
341
395
  }
342
396
  else {
343
- hunchDir = dir ? resolve(root, dir) : join(root, ".hunch-private", ".hunch");
397
+ hunchDir = dir ? resolve(root, dir) : join(anchor, ".hunch-private", ".hunch");
344
398
  }
345
399
  // 2) create the layout (decisions/, manifest, …) so it's queryable immediately
346
400
  new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
@@ -366,14 +420,23 @@ function configureOverlay(dir, opts, mode) {
366
420
  // a store elsewhere on disk. Resolution (env || local.json) re-resolves against root.
367
421
  const rel = relative(root, hunchDir);
368
422
  const stored = rel && !rel.startsWith("..") && !isAbsolute(rel) ? toPosixTarget(rel) : hunchDir;
369
- writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit }, null, 2) + "\n");
423
+ writeFileAtomic(join(paths.hunch, "local.json"), JSON.stringify({ privateDir: stored, autoCommit: !!opts.autoCommit, mode }, null, 2) + "\n");
370
424
  ensureGitignore(root); // keeps .hunch/local.json + .hunch-private/ out of git
425
+ // SHARED mode with a remote: publish the store's URL in a COMMITTED team.json, so a
426
+ // fresh clone / new teammate / headless agent auto-connects on `hunch init` (or MCP
427
+ // server start) — everyone resolves the same single source of truth. Private mode
428
+ // never publishes its URL.
429
+ let teamNote = "";
430
+ if (mode === "shared" && opts.repo) {
431
+ writeTeamConfig(root, { shared_repo: opts.repo });
432
+ teamNote = " ✓ published .hunch/team.json (commit it) — teammates, worktrees, and agents auto-connect\n";
433
+ }
371
434
  // Also register the overlay at the SHARED git common dir, so EVERY worktree of this repo
372
435
  // (current + future, any branch) auto-discovers the same memory with zero per-worktree
373
436
  // setup. Stored ABSOLUTE — a linked worktree resolves relative paths from its OWN root, so
374
437
  // only an absolute path survives the move. Lives under .git/ (never tracked; nothing to ignore).
375
438
  let worktreeNote = "";
376
- if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit)) {
439
+ if (ensureSharedOverlayPointer(root, hunchDir, !!opts.autoCommit, mode)) {
377
440
  worktreeNote = " ✓ registered in the git common dir — shared by every worktree of this repo, on any branch\n";
378
441
  }
379
442
  // 4) route post-commit synthesis to the overlay (local hook, never committed)
@@ -413,10 +476,11 @@ function configureOverlay(dir, opts, mode) {
413
476
  : `✓ shared overlay enabled → ${hunchDir}\n`;
414
477
  const tail = mode === "private"
415
478
  ? " 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."
416
- : " 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.";
479
+ : " UNIFIED: every capture (decisions, bugs, constraints, runbooks) routes HERE one source of truth\n across branches, worktrees, teammates, and agents. Override per-shell with HUNCH_PRIVATE_DIR if needed.";
417
480
  console.log(lead +
418
481
  " ✓ recorded in .hunch/local.json (gitignored) — auto-detected, no env var or shell-profile edit\n" +
419
482
  worktreeNote +
483
+ teamNote +
420
484
  hookNote +
421
485
  migrateNote +
422
486
  tail);
@@ -692,10 +756,7 @@ program
692
756
  provenance: { source: "extracted", confidence: 0.5, evidence: [range] },
693
757
  date: now,
694
758
  };
695
- if (opts.private)
696
- store.putPrivate("runbooks", rec);
697
- else
698
- store.json.put("runbooks", rec);
759
+ store.putCapture("runbooks", rec, opts.private);
699
760
  store.reindex();
700
761
  console.log(`✓ runbook ${rec.id} — "${rec.task}" (${rec.steps.length} steps, ${rec.files.length} files)${opts.private ? " [private overlay]" : ""}`);
701
762
  console.log(dim(" advisory, deterministic draft — refine the steps/gotchas; surfaced via `hunch query` and MCP."));
@@ -721,7 +782,7 @@ program
721
782
  const id = decisionId(`inline:${it.file}:${it.text}`);
722
783
  const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
723
784
  const rec = {
724
- id, title: it.text, status: "accepted",
785
+ id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
725
786
  context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
726
787
  decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
727
788
  related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
@@ -729,10 +790,7 @@ program
729
790
  retired: { symbols: [], deps: [] },
730
791
  provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev }, date: prev?.date ?? now,
731
792
  };
732
- if (opts.private)
733
- store.putPrivate("decisions", rec);
734
- else
735
- store.json.put("decisions", rec);
793
+ store.putCapture("decisions", rec, opts.private);
736
794
  dec++;
737
795
  }
738
796
  else {
@@ -748,16 +806,13 @@ program
748
806
  valid_from: prev?.valid_from ?? now, valid_to: null,
749
807
  provenance: { source: "human_confirmed", confidence: 0.9, evidence: ev },
750
808
  };
751
- if (opts.private)
752
- store.putPrivate("constraints", rec);
753
- else
754
- store.json.put("constraints", rec);
809
+ store.putCapture("constraints", rec, opts.private);
755
810
  con++;
756
811
  }
757
812
  }
758
813
  store.reindex();
759
- if (opts.private && (dec || con))
760
- flushPrivate(store, `hunch: capture ${dec + con} inline intent(s)`);
814
+ if (dec || con)
815
+ flushCapture(store, hunchPaths(root).hunch, !!opts.private, `hunch: capture ${dec + con} inline intent(s)`);
761
816
  if (!intents.length)
762
817
  console.log("No `hunch-why:` / `hunch-rule:` comments found.");
763
818
  else
@@ -789,7 +844,7 @@ program
789
844
  store.json.ensureDirs();
790
845
  const now = new Date().toISOString();
791
846
  const arrow = opts.assert.startsWith("not-") ? "↛" : "→";
792
- const d = store.json.put("decisions", {
847
+ const d = store.putCapture("decisions", {
793
848
  id: decisionId(`conform:${opts.add}:${opts.subject}:${opts.object ?? ""}`),
794
849
  title: opts.add,
795
850
  status: "accepted",
@@ -1022,7 +1077,7 @@ program
1022
1077
  forbids = deriveForbids(statement, deps.length ? deps : undefined);
1023
1078
  derived = !!forbids;
1024
1079
  }
1025
- const c = store.json.put("constraints", {
1080
+ const c = store.putCapture("constraints", {
1026
1081
  id: constraintId(statement),
1027
1082
  type: opts.type,
1028
1083
  statement,
@@ -1307,7 +1362,7 @@ vetoCmd
1307
1362
  if ((d.rejected_tripwires?.length ?? 0) > 0)
1308
1363
  continue; // never clobber existing tripwires
1309
1364
  const tws = draftTripwires(d.alternatives_rejected, d.related_files, knownDeps);
1310
- store.json.put("decisions", { ...d, rejected_tripwires: tws });
1365
+ store.putWhereItLives("decisions", { ...d, rejected_tripwires: tws });
1311
1366
  drafted += tws.length;
1312
1367
  touched++;
1313
1368
  }
@@ -1534,6 +1589,11 @@ program
1534
1589
  const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
1535
1590
  text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
1536
1591
  }
1592
+ // Decision-grounding (§3): for topic-anchored decisions governing this file, state
1593
+ // the current decision assertively (graph over any stale doc) + what it rejected.
1594
+ const grounding = renderGrounding(ctx.decisions);
1595
+ if (grounding)
1596
+ text += `\n\n${grounding}`;
1537
1597
  emitContext("PreToolUse", text);
1538
1598
  }
1539
1599
  catch {
@@ -1563,7 +1623,7 @@ function acceptDecision(store, d) {
1563
1623
  last_verified: now,
1564
1624
  },
1565
1625
  }));
1566
- store.json.put("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
1626
+ store.putWhereItLives("decisions", { ...d, status: "accepted", rejected_tripwires: confirmedTws, provenance: { ...d.provenance, source, confidence: 0.95, last_verified: now } });
1567
1627
  const armed = confirmedTws.filter((tw) => tw.forbids.deps.length || tw.forbids.symbols.length || tw.forbids.patterns.length).length;
1568
1628
  return { source, armed };
1569
1629
  }
@@ -1680,6 +1740,80 @@ program
1680
1740
  }
1681
1741
  store.close();
1682
1742
  });
1743
+ // ---- reconcile-topics (decision-grounding §4 Enforcement) -----------------
1744
+ program
1745
+ .command("reconcile-topics")
1746
+ .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.")
1747
+ .action(() => {
1748
+ const { store } = storeFor();
1749
+ try {
1750
+ const collisions = topicCollisions(store.recs("decisions"));
1751
+ if (collisions.size === 0) {
1752
+ console.log("✓ No topic collisions — every topic has at most one live decision.");
1753
+ return;
1754
+ }
1755
+ 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`);
1756
+ for (const [topic, decs] of collisions) {
1757
+ console.error(` topic "${topic}":`);
1758
+ for (const d of decs)
1759
+ console.error(` - ${d.id} — "${d.title}" (${d.status})`);
1760
+ }
1761
+ console.error(`\nResolve: re-record one with supersedes:<other-id> to link it over the other, or give one a distinct topic to split.`);
1762
+ process.exitCode = 1;
1763
+ }
1764
+ finally {
1765
+ store.close();
1766
+ }
1767
+ });
1768
+ // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
1769
+ program
1770
+ .command("drift")
1771
+ .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.")
1772
+ .action(() => {
1773
+ const { store, root } = storeFor();
1774
+ try {
1775
+ const { findings } = computeDrift(store, root);
1776
+ const collisions = topicCollisions(store.recs("decisions"));
1777
+ if (!findings.length && collisions.size === 0) {
1778
+ console.log("✓ No drift — memory is in sync with the code/docs.");
1779
+ return;
1780
+ }
1781
+ for (const f of findings.slice(0, 50))
1782
+ console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
1783
+ for (const [topic, decs] of collisions)
1784
+ console.log(`· [topic-collision] "${topic}" has ${decs.length} live decisions: ${decs.map((d) => d.id).join(", ")} — run \`hunch reconcile-topics\``);
1785
+ const anchor = findings.filter((f) => f.kind === "anchor-stale").length;
1786
+ console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
1787
+ if (anchor || collisions.size)
1788
+ process.exitCode = 1;
1789
+ }
1790
+ finally {
1791
+ store.close();
1792
+ }
1793
+ });
1794
+ // ---- heal (decision-grounded drift reconciliation front door) -------------
1795
+ program
1796
+ .command("heal")
1797
+ .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.")
1798
+ .action(() => {
1799
+ const { store, root } = storeFor();
1800
+ try {
1801
+ const anchor = computeDrift(store, root).findings.filter((f) => f.kind === "anchor-stale");
1802
+ if (!anchor.length) {
1803
+ console.log("✓ No doc≠graph drift to heal — every anchored view matches its current decision.");
1804
+ return;
1805
+ }
1806
+ console.log(`${anchor.length} anchored section(s) drifted from the graph:\n`);
1807
+ for (const f of anchor)
1808
+ console.log(`· ${f.detail}`);
1809
+ console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision — a prose fix.`);
1810
+ 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.`);
1811
+ console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
1812
+ }
1813
+ finally {
1814
+ store.close();
1815
+ }
1816
+ });
1683
1817
  // ---- compact (bound Hunch growth) -----------------------------------------
1684
1818
  program
1685
1819
  .command("compact")
@@ -1776,9 +1910,24 @@ program
1776
1910
  }
1777
1911
  const c = store.reindex().counts;
1778
1912
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
1779
- console.log(store.privateDir
1780
- ? `private: on ${store.privateDir} (local overlay unioned into queries; never committed or posted publicly)`
1781
- : dim(`private: off run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1913
+ // Overlay status speaks the TRUE mode, and a dead pointer is a loud finding, not a
1914
+ // silent empty store: the JSON reader degrades to [] when the target dir is missing,
1915
+ // so this is the one place the loss is visible.
1916
+ if (store.privateDir && !existsSync(store.privateDir)) {
1917
+ console.log(`overlay: ⛔ POINTER IS DEAD → ${store.privateDir} does not exist — shared/private memory is NOT being read.`);
1918
+ console.log(` fix: re-run \`hunch ${store.mode === "shared" ? "shared" : "private"} --repo <url>\` (or restore the directory); the pointer lives in .hunch/local.json / the git common dir`);
1919
+ }
1920
+ else if (store.privateDir) {
1921
+ console.log(store.mode === "shared"
1922
+ ? `shared: on → ${store.privateDir} (UNIFIED — every capture routes here; one source of truth across branches, worktrees, teammates, agents)`
1923
+ : `private: on → ${store.privateDir} (local overlay — unioned into queries; never committed or posted publicly)`);
1924
+ }
1925
+ else {
1926
+ const team = readTeamConfig(root);
1927
+ console.log(team
1928
+ ? `overlay: off, but .hunch/team.json advertises the team store (${team.shared_repo}) — run \`hunch init\` to auto-connect`
1929
+ : dim(`private: off — run \`hunch shared\` (or \`hunch private\`) to use one overlay repo across teammates/worktrees (or set HUNCH_PRIVATE_DIR)`));
1930
+ }
1782
1931
  // Worktree posture: linked worktrees share ONE memory via the git common dir. Only
1783
1932
  // surfaced in a linked worktree (no noise in a normal single checkout), so a
1784
1933
  // "memory missing here" symptom has an obvious cause + fix.
@@ -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