@davesheffer/hunch 1.9.4 → 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/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")
@@ -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
@@ -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 = []) {
@@ -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
  ], {
@@ -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",
@@ -14,7 +14,7 @@ import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
14
  import { 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}` : ""})` : "";
@@ -912,6 +913,96 @@ export function buildServerWithRootControl(initialRoot) {
912
913
  return err(`Failed to record correction: ${e.message}`);
913
914
  }
914
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
+ });
915
1006
  server.registerTool("hunch_policy_upgrade_correction", {
916
1007
  title: "Build a proved review proposal from one exact correction",
917
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.",
@@ -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.4",
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.",