@davesheffer/hunch 1.9.3 → 1.10.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
@@ -16,10 +16,9 @@ strict enforcement.
16
16
  **Memory is the input. The product boundary is the receipt:** relevant evidence before an edit,
17
17
  then a deterministic check of the change against the rules your team has explicitly trusted.
18
18
 
19
- > **New in v1.9.3:** Matrix mode gives the whole team one live, private Git memory across fresh
20
- > clones, worktrees, CLI checks, and MCP assistants. npm publishes with short-lived OIDC
21
- > credentials, while the editor companion ships as one immutable, publicly verified Open VSX
22
- > artifact.
19
+ > **New in v1.9.4:** MCP connections stay collision-safe across repositories and simultaneous
20
+ > captures, while generated MCP, hook, plugin, and CI commands pin the exact npm release that
21
+ > created them.
23
22
 
24
23
  ## Start in five minutes
25
24
 
@@ -82,7 +81,7 @@ Git repo that every teammate can access, install the Matrix release on team mach
82
81
  have one maintainer run:
83
82
 
84
83
  ```bash
85
- npm i -g @davesheffer/hunch@1.9.3
84
+ npm i -g @davesheffer/hunch@1.9.4
86
85
  hunch shared --repo git@github.com:acme/project-hunch-memory.git
87
86
  git add .gitignore .hunch/team.json
88
87
  git commit -m "chore: connect shared Hunch memory"
@@ -97,7 +96,7 @@ printed by Hunch. Omit `--migrate` for a new setup.
97
96
  After the pointer commit lands, teammates need Hunch installed and Git access to the memory repo:
98
97
 
99
98
  ```bash
100
- npm i -g @davesheffer/hunch@1.9.3
99
+ npm i -g @davesheffer/hunch@1.9.4
101
100
  git pull
102
101
  hunch init
103
102
  hunch doctor
package/dist/cli/index.js CHANGED
@@ -3855,7 +3855,7 @@ program
3855
3855
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
3856
3856
  // not a block; the commit-time `hunch check` does the actual gating.
3857
3857
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
3858
- const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length || docGround;
3858
+ const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || ctx.findings.length || retired.length || docGround;
3859
3859
  if (!hasContent)
3860
3860
  return; // no noise on files Hunch hasn't learned yet
3861
3861
  let text = formatContext(ctx).trim();
@@ -4538,6 +4538,34 @@ program
4538
4538
  store.close();
4539
4539
  }
4540
4540
  });
4541
+ // ---- findings (the open-observations ledger) --------------------------------
4542
+ program
4543
+ .command("findings")
4544
+ .description("LIVE findings — observed gaps/debt with no fix landed yet (audits, measurements, incidents; anchored to a date + evidence, not a commit). Same store method as the hunch_findings MCP tool. Read-only, advisory.")
4545
+ .argument("[scope]", "a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all")
4546
+ .option("--all", "include resolved/stale findings (the full history)")
4547
+ .action((scope, opts) => {
4548
+ const { store } = storeFor();
4549
+ try {
4550
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
4551
+ const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(opts.all ? () => true : live))
4552
+ .filter(opts.all ? () => true : live);
4553
+ if (!list.length) {
4554
+ console.log(`No ${opts.all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. Record one after an audit: /audit (or hunch_record_finding via MCP).`);
4555
+ return;
4556
+ }
4557
+ for (const f of list) {
4558
+ const links = [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join(" · ");
4559
+ console.log(`• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})`);
4560
+ console.log(` ${f.observation}`);
4561
+ console.log(` concerns: ${[...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)"}${links ? `\n ${links}` : ""}`);
4562
+ }
4563
+ console.log(`\n${list.length} finding(s).`);
4564
+ }
4565
+ finally {
4566
+ store.close();
4567
+ }
4568
+ });
4541
4569
  // ---- path (shortest dependency chain) --------------------------------------
4542
4570
  program
4543
4571
  .command("path")
@@ -6,11 +6,16 @@
6
6
  * same import-safety to be unit-testable. */
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { probeOllamaNumCtx } from "../synthesis/provider.js";
9
- /** Published package name used for OS-agnostic invocations (see below). */
10
- const PKG = "@davesheffer/hunch";
9
+ import { HUNCH_NPX_PACKAGE_SPEC } from "../core/version.js";
11
10
  export function dim(s) {
12
11
  return `\x1b[2m${s}\x1b[0m`;
13
12
  }
13
+ /** Portable invocation written into committed MCP/provider configuration.
14
+ * Keep it independently testable so the distribution pin cannot silently
15
+ * regress to npm's moving latest tag. */
16
+ export function publishedMcpInvocation() {
17
+ return { command: "npx", args: ["-y", `--package=${HUNCH_NPX_PACKAGE_SPEC}`, "hunch"] };
18
+ }
14
19
  /** The doctor command's synthesis-status line(s) for a resolved provider.
15
20
  * Exported for testing — the previous version (a bare provider-name switch,
16
21
  * before the resolveSynthesisProvider preference system existed) had zero
@@ -65,15 +70,16 @@ export function resolveInvocation() {
65
70
  // Running from an installed copy (global, local, or npx cache — i.e. NOT a
66
71
  // source checkout we're hacking on). The MCP/provider config files we write
67
72
  // are committed and shared across a team via git, so they must NOT embed this
68
- // machine's absolute path or OS-specific separators. Reference Hunch by its
69
- // published package name instead, which `npx` resolves the same on any OS and
70
- // any clone. The git hook lives in per-machine .git/hooks (never committed),
71
- // so it keeps the PATH-robust absolute-node invocation below.
73
+ // machine's absolute path or OS-specific separators. Reference the exact
74
+ // published Hunch package instead, which `npx` resolves the same on any OS
75
+ // and any clone without floating to a newer release. The git hook lives in
76
+ // per-machine .git/hooks (never committed), so it keeps the PATH-robust
77
+ // absolute-node invocation below.
72
78
  const installed = !isDev && entry.replace(/\\/g, "/").includes("/node_modules/");
73
79
  if (installed) {
74
80
  return {
75
81
  shell: `${q(process.execPath)} ${q(entry)}`,
76
- mcp: { command: "npx", args: ["-y", PKG] },
82
+ mcp: publishedMcpInvocation(),
77
83
  };
78
84
  }
79
85
  if (isDev) {
@@ -118,6 +118,31 @@ export function computeDrift(store, root) {
118
118
  // component vanished). Deterministic hash comparison against the manifest;
119
119
  // fires only when a wiki was adopted. Advisory like every other kind here.
120
120
  findings.push(...computeWikiDrift(store, root));
121
+ // 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
122
+ // an affected file that no longer exists, or a violates_constraint pointing at a
123
+ // retired/missing rule. Deterministic + advisory (never the exit-code class):
124
+ // the observation may be fixed, moved, or moot — re-verify (re-run its method)
125
+ // and re-record with triage resolved/stale, or refresh its paths.
126
+ for (const f of store.recs("findings")) {
127
+ if (f.triage === "resolved" || f.triage === "stale")
128
+ continue;
129
+ for (const file of f.affected_files) {
130
+ if (!file || file.includes("*"))
131
+ continue; // globs can't dead-ref
132
+ if (!existsSync(join(root, file))) {
133
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" references missing file "${file}" — re-verify${f.method ? ` (${f.method})` : ""} and re-record, or mark it stale` });
134
+ }
135
+ }
136
+ if (f.violates_constraint) {
137
+ const con = store.getRec("constraints", f.violates_constraint);
138
+ if (!con) {
139
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" claims to violate ${f.violates_constraint}, which does not exist — link the real rule or record it (hunch_record_correction)` });
140
+ }
141
+ else if (con.status === "retired") {
142
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" violates ${f.violates_constraint}, but that constraint is RETIRED — resolve the finding or re-link it` });
143
+ }
144
+ }
145
+ }
121
146
  return { findings };
122
147
  }
123
148
  /** Resolve a decision file reference without making private-memory paths depend on
@@ -21,6 +21,12 @@ export function formatContext(ctx) {
21
21
  for (const b of ctx.bugs)
22
22
  out.push(`- [${b.status}/${b.severity}] ${b.title} — root cause: ${b.root_cause}${prov(b.provenance)}`);
23
23
  }
24
+ if (ctx.findings.length) {
25
+ out.push(`\n## 🔍 Known findings (observed, unresolved — no fix landed yet)`);
26
+ for (const f of ctx.findings) {
27
+ out.push(`- [${f.triage}/${f.severity}] ${f.title} — ${f.observation}${prov(f.provenance)}\n (${f.id}; observed ${f.observed_at.slice(0, 10)}${f.violates_constraint ? `; violates ${f.violates_constraint}` : ""}${f.method ? `; re-verify via ${f.method}` : ""})`);
28
+ }
29
+ }
24
30
  if (ctx.blast_radius.length) {
25
31
  out.push(`\n## 💥 Blast radius (transitive dependents)`);
26
32
  out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
package/dist/core/ids.js CHANGED
@@ -43,4 +43,10 @@ export function runbookId(seed) {
43
43
  export function constraintId(statement) {
44
44
  return "con_" + shortHash(statement.trim().toLowerCase());
45
45
  }
46
+ /** Finding id seeded by its title (trim + lowercase, same idiom as constraints):
47
+ * re-recording the same observation UPDATES it (e.g. a triage change) instead of
48
+ * minting a duplicate. A genuinely new observation deserves a new title. */
49
+ export function findingId(title) {
50
+ return "fnd_" + shortHash(title.trim().toLowerCase());
51
+ }
46
52
  //# sourceMappingURL=ids.js.map
