@davesheffer/hunch 1.0.0 → 1.1.1

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
@@ -105,35 +105,21 @@ is its complement — it keeps your *docs* honest to the graph (**doc ≠ graph*
105
105
  says one thing; the decision that actually governs the file says another. Both are "memory that stays
106
106
  true"; you want both.
107
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.
108
+ A decision can be anchored to a **topic** (e.g. `"auth.session"`), and a topic always has one live
109
+ answer: the **current** decision, its **history**, and what was **rejected** along the way. Existing
110
+ graphs are unaffected until you opt in.
111
+
112
+ - **Read-time grounding.** Before the AI edits a file — or a markdown doc like `AGENTS.md` — it's told
113
+ which decision is *current* (follow the graph, not a stale doc) and what was **rejected**, so it
114
+ doesn't re-add the approach you already ruled out.
115
+ - **Drift, caught deterministically.** Prose or a file still describing a **superseded** decision is
116
+ flagged in `hunch doctor`, and `hunch drift` exits non-zero so CI can gate on it. It only fires on
117
+ explicit anchorsnever a semantic guess.
118
+ - **Capture, interviewed.** `/capture` walks a decision to a resolved state topic, rationale, and
119
+ rejected alternatives — before it's written, and the graph refuses to hold **two live decisions on
120
+ one topic**. `hunch reconcile-topics` catches the one case a git merge can create, for human resolution.
121
+ - **`hunch heal`** + the **`/heal`** slash command do **read-only** doc↔graph reconciliation — they show
122
+ exactly what disagrees and never rewrite your prose silently.
137
123
 
138
124
  → [docs](https://hunch-pi.vercel.app/docs#grounding)
139
125
 
@@ -147,6 +133,15 @@ hunch backfill --since 90d # cold start: seed decisions from recent git
147
133
  hunch why src/auth/session.ts # …then ask your assistant: "why is X built this way?"
148
134
  ```
149
135
 
136
+ **Claude Code users — one-step plugin install** (MCP tools + `/hunch:capture`, `/hunch:heal`, `/hunch:why`, `/hunch:fix`, `/hunch:fragile`):
137
+
138
+ ```text
139
+ /plugin marketplace add davesheffer/hunch
140
+ /plugin install hunch@hunch
141
+ ```
142
+
143
+ Then `hunch init` in each repo you want remembered (the plugin brings the tools; init builds the graph + hooks).
144
+
150
145
  `hunch init` scaffolds `.hunch/`, indexes the repo, installs the git hooks,
151
146
  writes `.mcp.json` + slash commands + an auto-maintained `CLAUDE.md`, and wires up **every
152
147
  detected assistant** (Claude Code, Cursor, VS Code/Copilot, Windsurf, Codex, Google Antigravity) to the same
@@ -230,12 +225,10 @@ Plus the **Regression Guard** (re-adding deliberately-retired code) and the
230
225
  comments the affected `con_`/`dec_` ids and fails on a blocking one).
231
226
 
232
227
  Name the actual violation — `record-constraint "…" --scope "src/**" --severity blocking
233
- --forbid-dep "lodash"` — and it blocks the *real* change across the file's whole life instead
234
- of relaxing to advisory after the file is edited again. The dep matcher reads the **parsed
235
- import**, so a comment or string naming the module can't false-positive and a submodule
236
- (`lodash/groupBy`) is still caught; a correction your assistant records gets the same matcher
237
- automatically. (`--match <regex>` remains a lint-grade textual fallback.) None of these are a
238
- bypass-proof boundary — deliberate indirection can still route around any rule.
228
+ --forbid-dep "lodash"` — and it blocks the *real* change for the file's whole life, while staying
229
+ quiet on edits that don't break the rule. A comment or string that merely mentions the module can't
230
+ false-positive. None of these are a bypass-proof boundary deliberate indirection can still route
231
+ around any rule.
239
232
 
240
233
  ## Working as a team
241
234
 
@@ -263,24 +256,15 @@ the same way via the MCP server) — everyone, on every branch, resolves the sam
263
256
  ## Private memory (public repo, private context)
264
257
 
265
258
  Open-source your code without open-sourcing your *reasoning*. **`hunch private`** sets up a
266
- separate private store in one command Hunch unions it into every query and guard **locally**
267
- (MCP and the pre-edit hook see your sensitive decisions/bugs/constraints) while your public
268
- `.hunch/` stays clean. It writes a gitignored `.hunch/local.json` so it's auto-detected **no
269
- env var, no shell-profile edit** (and `HUNCH_PRIVATE_DIR` still overrides per-shell). **Opt-in,
270
- default-off** (no config → fully inert), and **leak-safe by construction**: committed files and
271
- the CI PR comment render *public-only*, so a private record can't reach a public surface. Record
272
- sensitive items with `private: true` (`hunch_record_decision` / `hunch_record_correction`);
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`.
259
+ separate private store in one command: your local queries, guards, and assistants see the
260
+ sensitive decisions but they're never committed to the public repo, and public outputs (like
261
+ the CI PR comment) **can never contain them, by construction**. Opt-in, default-off; captures are
262
+ auto-committed to the store they land in (opt out with `--no-auto-commit`), and Hunch never
263
+ touches your code branch.
277
264
 
278
265
  Already published a repo *with* its `.hunch/` memory and want it private after the fact?
279
- `hunch private --repo <url> --migrate` does it in one shot: it **moves** your existing public
280
- records into the overlay (union by id nothing is lost), empties the public store, untracks +
281
- gitignores the `.hunch/` memory tree, and regenerates the assistant grounding (CLAUDE.md, AGENTS.md,
282
- …) so the repo becomes **code-only**. It commits the private overlay for you and prints the one
283
- `git` command to commit the now-clean public repo.
266
+ **`hunch private --repo <url> --migrate`** moves the existing memory into the private store
267
+ nothing lost and leaves the public repo code-only, telling you the one `git` command left to run.
284
268
  → [docs](https://hunch-pi.vercel.app/docs#private)
285
269
 
286
270
  ## Continuous learning (CI)
package/dist/cli/index.js CHANGED
@@ -33,7 +33,7 @@ import { writeTeamConfig, ensureTeamOverlay, readTeamConfig } from "../integrati
33
33
  import { runbookId, decisionId } from "../core/ids.js";
34
34
  import { deriveForbids, effectiveForbids } from "../core/constraintmatch.js";
35
35
  import { extractInlineIntent } from "../extractors/comments.js";
36
- import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
36
+ import { renderText, renderMarkdown, renderImpact, reportFailsStrict } from "../core/checkreport.js";
37
37
  import { partitionReview, READY_MIN_GROUNDED } from "../core/reviewqueue.js";
38
38
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
39
39
  import { ensureSharedOverlayPointer } from "../integrations/worktree.js";
@@ -52,6 +52,7 @@ import { loadGoldenSet, evaluateGraphLift } from "../eval/harness.js";
52
52
  import { loadGuardCases, evalGuards, generateGuardCases } from "../eval/guards.js";
53
53
  import { computeDrift } from "../core/drift.js";
54
54
  import { topicCollisions, renderGrounding } from "../core/topics.js";
55
+ import { parseDocAnchors, renderDocGrounding } from "../core/docanchors.js";
55
56
  import { compareCandidates } from "../core/compare.js";
56
57
  import { checkConformance } from "../core/conformance.js";
57
58
  import { draftTripwires, knownRepoDeps } from "../synthesis/tripwires.js";
@@ -707,6 +708,7 @@ program
707
708
  const { store, root } = storeFor();
708
709
  // Lookup mode: scoped runbook retrieval (search within runbooks, not the whole graph).
709
710
  if (opts.find) {
711
+ store.reindex(); // reflect out-of-band JSON edits before searching (mirrors `hunch query`)
710
712
  const emb = opts.semantic ? await selectEmbedder() : undefined;
711
713
  const hits = await store.searchRunbooks(opts.find, 5, { embedder: emb });
712
714
  if (!hits.length)
@@ -1572,12 +1574,22 @@ program
1572
1574
  }
1573
1575
  }
1574
1576
  // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
1577
+ // Decision-grounding for PROSE (doc≠graph): a markdown target that declares
1578
+ // <!-- hunch:topic … --> anchors gets each topic's CURRENT decision — the
1579
+ // graph outranks the prose being edited, and a stale pin is called out inline.
1580
+ let docGround = "";
1581
+ if (/\.(md|mdx)$/i.test(target)) {
1582
+ try {
1583
+ docGround = renderDocGrounding(parseDocAnchors(readFileSync(abs, "utf8")), store.recs("decisions"));
1584
+ }
1585
+ catch { /* unreadable / not yet created — no doc grounding */ }
1586
+ }
1575
1587
  const ctx = store.assembleContext(target);
1576
1588
  // Regression Guard (edit-time grounding): what an in-force decision retired
1577
1589
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
1578
1590
  // not a block; the commit-time `hunch check` does the actual gating.
1579
1591
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
1580
- const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length;
1592
+ const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length || docGround;
1581
1593
  if (!hasContent)
1582
1594
  return; // no noise on files Hunch hasn't learned yet
1583
1595
  let text = formatContext(ctx).trim();
@@ -1594,6 +1606,8 @@ program
1594
1606
  const grounding = renderGrounding(ctx.decisions);
1595
1607
  if (grounding)
1596
1608
  text += `\n\n${grounding}`;
1609
+ if (docGround)
1610
+ text += `\n\n${docGround}`;
1597
1611
  emitContext("PreToolUse", text);
1598
1612
  }
1599
1613
  catch {
@@ -1768,7 +1782,7 @@ program
1768
1782
  // ---- drift (doc≠graph detector; advisory + CI-gateable) -------------------
1769
1783
  program
1770
1784
  .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.")
1785
+ .description("Detect memory drift: dead refs, dangling supersedes, stale 'proposed' docs, doc≠graph anchor-stale (a file still anchored to a superseded decision), and markdown sections whose <!-- hunch:topic … dec_id --> pin points at a superseded or missing decision (AGENTS.md/CLAUDE.md as a drift surface). Exits non-zero on any anchor-stale drift or topic collision — the doc≠graph gate.")
1772
1786
  .action(() => {
1773
1787
  const { store, root } = storeFor();
1774
1788
  try {
@@ -1782,7 +1796,7 @@ program
1782
1796
  console.log(`· [${f.kind}] ${f.id} — ${f.detail}`);
1783
1797
  for (const [topic, decs] of collisions)
1784
1798
  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;
1799
+ const anchor = findings.filter((f) => f.kind === "anchor-stale" || f.kind === "doc-anchor-stale").length;
1786
1800
  console.log(`\n${findings.length} finding(s)${anchor ? `, ${anchor} doc≠graph (anchor-stale)` : ""}${collisions.size ? `, ${collisions.size} topic-collision(s)` : ""}.`);
1787
1801
  if (anchor || collisions.size)
1788
1802
  process.exitCode = 1;
@@ -1791,23 +1805,124 @@ program
1791
1805
  store.close();
1792
1806
  }
1793
1807
  });
1808
+ // ---- path (shortest dependency chain) --------------------------------------
1809
+ program
1810
+ .command("path")
1811
+ .description("Shortest dependency path between two symbols/files/components — 'how does A reach B?'. Walks call/import/dependency/contains edges in either direction. Read-only.")
1812
+ .argument("<from>", "symbol id/name or file path")
1813
+ .argument("<to>", "symbol id/name or file path")
1814
+ .option("--max-depth <n>", "maximum hops to search", "8")
1815
+ .action((from, to, opts) => {
1816
+ const { store } = storeFor();
1817
+ try {
1818
+ store.reindex(); // reflect out-of-band JSON edits before walking the graph
1819
+ const A = store.resolveNodeIds(from);
1820
+ const B = store.resolveNodeIds(to);
1821
+ if (!A.length)
1822
+ return fail(`"${from}" resolves to no indexed symbol/component (run \`hunch index\`?).`);
1823
+ if (!B.length)
1824
+ return fail(`"${to}" resolves to no indexed symbol/component.`);
1825
+ let best = null;
1826
+ for (const a of A.slice(0, 4)) {
1827
+ for (const b of B.slice(0, 4)) {
1828
+ const p = store.shortestPath(a, b, Number(opts.maxDepth) || 8);
1829
+ if (p && (!best || p.length < best.length))
1830
+ best = p;
1831
+ }
1832
+ }
1833
+ if (!best) {
1834
+ console.log(`No path between "${from}" and "${to}" within ${opts.maxDepth} hop(s).`);
1835
+ process.exitCode = 1;
1836
+ return;
1837
+ }
1838
+ const last = best.length - 1;
1839
+ console.log(`${last} hop(s):`);
1840
+ best.forEach((n, i) => console.log(` ${i === 0 ? "┌" : i === last ? "└" : "├"} ${n.via}${n.via === n.id ? "" : ` (${n.id})`}`));
1841
+ }
1842
+ finally {
1843
+ store.close();
1844
+ }
1845
+ });
1846
+ // ---- impact (PR impact — read-only, advisory) ------------------------------
1847
+ program
1848
+ .command("impact")
1849
+ .description("PR impact: the dependency + memory surface of a change — dependent files reached, invariants direct/near, and the decisions concerned. Read-only, advisory (gating is `hunch check`). Omit base and --commit to inspect staged changes.")
1850
+ .argument("[base]", "diff against this base ref (e.g. origin/main) for a branch/PR")
1851
+ .option("--commit <sha>", "impact of a single commit")
1852
+ .action((base, opts) => {
1853
+ const { store, root } = storeFor();
1854
+ try {
1855
+ if (base && opts.commit)
1856
+ return fail("Pass at most one of [base] / --commit.");
1857
+ if (base && !revExists(base, root))
1858
+ return fail(`base ref "${base}" does not resolve.`);
1859
+ if (opts.commit && !revExists(opts.commit, root))
1860
+ return fail(`commit "${opts.commit}" does not resolve.`);
1861
+ store.reindex(); // reflect out-of-band JSON edits before reading the graph
1862
+ const files = opts.commit ? commitFiles(opts.commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
1863
+ const scope = opts.commit ? `commit ${opts.commit}` : base ? `${base}..HEAD` : "staged changes";
1864
+ if (!files.length) {
1865
+ console.log(`No changed files in ${scope}.`);
1866
+ return;
1867
+ }
1868
+ const diff = opts.commit ? commitDiff(opts.commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
1869
+ console.log(renderImpact(store.prImpact(files, diff), scope));
1870
+ }
1871
+ finally {
1872
+ store.close();
1873
+ }
1874
+ });
1794
1875
  // ---- heal (decision-grounded drift reconciliation front door) -------------
1795
1876
  program
1796
1877
  .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.")
1878
+ .description("Drift reconciliation front door: every `hunch drift` finding with its next action — doc≠graph anchor-stale (reconcile toward the current decision), dead refs, dangling supersedes, stale 'proposed' docs. Read-only — proposes, never rewrites. Escalate to /capture only if the DECISION (not the doc) is stale.")
1798
1879
  .action(() => {
1799
1880
  const { store, root } = storeFor();
1800
1881
  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.");
1882
+ const findings = computeDrift(store, root).findings;
1883
+ if (!findings.length) {
1884
+ console.log("✓ No drift to heal — memory matches the code and docs.");
1804
1885
  return;
1805
1886
  }
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.`);
1887
+ // Every drift kind heals here `hunch drift` reporting N findings while heal
1888
+ // says "nothing to heal" reads as a broken loop (bug_drift_heal_asymmetry).
1889
+ const kind = (k) => findings.filter((f) => f.kind === k);
1890
+ const anchor = kind("anchor-stale");
1891
+ if (anchor.length) {
1892
+ console.log(`${anchor.length} anchored section(s) drifted from the graph (doc≠graph):\n`);
1893
+ for (const f of anchor)
1894
+ console.log(`· ${f.detail}`);
1895
+ console.log(`\nHeal A (doc stale): edit each file to match its CURRENT decision — a prose fix.`);
1896
+ 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.\n`);
1897
+ }
1898
+ const docAnchor = [...kind("doc-anchor-stale"), ...kind("doc-anchor-dangling")];
1899
+ if (docAnchor.length) {
1900
+ console.log(`${docAnchor.length} markdown section(s) drifted from the graph (prose≠graph):\n`);
1901
+ for (const f of docAnchor)
1902
+ console.log(`· ${f.id} — ${f.detail}`);
1903
+ console.log(`\nHeal: edit the prose to match the CURRENT decision, then update the pin in the <!-- hunch:topic … --> marker to its id. If the DECISION is what's wrong, run /capture to supersede it first.\n`);
1904
+ }
1905
+ const dead = kind("dead-ref");
1906
+ if (dead.length) {
1907
+ console.log(`${dead.length} dead reference(s) — an in-force decision points at a file that no longer exists:\n`);
1908
+ for (const f of dead)
1909
+ console.log(`· ${f.id} — ${f.detail}`);
1910
+ console.log(`\nHeal: update the decision's related_files to the file's new location — or supersede the decision if it no longer applies.\n`);
1911
+ }
1912
+ const dangling = kind("supersede");
1913
+ if (dangling.length) {
1914
+ console.log(`${dangling.length} dangling supersede(s) — the old decision was never properly closed:\n`);
1915
+ for (const f of dangling)
1916
+ console.log(`· ${f.id} — ${f.detail}`);
1917
+ console.log(`\nHeal: run \`hunch supersede <old> --by <new>\` to close the window and link them.\n`);
1918
+ }
1919
+ const docStale = kind("doc-stale");
1920
+ if (docStale.length) {
1921
+ console.log(`${docStale.length} stale doc(s) — still marked proposed/not-implemented but the code shipped:\n`);
1922
+ for (const f of docStale)
1923
+ console.log(`· ${f.id} — ${f.detail}`);
1924
+ console.log(`\nHeal: update the doc's status marker to match reality.\n`);
1925
+ }
1811
1926
  console.log(`Hunch never rewrites prose for you; this is a read-only reconciliation report.`);
1812
1927
  }
1813
1928
  finally {
@@ -6,6 +6,44 @@
6
6
  export function reportIsClean(r) {
7
7
  return r.direct.length === 0 && r.near.length === 0 && r.regressions.length === 0 && r.vetoes.length === 0 && r.redundant.length === 0;
8
8
  }
9
+ /** Terminal/markdown-lite rendering of an ImpactReport (hunch impact / hunch_pr_impact). */
10
+ export function renderImpact(im, scope) {
11
+ const out = [];
12
+ out.push(`Impact of ${scope} — ${im.files.length} changed file(s) → ${im.blast.length} dependent file(s):`);
13
+ if (im.blast.length) {
14
+ const cap = 20;
15
+ for (const b of im.blast.slice(0, cap))
16
+ out.push(` • [depth ${b.depth}] ${b.file} (via ${b.via})`);
17
+ if (im.blast.length > cap)
18
+ out.push(` …(+${im.blast.length - cap} more, closest first)`);
19
+ }
20
+ else {
21
+ out.push(" (nothing in the graph depends on these files)");
22
+ }
23
+ const r = im.report;
24
+ if (r.direct.length) {
25
+ out.push(`\nInvariants DIRECTLY in scope (${r.direct.length}):`);
26
+ for (const d of r.direct)
27
+ out.push(` ${mark(d.severity)} ${d.id} [${d.severity}] ${d.statement}`);
28
+ }
29
+ if (r.near.length) {
30
+ out.push(`\nInvariants reached via blast radius (${r.near.length}, advisory):`);
31
+ for (const n of r.near)
32
+ out.push(` ${mark(n.severity)} ${n.id} [${n.severity}] ${n.statement}\n via ${n.via[0] ?? ""}`);
33
+ }
34
+ if (im.decisions.length) {
35
+ const cap = 10;
36
+ out.push(`\nDecisions concerning the touched files (${im.decisions.length}):`);
37
+ for (const d of im.decisions.slice(0, cap))
38
+ out.push(` • ${d.id} [${d.status}] ${clip(d.title, 100)}`);
39
+ if (im.decisions.length > cap)
40
+ out.push(` …(+${im.decisions.length - cap} more)`);
41
+ }
42
+ if (!r.direct.length && !r.near.length && !im.decisions.length) {
43
+ out.push("\nNo recorded invariants or decisions touch this change.");
44
+ }
45
+ return out.join("\n");
46
+ }
9
47
  /** True when --strict should FAIL the commit/PR. */
10
48
  export function reportFailsStrict(r) {
11
49
  return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0 || r.vetoBlocking > 0);
@@ -0,0 +1,41 @@
1
+ import { currentForTopic, rejectedForTopic } from "./topics.js";
2
+ const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
3
+ /** Parse every hunch:topic marker out of a markdown document. */
4
+ export function parseDocAnchors(text) {
5
+ const out = [];
6
+ MARKER.lastIndex = 0;
7
+ let m;
8
+ while ((m = MARKER.exec(text))) {
9
+ out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, m.index).split("\n").length });
10
+ }
11
+ return out;
12
+ }
13
+ const clip = (s, n = 220) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
14
+ /** Pre-edit grounding for a markdown document that carries topic anchors: the
15
+ * CURRENT decision per declared topic (graph over prose), what it rejected,
16
+ * and a stale-pin warning the editor can heal inline. Empty when no anchor
17
+ * resolves to a decision. */
18
+ export function renderDocGrounding(anchors, decisions) {
19
+ const parts = [];
20
+ const seen = new Set();
21
+ for (const a of anchors) {
22
+ if (seen.has(a.topic))
23
+ continue;
24
+ seen.add(a.topic);
25
+ const current = currentForTopic(decisions, a.topic);
26
+ if (!current)
27
+ continue;
28
+ let line = `• topic "${a.topic}" → current decision ${current.id} — "${current.title}": ${clip(current.decision)}`;
29
+ const rejected = rejectedForTopic(decisions, a.topic);
30
+ if (rejected.length)
31
+ line += `\n rejected: ${rejected.slice(0, 3).map((r) => clip(r, 90)).join("; ")}`;
32
+ if (a.pin && a.pin !== current.id) {
33
+ line += `\n ⚠ this section is PINNED to ${a.pin}, which is no longer current — reconcile the prose with ${current.id}, then re-pin.`;
34
+ }
35
+ parts.push(line);
36
+ }
37
+ if (!parts.length)
38
+ return "";
39
+ return `🧭 Doc-grounding — this document declares topic anchors; the GRAPH is the source of truth. Follow the current decision, update prose to match it:\n${parts.join("\n")}`;
40
+ }
41
+ //# sourceMappingURL=docanchors.js.map
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
12
12
  import { join, extname } from "node:path";
