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