@@ -53,16 +53,16 @@ export function hunchPathsForDir(hunchDir) {
53
53
  * WITHOUT `.hunch` stops the walk: an ancestor `.hunch` above the repo
54
54
  * boundary belongs to some other scope (e.g. a stray ~/.hunch) and must never
55
55
  * hijack a fresh repo — init would scaffold, index, and scan OUTSIDE the repo. */
56
+ export function isDir(path) {
57
+ try {
58
+ return statSync(path).isDirectory();
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
56
64
  export function findRoot(start = process.cwd()) {
57
65
  let cur = resolve(start);
58
- const isDir = (p) => {
59
- try {
60
- return statSync(p).isDirectory();
61
- }
62
- catch {
63
- return false;
64
- }
65
- };
66
66
  for (;;) {
67
67
  if (isDir(join(cur, HUNCH_DIR)))
68
68
  return cur; // a `.hunch` regular file is not a root
@@ -218,8 +218,31 @@ export const RunbookSchema = z.object({
218
218
  provenance: ProvenanceSchema,
219
219
  date: z.string(),
220
220
  });
221
+ /** An OBSERVATION — audited knowledge with no diff (the anchor is a date + evidence,
222
+ * not a commit). Fills the gap between Bug (broke and got fixed) and Decision (chose
223
+ * and changed code): "we looked, we found, we haven't acted yet". Examples: an audit
224
+ * that surfaced unscoped tenant queries, a measured perf number, a vendor limit, an
225
+ * incident with no code fix. ADVISORY retrieval context (pre-edit grounding + MCP);
226
+ * never enters any block path. Lifecycle is `triage`, not valid-time: a finding is
227
+ * resolved/stale-marked, never superseded. */
228
+ export const FindingSchema = z.object({
229
+ id: z.string().describe("fnd_*"),
230
+ title: z.string(),
231
+ observation: z.string().default("").describe("what was observed, in plain words"),
232
+ evidence: z.array(z.string()).default([]).describe("the query/command run + representative output — a finding without evidence is an opinion"),
233
+ method: z.string().nullable().default(null).describe("rb_* runbook that re-runs the audit (makes the finding re-verifiable)"),
234
+ severity: z.enum(["low", "medium", "high", "critical"]).default("medium"),
235
+ triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).default("open"),
236
+ affected_files: z.array(z.string()).default([]).describe("concrete paths or globs the observation concerns"),
237
+ affected_symbols: z.array(z.string()).default([]).describe("symbols/objects concerned (e.g. dbo.GetOrders)"),
238
+ violates_constraint: z.string().nullable().default(null).describe("con_* this finding is a known violation of"),
239
+ spawned_decision: z.string().nullable().default(null).describe("dec_* recorded in response to this finding"),
240
+ observed_at: z.string().describe("ISO instant the observation was made — the anchor (findings have no commit)"),
241
+ resolved_commit: z.string().nullable().default(null).describe("the commit that fixed it (set when triage becomes resolved)"),
242
+ provenance: ProvenanceSchema,
243
+ });
221
244
  /** The entity collections, keyed by their on-disk directory name. */
222
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks"];
245
+ export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
223
246
  export const SCHEMAS = {
224
247
  components: ComponentSchema,
225
248
  edges: EdgeSchema,
@@ -228,6 +251,7 @@ export const SCHEMAS = {
228
251
  bugs: BugSchema,
229
252
  constraints: ConstraintSchema,
230
253
  runbooks: RunbookSchema,
254
+ findings: FindingSchema,
231
255
  };
232
256
  /** Default provenance helper for deterministic (extracted) records. */
233
257
  export function extracted(confidence, evidence = []) {
@@ -16,4 +16,13 @@ export const HUNCH_VERSION = (() => {
16
16
  return "0.0.0";
17
17
  }
18
18
  })();
19
+ /** Exact public npm package consumed by generated CI and shared MCP/provider
20
+ * configs. A floating package name would let one committed configuration run
21
+ * different Hunch semantics as npm's latest release changes. */
22
+ export const HUNCH_PACKAGE_SPEC = `@davesheffer/hunch@${HUNCH_VERSION}`;
23
+ /** npm alias used by npx launchers. Giving the fetched package a distinct local
24
+ * alias prevents npm exec from treating this repository (which has the same
25
+ * package name) as satisfying the request and then falling through to an older
26
+ * global `hunch` executable. */
27
+ export const HUNCH_NPX_PACKAGE_SPEC = `hunch-exact@npm:${HUNCH_PACKAGE_SPEC}`;
19
28
  //# sourceMappingURL=version.js.map
@@ -535,7 +535,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
535
535
  // remote .gitignore, local info/exclude, or ambient excludesFile must not
536
536
  // be able to silently stop the shared graph's heartbeat.
537
537
  for (let index = 0; index < paths.length; index += 128) {
538
- if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "add", "-f", "--", ...paths.slice(index, index + 128)])) {
538
+ if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "-f", "--", ...paths.slice(index, index + 128)])) {
539
539
  run(["reset", "-q", "--", "."]);
540
540
  return null;
541
541
  }
@@ -573,9 +573,12 @@ export function commitAndPushHunch(hunchDir, message, opts) {
573
573
  // docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
574
574
  // bug_overlay_clobber detection above nor sweep user edits.
575
575
  for (const file of opts.alsoStage ?? []) {
576
+ // core.autocrlf=false on every memory add/checkout: the Git-for-Windows
577
+ // installer default (system gitconfig autocrlf=true) would re-encode the
578
+ // graph's JSON bytes in transit, breaking byte-exact content hashes.
576
579
  run(opts.push === false
577
- ? ["add", "--", file]
578
- : ["-c", `core.attributesFile=${gitNullDevice()}`, "add", "--", file]);
580
+ ? ["-c", "core.autocrlf=false", "add", "--", file]
581
+ : ["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "--", file]);
579
582
  }
580
583
  // Only sync+push when a memory commit was actually created — never run pull/push against the
581
584
  // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
@@ -600,6 +603,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
600
603
  "-C", hunchDir,
601
604
  "-c", `core.hooksPath=${hooksDir}`,
602
605
  ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
606
+ "-c", "core.autocrlf=false",
603
607
  "-c", "commit.gpgsign=false",
604
608
  "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
605
609
  ], { stdio: "ignore", env, timeout: 15_000 });
@@ -1072,6 +1076,7 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
1072
1076
  "-C", hunchDir,
1073
1077
  "-c", `core.hooksPath=${hooksDir}`,
1074
1078
  "-c", `core.attributesFile=${gitNullDevice()}`,
1079
+ "-c", "core.autocrlf=false",
1075
1080
  "reset", "--hard", fetchedHead,
1076
1081
  ], {
1077
1082
  stdio: "ignore", env, timeout: 5_000,
@@ -1123,6 +1128,7 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1123
1128
  "-C", hunchDir,
1124
1129
  "-c", `core.hooksPath=${hooksDir}`,
1125
1130
  "-c", `core.attributesFile=${gitNullDevice()}`,
1131
+ "-c", "core.autocrlf=false",
1126
1132
  "-c", "commit.gpgsign=false",
1127
1133
  ...args,
1128
1134
  ], {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { mkdirSync, writeFileSync, existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { HUNCH_VERSION } from "../core/version.js";
11
+ import { HUNCH_PACKAGE_SPEC } from "../core/version.js";
12
12
  // `\${{ … }}` keeps GitHub Actions expressions literal inside this template
13
13
  // literal (a bare `${` would be JS interpolation).
14
14
  export function ciWorkflowYaml() {
@@ -44,7 +44,7 @@ jobs:
44
44
  # Pin the same release that generated this file so every assistant and CI
45
45
  # evaluate the graph with identical semantics. Dependabot/Renovate (or a
46
46
  # deliberate hunch-ci refresh) can advance this in a reviewed change.
47
- run: npm install -g @davesheffer/hunch@${HUNCH_VERSION}
47
+ run: npm install -g ${HUNCH_PACKAGE_SPEC}
48
48
 
49
49
  - name: Fetch the PR base branch
50
50
  # checkout sets up no origin/<base> tracking ref; create it explicitly so
@@ -20,6 +20,7 @@ export function renderHunchSection(store, root) {
20
20
  constraints: store.json.loadAll("constraints").length,
21
21
  components: store.json.loadAll("components").length,
22
22
  policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
23
+ findings: store.json.loadAll("findings").filter((f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled").length,
23
24
  };
24
25
  const lines = [];
25
26
  lines.push(START);
@@ -27,7 +28,7 @@ export function renderHunchSection(store, root) {
27
28
  lines.push("");
28
29
  lines.push("This repo has **Hunch** — a curated graph of *why* the code is the way it is " +
29
30
  "(decisions, bug history, invariants). It currently holds " +
30
- `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies**.`);
31
+ `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies${counts.findings ? `, ${counts.findings} open findings` : ""}**.`);
31
32
  lines.push("");
32
33
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
33
34
  lines.push("");
@@ -47,6 +48,7 @@ export function renderHunchSection(store, root) {
47
48
  lines.push("");
48
49
  lines.push("**Before editing:**");
49
50
  lines.push("- `hunch_check_constraints(scope)` and `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)` — invariants in scope + who you'd break. (The pre-edit hook injects this per file automatically; call these for PLANNING breadth.)");
51
+ lines.push("- `hunch_findings(scope?)` — known-but-unfixed gaps in the area (past audits, measurements, incidents) so you inherit them instead of re-discovering them.");
50
52
  lines.push("");
51
53
  lines.push("**Before committing / merging:**");
52
54
  lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.");
@@ -60,6 +62,7 @@ export function renderHunchSection(store, root) {
60
62
  lines.push("**After deciding / when corrected:**");
61
63
  lines.push("- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).");
62
64
  lines.push("- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.");
65
+ lines.push("- `hunch_record_finding(...)` — an OBSERVATION with no code change (an audit that found a gap, a measured number, an incident) becomes durable memory anchored to a date + evidence; `/audit` runs the ritual.");
63
66
  lines.push("- `hunch_timeline(target)` — decision history when investigating how something evolved.");
64
67
  const wiki = root ? wikiSummary(root) : null;
65
68
  if (wiki) {
@@ -50,6 +50,8 @@ const MEM_ENTRIES = [
50
50
  ".hunch/shadow/",
51
51
  ".hunch/symbols/",
52
52
  ".hunch/edges/",
53
+ ".hunch/runbooks/",
54
+ ".hunch/findings/",
53
55
  ];
54
56
  function pathIsWithin(path, parent) {
55
57
  const rel = relative(parent, path);
@@ -76,6 +76,18 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
76
76
  5. Commit with \`hunch_record_decision\`, passing \`capture_token\` (from step 1) and the confirmed \`topic\`. The artifact is the graph write, not prose.
77
77
  6. On CONFLICT for the topic, do NOT auto-supersede — Hunch refuses and presents both; let me choose supersede (link) / split the topic / discard.
78
78
  `;
79
+ const AUDIT_CMD = `---
80
+ description: Run an audit and record what it finds into Hunch as findings (observed gaps, no code change)
81
+ ---
82
+ Audit **$ARGUMENTS** and record what you find into Hunch's graph.
83
+
84
+ 1. Run the actual check (query/grep/script) — a finding needs EVIDENCE: the exact command you ran plus representative output. Never record a finding you didn't observe.
85
+ 2. For each REAL gap: \`hunch_record_finding\` with title, observation, evidence, affected_files/affected_symbols, severity. It grounds future edits to those files automatically.
86
+ 3. If the gap violates an existing invariant, link it via \`violates_constraint\`. If the RULE itself is unrecorded, capture the rule FIRST (\`hunch_record_correction\`), then link it.
87
+ 4. If the audit is re-runnable, capture the procedure as a runbook and set \`method\` to its rb_* id — that makes the finding re-verifiable, not folklore.
88
+ 5. Triage with me inline: open (default) / accepted-risk / scheduled. NEVER mark resolved without the fixing commit (\`resolved_commit\`).
89
+ 6. Report: findings recorded (ids), what was checked and came back clean, and what stays unverified.
90
+ `;
79
91
  const HEAL_CMD = `---
80
92
  description: Reconcile docs/code with Hunch's decision graph (doc≠graph drift), never rewriting prose silently
81
93
  ---
@@ -169,6 +181,7 @@ export function writeSlashCommands(root) {
169
181
  ["hunch-fragile.md", FRAGILE_CMD],
170
182
  ["capture.md", CAPTURE_CMD],
171
183
  ["heal.md", HEAL_CMD],
184
+ ["audit.md", AUDIT_CMD],
172
185
  ];
173
186
  for (const [name, body] of files) {
174
187
  const p = join(dir, name);
@@ -557,6 +557,7 @@ function materializeValidatedClone(team, teamRoot, overlayRoot, emptyHooks) {
557
557
  "-C", overlayRoot,
558
558
  "-c", `core.hooksPath=${emptyHooks}`,
559
559
  "-c", `core.attributesFile=${gitNullDevice()}`,
560
+ "-c", "core.autocrlf=false",
560
561
  "reset", "--hard", oid,
561
562
  ], {
562
563
  stdio: "ignore",
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Resolve MCP client roots to the one repository this server may safely serve.
3
+ *
4
+ * A server process keeps the cwd it was spawned with, while the client can move to
5
+ * another workspace or linked worktree. MCP roots are the client-neutral protocol
6
+ * mechanism for following that change.
7
+ */
8
+ import { statSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
12
+ function toPath(uri) {
13
+ if (!uri.startsWith("file:"))
14
+ return "";
15
+ try {
16
+ return fileURLToPath(uri);
17
+ }
18
+ catch {
19
+ return "";
20
+ }
21
+ }
22
+ function rootStart(path) {
23
+ try {
24
+ const stat = statSync(path);
25
+ if (stat.isDirectory())
26
+ return path;
27
+ if (stat.isFile())
28
+ return dirname(path);
29
+ }
30
+ catch {
31
+ // Missing/inaccessible roots are unusable.
32
+ }
33
+ return "";
34
+ }
35
+ /**
36
+ * Returns null when several advertised repositories are equally plausible.
37
+ * The roots protocol exposes a set of URI/name pairs, not an "active root" bit;
38
+ * choosing the first Hunch store in that case could silently write repo B's
39
+ * decision into repo A.
40
+ */
41
+ export function resolveActiveRoot(rootUris, fallbackCwd) {
42
+ const candidates = [];
43
+ for (const uri of rootUris) {
44
+ const start = rootStart(toPath(uri));
45
+ if (!start)
46
+ continue;
47
+ const root = findRoot(start);
48
+ if (!candidates.includes(root))
49
+ candidates.push(root);
50
+ }
51
+ if (!candidates.length)
52
+ return findRoot(fallbackCwd);
53
+ if (candidates.length === 1)
54
+ return candidates[0];
55
+ const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
56
+ return withStore.length === 1 ? withStore[0] : null;
57
+ }
58
+ //# sourceMappingURL=roots.js.map
@@ -8,11 +8,13 @@
8
8
  */
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
11
12
  import { z } from "zod";
12
13
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
+ import { resolveActiveRoot } from "./roots.js";
13
15
  import { HunchStore } from "../store/hunchStore.js";
14
16
  import { selectEmbedder } from "../store/embedder.js";
15
- import { decisionId } from "../core/ids.js";
17
+ import { decisionId, findingId } from "../core/ids.js";
16
18
  import { buildCorrectionConstraint } from "../core/correction.js";
17
19
  import { knownRepoDeps } from "../synthesis/tripwires.js";
18
20
  import { refreshExistingGrounding } from "../integrations/providers.js";
@@ -53,6 +55,7 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
53
55
  const WHY_CAP = 6; // per record-type in hunch_why
54
56
  const DEP_CAP = 25; // dependents in hunch_get_dependents
55
57
  const QUERY_HITS = 8; // hunch_query matches (was 12)
58
+ const FINDINGS_CAP = 12; // hunch_findings listing
56
59
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
57
60
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
58
61
  const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
@@ -121,14 +124,41 @@ function resolveFiles(store, target) {
121
124
  const files = new Set(resolveSymbols(store, target).map((s) => s.file));
122
125
  return files.size ? [...files] : [toPosixTarget(target)];
123
126
  }
124
- export function buildServer(root) {
127
+ function pullBackoff(status, finishedAt, failures) {
128
+ if (status === "updated" || status === "current") {
129
+ return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 1_000 };
130
+ }
131
+ if (status === "busy") {
132
+ return { consecutivePullFailures: failures, nextRemotePullAt: finishedAt + 100 };
133
+ }
134
+ if (status === "unconfigured") {
135
+ return { consecutivePullFailures: 0, nextRemotePullAt: finishedAt + 30_000 };
136
+ }
137
+ const consecutivePullFailures = Math.min(failures + 1, 6);
138
+ return {
139
+ consecutivePullFailures,
140
+ nextRemotePullAt: finishedAt + Math.min(30_000, 1_000 * (2 ** (consecutivePullFailures - 1))),
141
+ };
142
+ }
143
+ function rebuildFreshIndex(store) {
144
+ for (let attempt = 0; attempt < 2; attempt++) {
145
+ const before = store.sourceStamp();
146
+ store.reindexFresh();
147
+ const after = store.sourceStamp();
148
+ if (before === after)
149
+ return after;
150
+ }
151
+ return undefined;
152
+ }
153
+ /** Prepare and validate a complete root context before publishing it to handlers.
154
+ * A failed re-home therefore leaves the previous graph fully active. */
155
+ function prepareRoot(root, explicitOverlay, requireIndex) {
125
156
  // Team auto-discovery: a committed .hunch/team.json advertises the shared store — a
126
157
  // fresh clone (a new teammate, a headless agent, a CI workflow) wires itself BEFORE the
127
158
  // store is constructed, so every consumer resolves the same single source of truth.
128
159
  // Once that declaration is present it is fail-closed: starting against the public
129
160
  // graph after an invalid config, failed first clone, or dead pointer would let both
130
161
  // reads and writes silently escape the team's memory spine.
131
- const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
132
162
  const teamFile = join(hunchPaths(root).hunch, "team.json");
133
163
  const teamAdvertised = !explicitOverlay && existsSync(teamFile);
134
164
  const startupTeamConfig = teamAdvertised ? readTeamConfig(root) : null;
@@ -137,18 +167,70 @@ export function buildServer(root) {
137
167
  }
138
168
  ensureTeamOverlay(root);
139
169
  const store = new HunchStore(hunchPaths(root));
140
- if (teamAdvertised && (store.mode !== "shared"
141
- || !store.privateDir
142
- || !existsSync(store.privateDir)
143
- || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
170
+ try {
171
+ if (teamAdvertised && (store.mode !== "shared"
172
+ || !store.privateDir
173
+ || !existsSync(store.privateDir)
174
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
175
+ throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
176
+ }
177
+ const startupTeamRoute = teamAdvertised && store.privateDir
178
+ ? teamRemoteContract(root, join(store.privateDir, ".."))
179
+ : null;
180
+ if (startupTeamRoute)
181
+ pinSharedRemote(store, startupTeamRoute);
182
+ let nextRemotePullAt = 0;
183
+ let consecutivePullFailures = 0;
184
+ if (store.privateDir) {
185
+ try {
186
+ const status = pullHunchStatus(store.privateDir, {
187
+ timeoutMs: 5_000,
188
+ remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
189
+ });
190
+ ({ nextRemotePullAt, consecutivePullFailures } = pullBackoff(status, Date.now(), 0));
191
+ }
192
+ catch {
193
+ // Offline / no remote — proceed with the validated local store.
194
+ }
195
+ }
196
+ let indexedSourceStamp;
197
+ try {
198
+ indexedSourceStamp = rebuildFreshIndex(store);
199
+ }
200
+ catch (error) {
201
+ if (requireIndex)
202
+ throw error;
203
+ console.error("[hunch-mcp] reindex on startup failed:", error.message);
204
+ }
205
+ return {
206
+ root,
207
+ teamFile,
208
+ teamAdvertised,
209
+ startupTeamConfig,
210
+ startupTeamRoute,
211
+ store,
212
+ nextRemotePullAt,
213
+ consecutivePullFailures,
214
+ indexedSourceStamp,
215
+ };
216
+ }
217
+ catch (error) {
144
218
  store.close();
145
- throw new Error("the advertised team memory store is unavailable or tracks a different remote; refusing to start MCP on another graph");
219
+ throw error;
146
220
  }
147
- const startupTeamRoute = teamAdvertised && store.privateDir
148
- ? teamRemoteContract(root, join(store.privateDir, ".."))
149
- : null;
150
- if (startupTeamRoute)
151
- pinSharedRemote(store, startupTeamRoute);
221
+ }
222
+ export function buildServerWithRootControl(initialRoot) {
223
+ const explicitOverlay = !!process.env.HUNCH_PRIVATE_DIR?.trim();
224
+ const initial = prepareRoot(initialRoot, explicitOverlay, false);
225
+ let root = initial.root;
226
+ let teamFile = initial.teamFile;
227
+ let teamAdvertised = initial.teamAdvertised;
228
+ let startupTeamConfig = initial.startupTeamConfig;
229
+ let startupTeamRoute = initial.startupTeamRoute;
230
+ let store = initial.store;
231
+ let nextRemotePullAt = initial.nextRemotePullAt;
232
+ let consecutivePullFailures = initial.consecutivePullFailures;
233
+ let indexedSourceStamp = initial.indexedSourceStamp;
152
234
  const matchesStartupTeamRoute = () => {
153
235
  if (!teamAdvertised || !store.privateDir || !startupTeamConfig || !startupTeamRoute)
154
236
  return !teamAdvertised;
@@ -165,24 +247,9 @@ export function buildServer(root) {
165
247
  // session sees memory captured on other machines/worktrees before we index — making the
166
248
  // overlay genuinely one source of truth. Remote calls are bounded; request-time failures
167
249
  // back off exponentially instead of freezing every tool on the same unavailable remote.
168
- let nextRemotePullAt = 0;
169
- let consecutivePullFailures = 0;
170
250
  const notePull = (status, finishedAt) => {
171
- if (status === "updated" || status === "current") {
172
- consecutivePullFailures = 0;
173
- nextRemotePullAt = finishedAt + 1_000;
174
- }
175
- else if (status === "busy") {
176
- nextRemotePullAt = finishedAt + 100;
177
- }
178
- else if (status === "unconfigured") {
179
- consecutivePullFailures = 0;
180
- nextRemotePullAt = finishedAt + 30_000;
181
- }
182
- else {
183
- consecutivePullFailures = Math.min(consecutivePullFailures + 1, 6);
184
- nextRemotePullAt = finishedAt + Math.min(30_000, 1_000 * (2 ** (consecutivePullFailures - 1)));
185
- }
251
+ ({ nextRemotePullAt, consecutivePullFailures } =
252
+ pullBackoff(status, finishedAt, consecutivePullFailures));
186
253
  };
187
254
  const pullTeamMemory = (force = false) => {
188
255
  if (!store.privateDir)
@@ -195,86 +262,146 @@ export function buildServer(root) {
195
262
  remote: startupTeamRoute ?? advertisedTeamRemoteContract(root, join(store.privateDir, "..")),
196
263
  }), Date.now());
197
264
  };
198
- if (store.privateDir) {
199
- try {
200
- pullTeamMemory(true);
201
- }
202
- catch { /* offline / no remote — proceed with local */ }
203
- }
204
265
  // A source stamp is acknowledged ONLY after a stable, successful rebuild. If
205
266
  // another process changes the atomic JSON tree during the rebuild, retry once;
206
267
  // continued churn leaves the marker unset so the next request tries again.
207
- let indexedSourceStamp;
208
268
  const refreshIndex = () => {
209
- for (let attempt = 0; attempt < 2; attempt++) {
210
- const before = store.sourceStamp();
211
- store.reindexFresh();
212
- const after = store.sourceStamp();
213
- if (before === after) {
214
- indexedSourceStamp = after;
215
- return;
216
- }
217
- }
218
- indexedSourceStamp = undefined;
269
+ indexedSourceStamp = rebuildFreshIndex(store);
219
270
  };
220
- // Ensure the SQLite index reflects the JSON source of truth on startup.
221
- try {
222
- refreshIndex();
223
- }
224
- catch (e) {
225
- console.error("[hunch-mcp] reindex on startup failed:", e.message);
226
- }
227
271
  // Resolve the embedder ONCE for this long-lived process (never throws; null when
228
272
  // the optional model isn't installed). The model then loads lazily on the first
229
273
  // hunch_query and stays warm — and hybridSearch degrades to FTS until then.
230
274
  const embedderReady = selectEmbedder();
231
275
  const server = new McpServer({ name: "hunch", version: HUNCH_VERSION });
276
+ let activeRequests = 0;
277
+ let pendingRoot = null;
278
+ let pendingScheduled = false;
279
+ let closed = false;
280
+ const activateRoot = (next) => {
281
+ const canonical = findRoot(next);
282
+ if (canonical === root)
283
+ return;
284
+ const prepared = prepareRoot(canonical, explicitOverlay, true);
285
+ const previous = store;
286
+ root = prepared.root;
287
+ teamFile = prepared.teamFile;
288
+ teamAdvertised = prepared.teamAdvertised;
289
+ startupTeamConfig = prepared.startupTeamConfig;
290
+ startupTeamRoute = prepared.startupTeamRoute;
291
+ store = prepared.store;
292
+ nextRemotePullAt = prepared.nextRemotePullAt;
293
+ consecutivePullFailures = prepared.consecutivePullFailures;
294
+ indexedSourceStamp = prepared.indexedSourceStamp;
295
+ previous.close();
296
+ console.error(`[hunch-mcp] serving Hunch at ${root} (client root)`);
297
+ };
298
+ const applyPendingRoot = () => {
299
+ pendingScheduled = false;
300
+ if (closed || activeRequests || !pendingRoot)
301
+ return;
302
+ const next = pendingRoot;
303
+ pendingRoot = null;
304
+ try {
305
+ activateRoot(next);
306
+ }
307
+ catch (error) {
308
+ console.error(`[hunch-mcp] client root change refused: ${error.message}`);
309
+ }
310
+ };
311
+ const schedulePendingRoot = () => {
312
+ if (closed || activeRequests || !pendingRoot || pendingScheduled)
313
+ return;
314
+ pendingScheduled = true;
315
+ queueMicrotask(applyPendingRoot);
316
+ };
317
+ const setRoot = (next) => {
318
+ if (closed)
319
+ throw new Error("MCP server is closed");
320
+ const canonical = findRoot(next);
321
+ if (canonical === root) {
322
+ pendingRoot = null;
323
+ return;
324
+ }
325
+ if (activeRequests) {
326
+ pendingRoot = canonical;
327
+ return;
328
+ }
329
+ pendingRoot = null;
330
+ activateRoot(canonical);
331
+ };
332
+ const dispose = () => {
333
+ if (closed)
334
+ return;
335
+ closed = true;
336
+ pendingRoot = null;
337
+ store.close();
338
+ };
339
+ const underlyingClose = server.close.bind(server);
340
+ server.close = async () => {
341
+ try {
342
+ await underlyingClose();
343
+ }
344
+ finally {
345
+ dispose();
346
+ }
347
+ };
348
+ const priorOnClose = server.server.onclose;
349
+ server.server.onclose = () => {
350
+ dispose();
351
+ priorOnClose?.();
352
+ };
232
353
  const registerTool = server.registerTool.bind(server);
233
354
  server.registerTool = ((name, config, callback) => registerTool(name, config, async (...args) => {
234
- // Routing is live state, not a startup constant. A branch switch or
235
- // `hunch shared` can add/remove team.json while this stdio process remains
236
- // alive; serving the old store after that boundary would write the wrong
237
- // graph. Refuse and require a reconnect instead of attempting an in-place
238
- // HunchStore swap while requests may be active.
239
- // The explicit process overlay intentionally outranks committed team
240
- // discovery for this process, both at startup and at every later request.
241
- const teamFileNow = !explicitOverlay && existsSync(teamFile);
242
- if (teamFileNow !== teamAdvertised) {
243
- return err("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
244
- }
245
- const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
246
- if (teamAdvertised && !matchesStartupTeamRoute()) {
247
- return err("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
248
- }
249
- if (teamFileNow && (!currentTeamConfig
250
- || store.mode !== "shared"
251
- || !store.privateDir
252
- || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
253
- return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
254
- }
255
- if (store.mode === "shared" && store.privateDir) {
256
- try {
257
- pullTeamMemory();
355
+ activeRequests++;
356
+ try {
357
+ // Routing is live state, not a startup constant. A branch switch or
358
+ // `hunch shared` can add/remove team.json while this stdio process remains
359
+ // alive; serving the old store after that boundary would write the wrong
360
+ // graph. Protocol-driven root swaps are prepared atomically and deferred
361
+ // until this request count reaches zero, so every handler sees one stable
362
+ // root/store/route epoch for its complete execution.
363
+ const teamFileNow = !explicitOverlay && existsSync(teamFile);
364
+ if (teamFileNow !== teamAdvertised) {
365
+ return err("The committed team-memory routing changed after this MCP process started. Reconnect Hunch before reading or writing memory.");
258
366
  }
259
- catch { /* offline / lock held / invalid remote — use local */ }
260
- // Recompute the full semantic + physical snapshot after the synchronous
261
- // network seam. A paired team.json/origin change can occur while fetch is
262
- // blocked; serving after that race would attach the old checkout to a new
263
- // destination even though the pull itself correctly refused.
367
+ const currentTeamConfig = teamFileNow ? readTeamConfig(root) : null;
264
368
  if (teamAdvertised && !matchesStartupTeamRoute()) {
265
- return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
369
+ return err("The team-memory URL or branch changed after this MCP process started. Refusing the old graph; reconnect Hunch first.");
266
370
  }
267
- try {
268
- if (store.sourceStamp() !== indexedSourceStamp)
269
- refreshIndex();
371
+ if (teamFileNow && (!currentTeamConfig
372
+ || store.mode !== "shared"
373
+ || !store.privateDir
374
+ || !overlayMatchesTeamRemote(root, join(store.privateDir, "..")))) {
375
+ return err("The committed team memory destination is invalid or no longer matches this process. Refusing the stale graph; reconnect Hunch first.");
270
376
  }
271
- catch { /* corrupt/churning local source — serve the last durable indexed view */ }
377
+ if (store.mode === "shared" && store.privateDir) {
378
+ try {
379
+ pullTeamMemory();
380
+ }
381
+ catch { /* offline / lock held / invalid remote — use local */ }
382
+ // Recompute the full semantic + physical snapshot after the synchronous
383
+ // network seam. A paired team.json/origin change can occur while fetch is
384
+ // blocked; serving after that race would attach the old checkout to a new
385
+ // destination even though the pull itself correctly refused.
386
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
387
+ return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
388
+ }
389
+ try {
390
+ if (store.sourceStamp() !== indexedSourceStamp)
391
+ refreshIndex();
392
+ }
393
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
394
+ }
395
+ const result = await callback(...args);
396
+ if (teamAdvertised && !matchesStartupTeamRoute()) {
397
+ return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
398
+ }
399
+ return result;
272
400
  }
273
- const result = await callback(...args);
274
- if (teamAdvertised && !matchesStartupTeamRoute()) {
275
- return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
401
+ finally {
402
+ activeRequests--;
403
+ schedulePendingRoot();
276
404
  }
277
- return result;
278
405
  }));
279
406
  // -- hunch_query ----------------------------------------------------------
280
407
  server.registerTool("hunch_query", {
@@ -613,6 +740,23 @@ export function buildServer(root) {
613
740
  // same-id public record (and vice versa).
614
741
  const home = store.captureHome(!!decision.private);
615
742
  const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
743
+ // A commit-keyed id intentionally lets a human capture upgrade the machine draft
744
+ // for that commit. Once the slot is human-confirmed, however, a differently
745
+ // identified decision must never reuse it: that would silently replace the first
746
+ // ADR while reporting success (issue #23). Topic is the canonical identity when
747
+ // both sides have one; otherwise a matching title permits anchoring/refining the
748
+ // same record without blocking the existing draft-upgrade contract.
749
+ const sameHumanIdentity = existing?.topic && decision.topic
750
+ ? decision.topic === existing.topic
751
+ : existing?.title === decision.title;
752
+ const conflictsWithHuman = !!existing?.provenance.source.includes("human_confirmed")
753
+ && !sameHumanIdentity;
754
+ if (conflictsWithHuman) {
755
+ return err(`Decision id ${id} already identifies a different human-confirmed decision: ` +
756
+ `"${existing.title}"${existing.topic ? ` (topic "${existing.topic}")` : ""}. ` +
757
+ `Refusing to overwrite it with "${decision.title}"${decision.topic ? ` (topic "${decision.topic}")` : ""}. ` +
758
+ "Record the additional decision without commit, or reuse the incumbent topic/title when refining the same decision.");
759
+ }
616
760
  const source = existing && existing.provenance.source.includes("llm_draft")
617
761
  ? "llm_draft+human_confirmed"
618
762
  : "human_confirmed";
@@ -769,6 +913,96 @@ export function buildServer(root) {
769
913
  return err(`Failed to record correction: ${e.message}`);
770
914
  }
771
915
  });
916
+ // -- hunch_record_finding (write-back: observations, no diff) ---------------
917
+ server.registerTool("hunch_record_finding", {
918
+ title: "Record a finding (an observation with no code change)",
919
+ description: "Persist an OBSERVATION into Hunch — audited knowledge with no diff: an audit that surfaced a gap (e.g. queries missing tenant scoping), a measured number, a vendor/platform fact, an incident with no code fix. The anchor is a date + evidence, not a commit. Advisory: it grounds future edits to the affected files/symbols (pre-edit hook + hunch_context) and is listed by hunch_findings; it never blocks. Re-record the SAME title to update triage (e.g. triage:'resolved' + resolved_commit once fixed). If the finding is a violation of a rule that ISN'T recorded yet, record the rule first (hunch_record_correction) and link it via violates_constraint.",
920
+ inputSchema: {
921
+ finding: z.object({
922
+ title: z.string().describe("stable one-line name — re-recording the same title updates the finding"),
923
+ observation: z.string().describe("what was observed, in plain words"),
924
+ evidence: z.array(z.string()).optional().describe("the query/command run + representative output — a finding without evidence is an opinion"),
925
+ method: z.string().optional().describe("rb_* runbook that re-runs the audit (makes it re-verifiable)"),
926
+ severity: z.enum(["low", "medium", "high", "critical"]).optional(),
927
+ triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).optional().describe("default 'open'. 'resolved' should carry resolved_commit."),
928
+ affected_files: z.array(z.string()).optional().describe("paths or globs the observation concerns"),
929
+ affected_symbols: z.array(z.string()).optional().describe("symbols/objects concerned (e.g. dbo.GetOrders)"),
930
+ violates_constraint: z.string().optional().describe("con_* this finding is a known violation of"),
931
+ spawned_decision: z.string().optional().describe("dec_* recorded in response"),
932
+ resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
933
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
934
+ }),
935
+ },
936
+ }, async ({ finding }) => {
937
+ try {
938
+ if (!finding.title.trim())
939
+ return err("title is required.");
940
+ if (!finding.observation.trim())
941
+ return err("observation is required — state what you saw.");
942
+ const id = findingId(finding.title);
943
+ const home = store.captureHome(!!finding.private);
944
+ const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
945
+ const now = new Date().toISOString();
946
+ const triage = finding.triage ?? existing?.triage ?? "open";
947
+ if (triage === "resolved" && !(finding.resolved_commit ?? existing?.resolved_commit)) {
948
+ return err(`Refusing to mark ${id} resolved without resolved_commit — a resolution claim needs the fixing commit (or use triage:'stale' if it no longer applies).`);
949
+ }
950
+ const rec = {
951
+ id,
952
+ title: finding.title,
953
+ observation: finding.observation,
954
+ evidence: finding.evidence ?? existing?.evidence ?? [],
955
+ method: finding.method ?? existing?.method ?? null,
956
+ severity: finding.severity ?? existing?.severity ?? "medium",
957
+ triage,
958
+ affected_files: (finding.affected_files ?? existing?.affected_files ?? []).map(toPosixTarget),
959
+ affected_symbols: finding.affected_symbols ?? existing?.affected_symbols ?? [],
960
+ violates_constraint: finding.violates_constraint ?? existing?.violates_constraint ?? null,
961
+ spawned_decision: finding.spawned_decision ?? existing?.spawned_decision ?? null,
962
+ observed_at: existing?.observed_at ?? now, // first observation wins — updates re-verify, not re-date
963
+ resolved_commit: finding.resolved_commit ?? existing?.resolved_commit ?? null,
964
+ provenance: { source: "human_confirmed", confidence: 0.95, evidence: finding.evidence ?? existing?.provenance.evidence ?? [], last_verified: now },
965
+ };
966
+ store.putCapture("findings", rec, !!finding.private);
967
+ store.reindex();
968
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
969
+ const flushed = flushNote(flush, home, store.mode);
970
+ const where = finding.private
971
+ ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
972
+ : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
973
+ // Advisory nudges, never gates: an unresolvable constraint link and missing
974
+ // evidence both record fine, but say so.
975
+ const danglingCon = rec.violates_constraint && !store.getRec("constraints", rec.violates_constraint)
976
+ ? `\n\n△ violates_constraint ${rec.violates_constraint} resolves to no known constraint — if the rule isn't recorded yet, hunch_record_correction it and re-record this finding with the real id.`
977
+ : "";
978
+ const noEvidence = rec.evidence.length ? "" : "\n\n△ No evidence attached — a finding without the query/output that produced it is an opinion. Re-record with evidence when you have it.";
979
+ return ok(`${existing ? "Updated" : "Recorded"} finding ${id}: "${rec.title}" (${rec.triage}/${rec.severity}, observed ${rec.observed_at.slice(0, 10)}).${where} It now grounds edits to: ${[...rec.affected_files, ...rec.affected_symbols].join(", ") || "(nothing — add affected_files/symbols so it surfaces at edit time)"}.${danglingCon}${noEvidence}`);
980
+ }
981
+ catch (e) {
982
+ return err(`Failed to record finding: ${e.message}`);
983
+ }
984
+ });
985
+ // -- hunch_findings (read: the open-observations ledger) --------------------
986
+ server.registerTool("hunch_findings", {
987
+ title: "Open findings for a scope",
988
+ description: "List LIVE findings (observed gaps/debt with no fix yet — triage open/accepted-risk/scheduled) concerning a file, glob, or symbol; omit scope for the whole ledger. Call before planning work in an area to inherit past audits instead of re-discovering them. Advisory; resolved/stale findings are excluded unless all:true.",
989
+ inputSchema: {
990
+ scope: z.string().optional().describe("a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all"),
991
+ all: z.boolean().optional().describe("include resolved/stale findings (the full history)"),
992
+ },
993
+ }, async ({ scope, all }) => {
994
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
995
+ const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(all ? () => true : live))
996
+ .filter(all ? () => true : live)
997
+ .sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0) || a.id.localeCompare(b.id));
998
+ if (!list.length)
999
+ return ok(`No ${all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. (Record one after an audit with hunch_record_finding.)`);
1000
+ const L = list.slice(0, FINDINGS_CAP).map((f) => {
1001
+ const links = [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join("; ");
1002
+ return `• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})\n ${f.observation}\n concerns: ${[...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)"}${links ? `\n ${links}` : ""}`;
1003
+ });
1004
+ return ok(`${list.length} finding(s)${scope ? ` for "${scope}"` : ""}:\n${L.join("\n")}${more(list.length, FINDINGS_CAP)}`);
1005
+ });
772
1006
  server.registerTool("hunch_policy_upgrade_correction", {
773
1007
  title: "Build a proved review proposal from one exact correction",
774
1008
  description: "Upgrade the exact supported static ESM import-declaration package projection of one captured correction into a deterministic review packet when the baseline is clean. Writes proposal, plan, proof, and evidence artifacts only; never activates, warns, blocks, or grants authority. Unsupported corrections keep their immediate legacy guard and create no policy.",
@@ -1280,7 +1514,16 @@ export function buildServer(root) {
1280
1514
  return err(`Failed to materialize G2 behavior policies: ${e.message}`);
1281
1515
  }
1282
1516
  });
1283
- return server;
1517
+ return {
1518
+ server,
1519
+ getRoot: () => root,
1520
+ setRoot,
1521
+ };
1522
+ }
1523
+ /** Back-compatible server construction for tests and callers that do not need
1524
+ * to drive roots directly. The server still owns and closes its active store. */
1525
+ export function buildServer(root) {
1526
+ return buildServerWithRootControl(root).server;
1284
1527
  }
1285
1528
  function provLine(record) {
1286
1529
  const p = record?.provenance;
@@ -1289,12 +1532,44 @@ function provLine(record) {
1289
1532
  const v = p.last_verified ? `, verified ${p.last_verified.slice(0, 10)}` : "";
1290
1533
  return `\n ⟨${p.source ?? "?"}, confidence ${p.confidence ?? "?"}${v}⟩`;
1291
1534
  }
1535
+ /** Query client roots after initialization and follow later list changes.
1536
+ * Generation ordering prevents a slow stale roots/list response from winning. */
1537
+ export function wireClientRoots(control, fallback) {
1538
+ let generation = 0;
1539
+ const syncRoots = async () => {
1540
+ const mine = ++generation;
1541
+ try {
1542
+ if (!control.server.server.getClientCapabilities()?.roots)
1543
+ return;
1544
+ const response = await control.server.server.listRoots();
1545
+ if (mine !== generation)
1546
+ return;
1547
+ const next = resolveActiveRoot((response?.roots ?? []).map((root) => root.uri), fallback);
1548
+ if (!next) {
1549
+ console.error("[hunch-mcp] multiple client roots are equally plausible; keeping the current Hunch root");
1550
+ return;
1551
+ }
1552
+ control.setRoot(next);
1553
+ }
1554
+ catch (error) {
1555
+ // A client without roots support keeps the spawn root. A client-provided
1556
+ // root that fails fail-closed validation is also refused without taking
1557
+ // down the existing, already-validated graph.
1558
+ if (mine === generation) {
1559
+ console.error(`[hunch-mcp] could not apply client roots: ${error.message}`);
1560
+ }
1561
+ }
1562
+ };
1563
+ control.server.server.oninitialized = () => { void syncRoots(); };
1564
+ control.server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { await syncRoots(); });
1565
+ }
1292
1566
  /** Start the stdio server (called by `hunch mcp`). */
1293
1567
  export async function startServer(cwd = process.cwd()) {
1294
- const root = findRoot(cwd);
1295
- const server = buildServer(root);
1568
+ const fallback = findRoot(cwd);
1569
+ const control = buildServerWithRootControl(fallback);
1570
+ wireClientRoots(control, fallback);
1296
1571
  const transport = new StdioServerTransport();
1297
- await server.connect(transport);
1298
- console.error(`[hunch-mcp] serving Hunch at ${root} over stdio`);
1572
+ await control.server.connect(transport);
1573
+ console.error(`[hunch-mcp] serving Hunch over stdio (spawn root ${control.getRoot()}; resolving client roots…)`);
1299
1574
  }
1300
1575
  //# sourceMappingURL=server.js.map
@@ -351,6 +351,13 @@ export class HunchStore {
351
351
  fts(r.id, "runbooks", r.task, `${r.trigger.join(" ")} ${r.steps.join(" ")} ${r.gotchas.join(" ")} ${r.outcome} ${r.files.join(" ")}`);
352
352
  }
353
353
  counts.runbooks = runbooks.length;
354
+ // Findings (observations — audited, no diff): advisory records, same
355
+ // FTS-only ride as runbooks (dec_d32af7b821) — no dedicated SQL table.
356
+ const fnds = this.recs("findings");
357
+ for (const f of fnds) {
358
+ fts(f.id, "findings", f.title, `${f.observation} ${f.evidence.join(" ")} ${f.affected_files.join(" ")} ${f.affected_symbols.join(" ")} ${f.triage}`);
359
+ }
360
+ counts.findings = fnds.length;
354
361
  void j;
355
362
  });
356
363
  // Reconcile embeddings AFTER the FTS rebuild (model-free): drop vectors whose
@@ -978,6 +985,20 @@ export class HunchStore {
978
985
  .filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
979
986
  .sort((a, b) => sev(b.severity) - sev(a.severity));
980
987
  }
988
+ /** LIVE findings (observations — audited, no diff yet) concerning a file/scope:
989
+ * triage open / accepted-risk / scheduled; resolved and stale stay silent. The
990
+ * matcher mirrors checkConstraints: an affected entry may be a concrete path or a
991
+ * glob, and the queried scope may be either too. Advisory only — findings never
992
+ * enter any block path. Sorted worst-first, then id for stable output. */
993
+ liveFindingsFor(scope) {
994
+ const t = toPosixTarget(scope);
995
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
996
+ return this.recs("findings")
997
+ .filter(live)
998
+ .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathRelated(toPosixTarget(af), t))
999
+ || f.affected_symbols.some((s) => s === scope))
1000
+ .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1001
+ }
981
1002
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
982
1003
  * Deterministic graph join: constraint → source_decision (the decision that
983
1004
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -1420,6 +1441,13 @@ export class HunchStore {
1420
1441
  check("decision", d.id, d.related_files, d.provenance.last_verified);
1421
1442
  for (const c of this.recs("constraints"))
1422
1443
  check("constraint", c.id, c.scope, c.provenance.last_verified);
1444
+ // A LIVE finding whose affected files changed after it was observed/verified may
1445
+ // be silently fixed (or worse) — flag for re-verification (re-run its method).
1446
+ for (const f of this.recs("findings")) {
1447
+ if (f.triage === "resolved" || f.triage === "stale")
1448
+ continue;
1449
+ check("finding", f.id, f.affected_files, f.provenance.last_verified ?? f.observed_at);
1450
+ }
1423
1451
  return out.sort((a, b) => b.changed_at.localeCompare(a.changed_at));
1424
1452
  }
1425
1453
  /** The Context Assembler (DESIGN §2.1/§6): the MINIMAL relevant Hunch slice for
@@ -1445,6 +1473,7 @@ export class HunchStore {
1445
1473
  bugs,
1446
1474
  blast_radius: [...blast.values()].sort((a, b) => a.depth - b.depth).slice(0, 12),
1447
1475
  components: w.components,
1476
+ findings: this.liveFindingsFor(target).slice(0, 8),
1448
1477
  budget_tokens: budget,
1449
1478
  };
1450
1479
  return ctx;
@@ -1459,6 +1488,7 @@ function matchTripwire(tw, addedDeps, scopedAdded) {
1459
1488
  function sev(s) {
1460
1489
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
1461
1490
  }
1491
+ const SEV_FINDING = { critical: 4, high: 3, medium: 2, low: 1 };
1462
1492
  /** Is a valid-time window open at `asOf`? `valid_from` undefined = always-started
1463
1493
  * (legacy records). `valid_to` null = still in force. `asOf` undefined disables
1464
1494
  * filtering (the history-inclusive default). Half-open [from, to) so a record and
@@ -89,7 +89,7 @@ CREATE TABLE IF NOT EXISTS embeddings (
89
89
  export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
90
90
  CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
91
91
  ref UNINDEXED, -- entity id
92
- kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
92
+ kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints | runbooks | findings
93
93
  title,
94
94
  body,
95
95
  tokenize = 'porter unicode61'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.9.3",
3
+ "version": "1.10.0",
4
4
  "license": "Apache-2.0",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",