13
13
  import { toPosixTarget } from "./paths.js";
14
14
  import { currentForTopic, isLive } from "./topics.js";
15
+ import { parseDocAnchors } from "./docanchors.js";
15
16
  const STALE_MARKER = /\b(proposed|not yet implemented|no code yet)\b/i;
16
17
  const SRC_REF = /\bsrc\/[A-Za-z0-9_\-/]+\.ts\b/g;
17
18
  export function computeDrift(store, root) {
@@ -72,16 +73,39 @@ export function computeDrift(store, root) {
72
73
  }
73
74
  }
74
75
  }
75
- // 3. DOC-STALE a doc that still advertises "proposed / not implemented" while
76
- // referencing code that exists. Heuristic + advisory; scoped to the repo's own
77
- // markdown (node_modules and sub-projects skipped).
76
+ // One markdown pass feeds both prose checks (3 + 5): read each doc once.
78
77
  for (const doc of markdownDocs(root)) {
79
78
  const text = safeRead(doc.path);
80
- if (!STALE_MARKER.test(text.slice(0, 1500)))
81
- continue;
82
- const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
83
- if (existing) {
84
- findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
79
+ // 3. DOC-STALE — a doc that still advertises "proposed / not implemented" while
80
+ // referencing code that exists. Heuristic + advisory; scoped to the repo's own
81
+ // markdown (node_modules and sub-projects skipped).
82
+ if (STALE_MARKER.test(text.slice(0, 1500))) {
83
+ const existing = (text.match(SRC_REF) ?? []).find((r) => existsSync(join(root, r)));
84
+ if (existing) {
85
+ findings.push({ kind: "doc-stale", id: doc.rel, detail: `marked proposed/not-implemented but references shipped code (${existing})` });
86
+ }
87
+ }
88
+ // 5. DOC-ANCHORS (prose≠graph, decision-grounding for markdown) — a section
89
+ // PINNED via `<!-- hunch:topic <topic> <dec_id> -->` to a decision that has
90
+ // been superseded (doc-anchor-stale, the CI-gateable one) or that doesn't
91
+ // exist (doc-anchor-dangling). Unpinned markers only ground the pre-edit
92
+ // hook — an explicit pin is the ONLY thing that can fire drift here.
93
+ for (const a of parseDocAnchors(text)) {
94
+ if (!a.pin)
95
+ continue;
96
+ const pinned = byId.get(a.pin);
97
+ if (!pinned) {
98
+ findings.push({ kind: "doc-anchor-dangling", id: doc.rel, detail: `line ${a.line}: pinned to ${a.pin} (topic "${a.topic}"), which does not exist` });
99
+ continue;
100
+ }
101
+ const current = currentForTopic(decisions, a.topic);
102
+ if ((pinned.status === "superseded" || pinned.superseded_by) && current && current.id !== a.pin) {
103
+ findings.push({
104
+ kind: "doc-anchor-stale",
105
+ id: doc.rel,
106
+ detail: `line ${a.line}: prose pinned to superseded ${a.pin} (topic "${a.topic}"); the current decision is ${current.id} — "${current.title}". Reconcile the prose with it, then re-pin.`,
107
+ });
108
+ }
85
109
  }
86
110
  }
87
111
  return { findings };
@@ -37,11 +37,13 @@ export function hunchPathsForDir(hunchDir) {
37
37
  dir: (kind) => join(hunch, kind),
38
38
  };
39
39
  }
