@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.
- package/dist/cli/index.js +65 -9
- package/dist/core/drift.js +30 -2
- package/dist/core/format.js +6 -0
- package/dist/core/ids.js +6 -0
- package/dist/core/io.js +64 -9
- package/dist/core/types.js +25 -1
- package/dist/extractors/git.js +106 -31
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +11 -5
- package/dist/integrations/gitignore.js +2 -0
- package/dist/integrations/providers.js +16 -6
- package/dist/integrations/scaffold.js +34 -5
- package/dist/integrations/team.js +1 -0
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +109 -9
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +38 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/store/schema.js +1 -1
- package/dist/synthesis/provider.js +29 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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,
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
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;
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)));
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
281
|
-
|
|
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.",
|
package/dist/store/compact.js
CHANGED
|
@@ -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
|
}
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|