@davesheffer/hunch 0.38.3 → 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/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";
@@ -721,7 +722,7 @@ program
721
722
  const id = decisionId(`inline:${it.file}:${it.text}`);
722
723
  const prev = store.recs("decisions").find((d) => d.id === id); // preserve window for idempotent re-capture
723
724
  const rec = {
724
- id, title: it.text, status: "accepted",
725
+ id, title: it.text, topic: prev?.topic ?? null, status: "accepted",
725
726
  context: `Captured from an inline hunch-why comment (${it.file}:${it.line}).`,
726
727
  decision: it.text, consequences: [], alternatives_rejected: [], rejected_tripwires: [],
727
728
  related_components: [], related_files: [it.file], supersedes: null, superseded_by: null,
@@ -1534,6 +1535,11 @@ program
1534
1535
  const items = retired.map((r) => `${[...r.symbols, ...r.deps].join(", ")} (${r.decision})`).join("; ");
1535
1536
  text += `\n\n⚠ Deliberately RETIRED from this file — do not re-introduce without cause: ${items}.`;
1536
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}`;
1537
1543
  emitContext("PreToolUse", text);
1538
1544
  }
1539
1545
  catch {
@@ -1680,6 +1686,80 @@ program
1680
1686
  }
1681
1687
  store.close();
1682
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
+ });
1683
1763
  // ---- compact (bound Hunch growth) -----------------------------------------
1684
1764
  program
1685
1765
  .command("compact")
@@ -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(""),
@@ -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.3",
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).",