40
- /** Walk up from `start` to find the nearest repo containing a .hunch/ dir,
41
- * else the nearest git repo, else `start`. Lets `hunch` run from subdirs. */
40
+ /** Walk up from `start` to the nearest dir containing a .hunch/ dir OR a .git
41
+ * (repo boundary), else `start`. Lets `hunch` run from subdirs. A `.git`
42
+ * WITHOUT `.hunch` stops the walk: an ancestor `.hunch` above the repo
43
+ * boundary belongs to some other scope (e.g. a stray ~/.hunch) and must never
44
+ * hijack a fresh repo — init would scaffold, index, and scan OUTSIDE the repo. */
42
45
  export function findRoot(start = process.cwd()) {
43
46
  let cur = resolve(start);
44
- let gitFallback = null;
45
47
  const isDir = (p) => {
46
48
  try {
47
49
  return statSync(p).isDirectory();
@@ -53,13 +55,13 @@ export function findRoot(start = process.cwd()) {
53
55
  for (;;) {
54
56
  if (isDir(join(cur, HUNCH_DIR)))
55
57
  return cur; // a `.hunch` regular file is not a root
56
- if (gitFallback === null && existsSync(join(cur, ".git")))
57
- gitFallback = cur;
58
+ if (existsSync(join(cur, ".git")))
59
+ return cur; // repo boundary — .git file (worktree) counts
58
60
  const parent = dirname(cur);
59
61
  if (parent === cur)
60
62
  break;
61
63
  cur = parent;
62
64
  }
63
- return gitFallback ?? resolve(start);
65
+ return resolve(start);
64
66
  }
65
67
  //# sourceMappingURL=paths.js.map
@@ -22,7 +22,7 @@ import { ensureTeamOverlay } from "../integrations/team.js";
22
22
  import { formatContext } from "../core/format.js";
23
23
  import { compareCandidates } from "../core/compare.js";
24
24
  import { checkConformance } from "../core/conformance.js";
25
- import { renderMarkdown, verdict } from "../core/checkreport.js";
25
+ import { renderMarkdown, renderImpact, verdict } from "../core/checkreport.js";
26
26
  import { HUNCH_VERSION } from "../core/version.js";
27
27
  import { liveForTopic, historyForTopic, rejectedForTopic, captureConflicts } from "../core/topics.js";
28
28
  import { issueCaptureToken as issueToken, consumeCaptureToken as consumeToken } from "../core/capturetoken.js";
@@ -528,6 +528,62 @@ export function buildServer(root) {
528
528
  return err(`Failed to compute merge verdict: ${e.message}`);
529
529
  }
530
530
  });
531
+ // -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
532
+ server.registerTool("hunch_pr_impact", {
533
+ title: "PR impact: the dependency + memory surface of a change",
534
+ description: "Given a change (staged, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base AND commit for staged changes.",
535
+ inputSchema: {
536
+ base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
537
+ commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
538
+ },
539
+ }, async ({ base, commit }) => {
540
+ try {
541
+ if (base && commit)
542
+ return err("Pass at most one of base/commit (omit both for staged changes).");
543
+ if (base && !revExists(base, root))
544
+ return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
545
+ if (commit && !revExists(commit, root))
546
+ return err(`commit "${commit}" does not resolve.`);
547
+ const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
548
+ const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
549
+ if (!files.length)
550
+ return ok(`No changed files in ${scope}.`);
551
+ const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
552
+ return ok(renderImpact(store.prImpact(files, diff), scope));
553
+ }
554
+ catch (e) {
555
+ return err(`Failed to compute impact: ${e.message}`);
556
+ }
557
+ });
558
+ // -- hunch_path (shortest dependency chain) --------------------------------
559
+ server.registerTool("hunch_path", {
560
+ title: "Shortest dependency path between two nodes",
561
+ description: "How does A reach B? Returns the shortest chain of call/import/dependency/contains edges connecting two symbols, files, or components — walked in either direction. Use to understand coupling before a refactor, to verify the actual route behind a must-reach invariant, or to explain why editing A shows up in B's blast radius. Deterministic, read-only.",
562
+ inputSchema: {
563
+ from: z.string().describe("Start: a symbol id/name or file path."),
564
+ to: z.string().describe("End: a symbol id/name or file path."),
565
+ max_depth: z.number().optional().describe("Maximum hops to search (default 8)."),
566
+ },
567
+ }, async ({ from, to, max_depth }) => {
568
+ const A = store.resolveNodeIds(from);
569
+ const B = store.resolveNodeIds(to);
570
+ if (!A.length)
571
+ return err(`"${from}" resolves to no indexed symbol/component (is the repo indexed?).`);
572
+ if (!B.length)
573
+ return err(`"${to}" resolves to no indexed symbol/component.`);
574
+ let best = null;
575
+ for (const a of A.slice(0, 4)) {
576
+ for (const b of B.slice(0, 4)) {
577
+ const p = store.shortestPath(a, b, max_depth ?? 8);
578
+ if (p && (!best || p.length < best.length))
579
+ best = p;
580
+ }
581
+ }
582
+ if (!best)
583
+ return ok(`No path between "${from}" and "${to}" within ${max_depth ?? 8} hop(s) — they are not connected in the indexed graph.`);
584
+ const chain = best.map((n, i) => ` ${i === 0 ? "┌" : i === best.length - 1 ? "└" : "├"} ${n.via}`).join("\n");
585
+ return ok(`${best.length - 1} hop(s) from "${from}" to "${to}":\n${chain}`);
586
+ });
531
587
  // -- hunch_compare --------------------------------------------------------
532
588
  server.registerTool("hunch_compare", {
533
589
  title: "Rank candidate solutions by architectural fit",
@@ -632,6 +632,79 @@ export class HunchStore {
632
632
  WHERE up.depth > 0 AND s.file <> ?
633
633
  GROUP BY s.file ORDER BY depth, file`).all(file, maxDepth, file);
634
634
  }
635
+ /** Shortest undirected path between two graph nodes (symbols/components) over
636
+ * call/dep/import/contains edges — "how does A reach B?" (hunch path / hunch_path).
637
+ * BFS via a recursive CTE; visited ids ride a |-delimited list so cycles terminate.
638
+ * Returns the node chain in order, or null when no path exists within maxDepth. */
639
+ shortestPath(fromId, toId, maxDepth = 8) {
640
+ if (fromId === toId)
641
+ return [{ id: fromId, via: this.nodeLabel(fromId) }];
642
+ const row = this.db.prepare(
643
+ /* sql */ `
644
+ WITH RECURSIVE step(node, path, depth) AS (
645
+ SELECT ?, '|' || ? || '|', 0
646
+ UNION
647
+ SELECT x.nb, step.path || x.nb || '|', step.depth + 1
648
+ FROM (
649
+ SELECT e."from" AS frm, e."to" AS nb FROM edges e WHERE e.type IN ('calls','depends_on','imports','contains')
650
+ UNION ALL
651
+ SELECT e."to" AS frm, e."from" AS nb FROM edges e WHERE e.type IN ('calls','depends_on','imports','contains')
652
+ ) x JOIN step ON x.frm = step.node
653
+ WHERE step.depth < ? AND instr(step.path, '|' || x.nb || '|') = 0
654
+ )
655
+ SELECT path FROM step WHERE node = ? ORDER BY depth LIMIT 1`).get(fromId, fromId, maxDepth, toId);
656
+ if (!row)
657
+ return null;
658
+ const ids = row.path.split("|").filter(Boolean);
659
+ return ids.map((id) => ({ id, via: this.nodeLabel(id) }));
660
+ }
661
+ /** Human label for a graph node id: "name @ file" for a symbol, the component name,
662
+ * or the id itself when unindexed. */
663
+ nodeLabel(id) {
664
+ const s = this.db.prepare(`SELECT name || ' @ ' || file AS v FROM symbols WHERE id = ?`).get(id);
665
+ if (s)
666
+ return s.v;
667
+ const c = this.db.prepare(`SELECT name AS v FROM components WHERE id = ?`).get(id);
668
+ return c?.v ?? id;
669
+ }
670
+ /** Resolve a free-form target (symbol id / name / file path, component id / name)
671
+ * to graph node ids — symbols win over components, exact file before suffix. */
672
+ resolveNodeIds(target) {
673
+ const t = toPosixTarget(target);
674
+ const sym = this.db.prepare(`SELECT id FROM symbols WHERE id = ? OR name = ? OR file = ? OR file LIKE ? LIMIT 20`).all(t, t, t, `%/${t}`);
675
+ if (sym.length)
676
+ return sym.map((r) => r.id);
677
+ const cmp = this.db.prepare(`SELECT id FROM components WHERE id = ? OR name = ? LIMIT 5`).all(t, t);
678
+ return cmp.map((r) => r.id);
679
+ }
680
+ /** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
681
+ * of a change. Composes the SAME primitives as buildCheckReport (blast radius,
682
+ * scope-matched constraints, why) so impact and gating can never disagree. */
683
+ prImpact(files, diff) {
684
+ const changed = new Set(files.map(toPosixTarget));
685
+ const blast = new Map();
686
+ for (const f of changed) {
687
+ for (const b of this.blastRadiusFiles(f)) {
688
+ if (changed.has(b.file))
689
+ continue;
690
+ const prev = blast.get(b.file);
691
+ if (!prev || b.depth < prev.depth)
692
+ blast.set(b.file, b);
693
+ }
694
+ }
695
+ const report = this.buildCheckReport([...changed], diff, { strict: false });
696
+ const decisions = new Map();
697
+ for (const f of changed) {
698
+ for (const d of this.why(f).decisions)
699
+ decisions.set(d.id, { id: d.id, title: d.title, status: d.status });
700
+ }
701
+ return {
702
+ files: [...changed],
703
+ blast: [...blast.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file)),
704
+ report,
705
+ decisions: [...decisions.values()],
706
+ };
707
+ }
635
708
  /** Constraints whose scope glob matches a path/glob (hunch_check_constraints).
636
709
  * By default only ACTIVE invariants are returned — a retired constraint is no
637
710
  * longer enforced. Pass `{ asOf }` to instead return the invariants in force at
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
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).",