@davesheffer/hunch 1.9.4 → 1.10.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.
@@ -56,7 +56,8 @@ export function parseTestReport(output) {
56
56
  // Collect the following more-indented diagnostic block as the message.
57
57
  const baseIndent = leadingSpaces(raw);
58
58
  const block = [];
59
- for (let j = i + 1; j < lines.length; j++) {
59
+ let j = i + 1;
60
+ for (; j < lines.length; j++) {
60
61
  const ln = lines[j];
61
62
  if (ln.trim() === "") {
62
63
  block.push("");
@@ -68,6 +69,11 @@ export function parseTestReport(output) {
68
69
  }
69
70
  const diag = block.join("\n").trim();
70
71
  failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
72
+ // Skip the consumed diagnostic block (issue #51): re-visiting it let
73
+ // TAP-looking text QUOTED INSIDE an error message (assertion diffs in this
74
+ // very repo quote "ok N - …" lines) parse as real results — a phantom pass
75
+ // can mark a previously-open bug fixed without any test having re-run.
76
+ i = j - 1;
71
77
  }
72
78
  // A test can legitimately appear as both (flaky retry) — trust the failure.
73
79
  for (const name of failMap.keys())
@@ -3,8 +3,9 @@
3
3
  * context loaded every session for free"). We own ONLY the region between the
4
4
  * HUNCH markers — any user-authored content outside it is preserved verbatim.
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
- import { join, dirname } from "node:path";
6
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { writeFileAtomic } from "../core/io.js";
8
+ import { basename, join, dirname } from "node:path";
8
9
  import { wikiSummary } from "../wiki/wiki.js";
9
10
  import { PolicyRepository } from "../constitution/repository.js";
10
11
  const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
@@ -20,6 +21,7 @@ export function renderHunchSection(store, root) {
20
21
  constraints: store.json.loadAll("constraints").length,
21
22
  components: store.json.loadAll("components").length,
22
23
  policies: root ? new PolicyRepository(root, store).listPolicies({ publicOnly: true }).length : 0,
24
+ findings: store.json.loadAll("findings").filter((f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled").length,
23
25
  };
24
26
  const lines = [];
25
27
  lines.push(START);
@@ -27,7 +29,7 @@ export function renderHunchSection(store, root) {
27
29
  lines.push("");
28
30
  lines.push("This repo has **Hunch** — a curated graph of *why* the code is the way it is " +
29
31
  "(decisions, bug history, invariants). It currently holds " +
30
- `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies**.`);
32
+ `**${counts.decisions} decisions, ${counts.bugs} bugs, ${counts.constraints} constraints, ${counts.components} components, ${counts.policies} policies${counts.findings ? `, ${counts.findings} open findings` : ""}**.`);
31
33
  lines.push("");
32
34
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
33
35
  lines.push("");
@@ -47,6 +49,7 @@ export function renderHunchSection(store, root) {
47
49
  lines.push("");
48
50
  lines.push("**Before editing:**");
49
51
  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.)");
52
+ 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
53
  lines.push("");
51
54
  lines.push("**Before committing / merging:**");
52
55
  lines.push("- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.");
@@ -60,6 +63,7 @@ export function renderHunchSection(store, root) {
60
63
  lines.push("**After deciding / when corrected:**");
61
64
  lines.push("- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).");
62
65
  lines.push("- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.");
66
+ 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
67
  lines.push("- `hunch_timeline(target)` — decision history when investigating how something evolved.");
64
68
  const wiki = root ? wikiSummary(root) : null;
65
69
  if (wiki) {
@@ -100,12 +104,14 @@ export function upsertSection(file, section, fallbackTitle) {
100
104
  content = `${fallbackTitle}\n\n${section}\n`;
101
105
  }
102
106
  mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
103
- writeFileSync(file, content);
107
+ // Atomic: this file carries the USER'S prose around the managed block — a torn
108
+ // write must not be able to truncate it (issue #43).
109
+ writeFileAtomic(file, content);
104
110
  return file;
105
111
  }
106
112
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
107
113
  export function updateClaudeMd(root, store) {
108
- return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root.split("/").pop()}`);
114
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
109
115
  }
110
116
  function sev(s) {
111
117
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
@@ -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);
@@ -17,7 +17,8 @@
17
17
  * Every writer MERGES into existing files (preserving other servers / user prose)
18
18
  * and is idempotent, so re-running `hunch init` is safe.
19
19
  */
20
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
+ import { writeFileAtomic } from "../core/io.js";
21
22
  import { homedir } from "node:os";
22
23
  import { join, dirname } from "node:path";
23
24
  import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
@@ -135,7 +136,9 @@ function tomlStr(s) {
135
136
  }
136
137
  function writeJson(file, obj) {
137
138
  mkdirSync(dirname(file), { recursive: true });
138
- writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
139
+ // Atomic: these files hold the USER'S merged servers/hooks — a torn write would
140
+ // leave them unparseable, which every writer here then refuses to touch (#43).
141
+ writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
139
142
  return file;
140
143
  }
141
144
  /** Provider hook commands live in tracked config files, so use the structured
@@ -148,7 +151,14 @@ function hookCommand(inv, provider) {
148
151
  function isHunchProviderHook(entry) {
149
152
  const e = entry && typeof entry === "object" ? entry : null;
150
153
  const command = typeof e?.command === "string" ? e.command : "";
151
- return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command) && /\bhook\b/.test(command);
154
+ // Anchored to the exact shape hookCommand() writes — JSON-quoted parts ending
155
+ // in "hook" "--provider" "<name>" — plus a Hunch launcher (the pinned npm
156
+ // package spec, or a quoted …/index.js|ts path for source installs). The old
157
+ // unanchored /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries
158
+ // like `node ./hook/index.js` as ours and silently deleted them, violating
159
+ // the leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
160
+ return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts)")/.test(command)
161
+ && /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
152
162
  }
153
163
  /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
154
164
  * We replace only old Hunch commands and leave every foreign hook in place. */
@@ -255,7 +265,7 @@ export function writeCodexConfig(root, inv) {
255
265
  }
256
266
  base = base.trimEnd();
257
267
  mkdirSync(dirname(file), { recursive: true });
258
- writeFileSync(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
268
+ writeFileAtomic(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
259
269
  return file;
260
270
  }
261
271
  /** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
@@ -273,7 +283,7 @@ export function writeCursorRule(root, store) {
273
283
  const file = join(root, ".cursor", "rules", "hunch.mdc");
274
284
  const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
275
285
  mkdirSync(dirname(file), { recursive: true });
276
- writeFileSync(file, body);
286
+ writeFileAtomic(file, body);
277
287
  return file;
278
288
  }
279
289
  /** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
@@ -308,7 +318,7 @@ export function writeWindsurfRule(root, store) {
308
318
  const file = join(root, ".windsurf", "rules", "hunch.md");
309
319
  const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
310
320
  mkdirSync(dirname(file), { recursive: true });
311
- writeFileSync(file, body);
321
+ writeFileAtomic(file, body);
312
322
  return file;
313
323
  }
314
324
  /** Cursor's hook API is beta, but its project-level config accepts this standard
@@ -3,7 +3,8 @@
3
3
  * - .mcp.json → registers the `hunch` MCP server with Claude Code
4
4
  * - .claude/commands/* → user-triggered slash commands for the §5 workflows
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { writeFileAtomic } from "../core/io.js";
7
8
  import { join, dirname } from "node:path";
8
9
  /** Merge a `hunch` server entry into .mcp.json, preserving other servers.
9
10
  * A non-empty file we cannot parse THROWS instead of being silently replaced
@@ -28,7 +29,9 @@ export function writeMcpJson(root, inv) {
28
29
  }
29
30
  json.mcpServers = json.mcpServers ?? {};
30
31
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
31
- writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
32
+ // Atomic: .mcp.json holds the user's other servers — a torn write would leave
33
+ // it unparseable, which this writer then refuses to touch (issue #43).
34
+ writeFileAtomic(file, JSON.stringify(json, null, 2) + "\n");
32
35
  return file;
33
36
  }
34
37
  const WHY_CMD = `---
@@ -76,6 +79,18 @@ Capture the decision for **$ARGUMENTS** into Hunch's graph.
76
79
  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
80
  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
81
  `;
82
+ const AUDIT_CMD = `---
83
+ description: Run an audit and record what it finds into Hunch as findings (observed gaps, no code change)
84
+ ---
85
+ Audit **$ARGUMENTS** and record what you find into Hunch's graph.
86
+
87
+ 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.
88
+ 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.
89
+ 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.
90
+ 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.
91
+ 5. Triage with me inline: open (default) / accepted-risk / scheduled. NEVER mark resolved without the fixing commit (\`resolved_commit\`).
92
+ 6. Report: findings recorded (ids), what was checked and came back clean, and what stays unverified.
93
+ `;
79
94
  const HEAL_CMD = `---
80
95
  description: Reconcile docs/code with Hunch's decision graph (doc≠graph drift), never rewriting prose silently
81
96
  ---
@@ -156,25 +171,39 @@ export function installClaudeHooks(root, hookCmd) {
156
171
  if (existed && before === next)
157
172
  return { path: file, action: "unchanged" };
158
173
  mkdirSync(dirname(file), { recursive: true });
159
- writeFileSync(file, next);
174
+ writeFileAtomic(file, next);
160
175
  return { path: file, action: existed ? "updated" : "created" };
161
176
  }
177
+ /** Ownership marker for generated slash commands: its presence means Hunch may
178
+ * refresh the file; deleting the line hands the file to the user for good. */
179
+ const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
162
180
  export function writeSlashCommands(root) {
163
181
  const dir = join(root, ".claude", "commands");
164
182
  mkdirSync(dir, { recursive: true });
165
183
  const written = [];
184
+ const skipped = [];
166
185
  const files = [
167
186
  ["hunch-why.md", WHY_CMD],
168
187
  ["hunch-fix.md", FIX_CMD],
169
188
  ["hunch-fragile.md", FRAGILE_CMD],
170
189
  ["capture.md", CAPTURE_CMD],
171
190
  ["heal.md", HEAL_CMD],
191
+ ["audit.md", AUDIT_CMD],
172
192
  ];
173
193
  for (const [name, body] of files) {
174
194
  const p = join(dir, name);
175
- writeFileSync(p, body);
195
+ // Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;
196
+ // hunch-prefixed names are namespaced ours. Overwrite an existing file only
197
+ // when it carries the ownership marker or the hunch- namespace — never
198
+ // silently replace user content (issue #42). Pre-marker Hunch installs skip
199
+ // once and report; re-adopt by deleting the file and re-running init.
200
+ if (existsSync(p) && !name.startsWith("hunch-") && !readFileSync(p, "utf8").includes("hunch:generated")) {
201
+ skipped.push(p);
202
+ continue;
203
+ }
204
+ writeFileAtomic(p, `${body}\n${CMD_MARKER}\n`);
176
205
  written.push(p);
177
206
  }
178
- return written;
207
+ return { written, skipped };
179
208
  }
180
209
  //# sourceMappingURL=scaffold.js.map
@@ -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",
package/dist/mcp/roots.js CHANGED
@@ -5,10 +5,26 @@
5
5
  * another workspace or linked worktree. MCP roots are the client-neutral protocol
6
6
  * mechanism for following that change.
7
7
  */
8
- import { statSync } from "node:fs";
8
+ import { realpathSync, statSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
12
+ /** One canonical spelling per directory. `findRoot` only resolve()s, but a
13
+ * client's roots/list URI can spell the same repo differently — VS Code sends
14
+ * a lowercase drive letter (`c:\…`) while the spawn cwd has `C:\…`, and Git
15
+ * for Windows can surface 8.3/short names. Raw string comparison then treats
16
+ * ONE repo as different roots: a full re-prepare (new store + reindex) on
17
+ * every connect, or a false "multiple roots equally plausible" refusal
18
+ * (issue #54). realpathSync.native returns the on-disk spelling for all of
19
+ * these; fall back to the input when the path is transiently unreadable. */
20
+ export function canonicalRootPath(root) {
21
+ try {
22
+ return realpathSync.native(root);
23
+ }
24
+ catch {
25
+ return root;
26
+ }
27
+ }
12
28
  function toPath(uri) {
13
29
  if (!uri.startsWith("file:"))
14
30
  return "";
@@ -44,12 +60,12 @@ export function resolveActiveRoot(rootUris, fallbackCwd) {
44
60
  const start = rootStart(toPath(uri));
45
61
  if (!start)
46
62
  continue;
47
- const root = findRoot(start);
63
+ const root = canonicalRootPath(findRoot(start));
48
64
  if (!candidates.includes(root))
49
65
  candidates.push(root);
50
66
  }
51
67
  if (!candidates.length)
52
- return findRoot(fallbackCwd);
68
+ return canonicalRootPath(findRoot(fallbackCwd));
53
69
  if (candidates.length === 1)
54
70
  return candidates[0];
55
71
  const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
@@ -11,10 +11,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { z } from "zod";
13
13
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
- import { resolveActiveRoot } from "./roots.js";
14
+ import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
15
15
  import { HunchStore } from "../store/hunchStore.js";
16
16
  import { selectEmbedder } from "../store/embedder.js";
17
- import { decisionId } from "../core/ids.js";
17
+ import { decisionId, findingId } from "../core/ids.js";
18
18
  import { buildCorrectionConstraint } from "../core/correction.js";
19
19
  import { knownRepoDeps } from "../synthesis/tripwires.js";
20
20
  import { refreshExistingGrounding } from "../integrations/providers.js";
@@ -55,6 +55,7 @@ const flushNote = (flush, home, mode) => flush === "pushed" ? ` (committed + pus
55
55
  const WHY_CAP = 6; // per record-type in hunch_why
56
56
  const DEP_CAP = 25; // dependents in hunch_get_dependents
57
57
  const QUERY_HITS = 8; // hunch_query matches (was 12)
58
+ const FINDINGS_CAP = 12; // hunch_findings listing
58
59
  const SEV_CONSTRAINT = { blocking: 3, warning: 2, advisory: 1 };
59
60
  const SEV_BUG = { critical: 4, high: 3, medium: 2, low: 1 };
60
61
  const more = (total, cap, hint = "") => total > cap ? `\n …(+${total - cap} more${hint ? ` — ${hint}` : ""})` : "";
@@ -277,8 +278,11 @@ export function buildServerWithRootControl(initialRoot) {
277
278
  let pendingScheduled = false;
278
279
  let closed = false;
279
280
  const activateRoot = (next) => {
280
- const canonical = findRoot(next);
281
- if (canonical === root)
281
+ // canonicalRootPath: a case/8.3 spelling difference must not read as a
282
+ // DIFFERENT repo — that closed the live store and re-prepared everything
283
+ // on every same-repo client connect (issue #54).
284
+ const canonical = canonicalRootPath(findRoot(next));
285
+ if (canonical === canonicalRootPath(root))
282
286
  return;
283
287
  const prepared = prepareRoot(canonical, explicitOverlay, true);
284
288
  const previous = store;
@@ -385,12 +389,18 @@ export function buildServerWithRootControl(initialRoot) {
385
389
  if (teamAdvertised && !matchesStartupTeamRoute()) {
386
390
  return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
387
391
  }
388
- try {
389
- if (store.sourceStamp() !== indexedSourceStamp)
390
- refreshIndex();
391
- }
392
- catch { /* corrupt/churning local source — serve the last durable indexed view */ }
393
392
  }
393
+ // Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
394
+ // hook in another terminal writes JSON that mtime-invalidated loadAll sees
395
+ // immediately, while the SQLite FTS/graph index this long-lived process
396
+ // serves would stay frozen at startup — split-brain answers within one
397
+ // session (JSON-backed tools fresh, query/structure/dependents stale)
398
+ // until restart (issue #49).
399
+ try {
400
+ if (store.sourceStamp() !== indexedSourceStamp)
401
+ refreshIndex();
402
+ }
403
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
394
404
  const result = await callback(...args);
395
405
  if (teamAdvertised && !matchesStartupTeamRoute()) {
396
406
  return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
@@ -912,6 +922,96 @@ export function buildServerWithRootControl(initialRoot) {
912
922
  return err(`Failed to record correction: ${e.message}`);
913
923
  }
914
924
  });
925
+ // -- hunch_record_finding (write-back: observations, no diff) ---------------
926
+ server.registerTool("hunch_record_finding", {
927
+ title: "Record a finding (an observation with no code change)",
928
+ 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.",
929
+ inputSchema: {
930
+ finding: z.object({
931
+ title: z.string().describe("stable one-line name — re-recording the same title updates the finding"),
932
+ observation: z.string().describe("what was observed, in plain words"),
933
+ evidence: z.array(z.string()).optional().describe("the query/command run + representative output — a finding without evidence is an opinion"),
934
+ method: z.string().optional().describe("rb_* runbook that re-runs the audit (makes it re-verifiable)"),
935
+ severity: z.enum(["low", "medium", "high", "critical"]).optional(),
936
+ triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).optional().describe("default 'open'. 'resolved' should carry resolved_commit."),
937
+ affected_files: z.array(z.string()).optional().describe("paths or globs the observation concerns"),
938
+ affected_symbols: z.array(z.string()).optional().describe("symbols/objects concerned (e.g. dbo.GetOrders)"),
939
+ violates_constraint: z.string().optional().describe("con_* this finding is a known violation of"),
940
+ spawned_decision: z.string().optional().describe("dec_* recorded in response"),
941
+ resolved_commit: z.string().optional().describe("the commit that fixed it (with triage:'resolved')"),
942
+ private: z.boolean().optional().describe("write into the PRIVATE overlay store instead of the committed repo. Errors if no private store is configured."),
943
+ }),
944
+ },
945
+ }, async ({ finding }) => {
946
+ try {
947
+ if (!finding.title.trim())
948
+ return err("title is required.");
949
+ if (!finding.observation.trim())
950
+ return err("observation is required — state what you saw.");
951
+ const id = findingId(finding.title);
952
+ const home = store.captureHome(!!finding.private);
953
+ const existing = home === "private" ? store.getPrivateRec("findings", id) : store.json.get("findings", id);
954
+ const now = new Date().toISOString();
955
+ const triage = finding.triage ?? existing?.triage ?? "open";
956
+ if (triage === "resolved" && !(finding.resolved_commit ?? existing?.resolved_commit)) {
957
+ 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).`);
958
+ }
959
+ const rec = {
960
+ id,
961
+ title: finding.title,
962
+ observation: finding.observation,
963
+ evidence: finding.evidence ?? existing?.evidence ?? [],
964
+ method: finding.method ?? existing?.method ?? null,
965
+ severity: finding.severity ?? existing?.severity ?? "medium",
966
+ triage,
967
+ affected_files: (finding.affected_files ?? existing?.affected_files ?? []).map(toPosixTarget),
968
+ affected_symbols: finding.affected_symbols ?? existing?.affected_symbols ?? [],
969
+ violates_constraint: finding.violates_constraint ?? existing?.violates_constraint ?? null,
970
+ spawned_decision: finding.spawned_decision ?? existing?.spawned_decision ?? null,
971
+ observed_at: existing?.observed_at ?? now, // first observation wins — updates re-verify, not re-date
972
+ resolved_commit: finding.resolved_commit ?? existing?.resolved_commit ?? null,
973
+ provenance: { source: "human_confirmed", confidence: 0.95, evidence: finding.evidence ?? existing?.provenance.evidence ?? [], last_verified: now },
974
+ };
975
+ store.putCapture("findings", rec, !!finding.private);
976
+ store.reindex();
977
+ const flush = flushCapture(store, hunchPaths(root).hunch, !!finding.private, `hunch: capture ${id}`, startupTeamRoute ?? undefined);
978
+ const flushed = flushNote(flush, home, store.mode);
979
+ const where = finding.private
980
+ ? ` [PRIVATE overlay — not committed to this repo]${flushed}`
981
+ : home === "private" ? ` [SHARED store — one source of truth for the whole team]${flushed}` : flushed;
982
+ // Advisory nudges, never gates: an unresolvable constraint link and missing
983
+ // evidence both record fine, but say so.
984
+ const danglingCon = rec.violates_constraint && !store.getRec("constraints", rec.violates_constraint)
985
+ ? `\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.`
986
+ : "";
987
+ 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.";
988
+ 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}`);
989
+ }
990
+ catch (e) {
991
+ return err(`Failed to record finding: ${e.message}`);
992
+ }
993
+ });
994
+ // -- hunch_findings (read: the open-observations ledger) --------------------
995
+ server.registerTool("hunch_findings", {
996
+ title: "Open findings for a scope",
997
+ 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.",
998
+ inputSchema: {
999
+ scope: z.string().optional().describe("a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all"),
1000
+ all: z.boolean().optional().describe("include resolved/stale findings (the full history)"),
1001
+ },
1002
+ }, async ({ scope, all }) => {
1003
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
1004
+ const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(all ? () => true : live))
1005
+ .filter(all ? () => true : live)
1006
+ .sort((a, b) => (SEV_BUG[b.severity] ?? 0) - (SEV_BUG[a.severity] ?? 0) || a.id.localeCompare(b.id));
1007
+ if (!list.length)
1008
+ return ok(`No ${all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. (Record one after an audit with hunch_record_finding.)`);
1009
+ const L = list.slice(0, FINDINGS_CAP).map((f) => {
1010
+ 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("; ");
1011
+ 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}` : ""}`;
1012
+ });
1013
+ return ok(`${list.length} finding(s)${scope ? ` for "${scope}"` : ""}:\n${L.join("\n")}${more(list.length, FINDINGS_CAP)}`);
1014
+ });
915
1015
  server.registerTool("hunch_policy_upgrade_correction", {
916
1016
  title: "Build a proved review proposal from one exact correction",
917
1017
  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.",
@@ -58,6 +58,12 @@ export function planCompaction(input, opts) {
58
58
  continue; // d is being removed → its references don't count
59
59
  if (d.supersedes)
60
60
  refDec.add(d.supersedes);
61
+ // superseded_by is a reference too: supersedeIn() sets old.superseded_by
62
+ // without requiring the successor's `supersedes`, so removing a later-
63
+ // rejected successor would leave the surviving record with a dangling
64
+ // pointer AND permanently non-live for its topic (issue #36).
65
+ if (d.superseded_by)
66
+ refDec.add(d.superseded_by);
61
67
  if (d.caused_by_bug)
62
68
  refBug.add(d.caused_by_bug);
63
69
  }
@@ -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
@@ -753,11 +760,14 @@ export class HunchStore {
753
760
  const symbols = this.recs("symbols");
754
761
  const components = this.recs("components");
755
762
  const asOf = opts.asOf;
756
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
763
+ // pathRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
764
+ // so an unanchored suffix pulled unrelated files' records into why()/the
765
+ // pre-edit grounding block (issue #32). Segment-anchored matching only.
766
+ const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathRelated(s.file, target));
757
767
  const symIds = new Set(matchedSymbols.map((s) => s.id));
758
768
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
759
769
  const isPath = target.includes("/") || target.includes(".");
760
- const fileMatch = (files) => files.some((f) => f === target || (isPath && (f.endsWith(target) || target.endsWith(f))) || fileSet.has(f));
770
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && pathRelated(f, target)) || fileSet.has(f));
761
771
  return {
762
772
  target,
763
773
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -978,6 +988,20 @@ export class HunchStore {
978
988
  .filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
979
989
  .sort((a, b) => sev(b.severity) - sev(a.severity));
980
990
  }
991
+ /** LIVE findings (observations — audited, no diff yet) concerning a file/scope:
992
+ * triage open / accepted-risk / scheduled; resolved and stale stay silent. The
993
+ * matcher mirrors checkConstraints: an affected entry may be a concrete path or a
994
+ * glob, and the queried scope may be either too. Advisory only — findings never
995
+ * enter any block path. Sorted worst-first, then id for stable output. */
996
+ liveFindingsFor(scope) {
997
+ const t = toPosixTarget(scope);
998
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
999
+ return this.recs("findings")
1000
+ .filter(live)
1001
+ .filter((f) => f.affected_files.some((af) => pathMatchesGlob(t, af) || pathMatchesGlob(af, t) || pathRelated(toPosixTarget(af), t))
1002
+ || f.affected_symbols.some((s) => s === scope))
1003
+ .sort((a, b) => (SEV_FINDING[b.severity] ?? 0) - (SEV_FINDING[a.severity] ?? 0) || a.id.localeCompare(b.id));
1004
+ }
981
1005
  /** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
982
1006
  * Deterministic graph join: constraint → source_decision (the decision that
983
1007
  * motivated the guard) → the bug whose root cause spawned it (via
@@ -1420,6 +1444,13 @@ export class HunchStore {
1420
1444
  check("decision", d.id, d.related_files, d.provenance.last_verified);
1421
1445
  for (const c of this.recs("constraints"))
1422
1446
  check("constraint", c.id, c.scope, c.provenance.last_verified);
1447
+ // A LIVE finding whose affected files changed after it was observed/verified may
1448
+ // be silently fixed (or worse) — flag for re-verification (re-run its method).
1449
+ for (const f of this.recs("findings")) {
1450
+ if (f.triage === "resolved" || f.triage === "stale")
1451
+ continue;
1452
+ check("finding", f.id, f.affected_files, f.provenance.last_verified ?? f.observed_at);
1453
+ }
1423
1454
  return out.sort((a, b) => b.changed_at.localeCompare(a.changed_at));
1424
1455
  }
1425
1456
  /** The Context Assembler (DESIGN §2.1/§6): the MINIMAL relevant Hunch slice for
@@ -1445,6 +1476,7 @@ export class HunchStore {
1445
1476
  bugs,
1446
1477
  blast_radius: [...blast.values()].sort((a, b) => a.depth - b.depth).slice(0, 12),
1447
1478
  components: w.components,
1479
+ findings: this.liveFindingsFor(target).slice(0, 8),
1448
1480
  budget_tokens: budget,
1449
1481
  };
1450
1482
  return ctx;
@@ -1459,6 +1491,7 @@ function matchTripwire(tw, addedDeps, scopedAdded) {
1459
1491
  function sev(s) {
1460
1492
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
1461
1493
  }
1494
+ const SEV_FINDING = { critical: 4, high: 3, medium: 2, low: 1 };
1462
1495
  /** Is a valid-time window open at `asOf`? `valid_from` undefined = always-started
1463
1496
  * (legacy records). `valid_to` null = still in force. `asOf` undefined disables
1464
1497
  * filtering (the history-inclusive default). Half-open [from, to) so a record and
@@ -1495,7 +1528,9 @@ const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
1495
1528
  const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
1496
1529
  function numEnv(name, dflt) {
1497
1530
  const v = Number(process.env[name]);
1498
- return Number.isFinite(v) && v > 0 ? v : dflt;
1531
+ // >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
1532
+ // a stream); rejecting it silently re-enabled the default weight (issue #33).
1533
+ return Number.isFinite(v) && v >= 0 ? v : dflt;
1499
1534
  }
1500
1535
  /** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
1501
1536
  * view (byteOffset != 0) writes only its slice, not the whole backing buffer.