@davesheffer/hunch 0.16.0 → 0.17.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 +21 -0
- package/dist/cli/index.js +11 -4
- package/dist/core/paths.js +15 -0
- package/dist/integrations/ciAction.js +6 -1
- package/dist/integrations/gitignore.js +4 -0
- package/dist/mcp/server.js +29 -11
- package/dist/store/hunchStore.js +124 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,6 +135,16 @@ forbidden dependency) — code that never existed, so a diff reviewer is blind t
|
|
|
135
135
|
decision carries machine-checkable **tripwires**; re-introduce one and Hunch blocks it with
|
|
136
136
|
the receipt of what you rejected and why. → [docs](https://hunch-pi.vercel.app/docs#veto)
|
|
137
137
|
|
|
138
|
+
### Redundancy Guard — "this already exists"
|
|
139
|
+
|
|
140
|
+
An agent works from a *local* context window, so it re-implements a helper that already
|
|
141
|
+
lives three modules over, or re-adds a dependency the codebase already has — sprawl a
|
|
142
|
+
diff-only reviewer can't see, but Hunch's symbol graph can. Add a function or class already
|
|
143
|
+
defined elsewhere and `hunch check` / the CI guard / `hunch_merge_verdict` flag it with the
|
|
144
|
+
existing location. Deterministic and **advisory** — it never blocks; tuned to stay quiet
|
|
145
|
+
(stopword + length filters, scoped to the change's own project root, move-aware so a
|
|
146
|
+
refactor isn't mistaken for a duplicate). → [docs](https://hunch-pi.vercel.app/docs#redundancy)
|
|
147
|
+
|
|
138
148
|
Plus the **Regression Guard** (re-adding deliberately-retired code) and the
|
|
139
149
|
**[CI Constraint Guard](https://hunch-pi.vercel.app/docs#ci)** (`hunch ci` — a PR gate that
|
|
140
150
|
comments the affected `con_`/`dec_` ids and fails on a blocking one).
|
|
@@ -148,6 +158,17 @@ is **OS-agnostic**: paths are stored in POSIX form and an installed Hunch regist
|
|
|
148
158
|
server by package name, so Windows / macOS / Linux teammates share one memory without
|
|
149
159
|
per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
|
|
150
160
|
|
|
161
|
+
## Private memory (public repo, private context)
|
|
162
|
+
|
|
163
|
+
Open-source your code without open-sourcing your *reasoning*. Point `HUNCH_PRIVATE_DIR` at a
|
|
164
|
+
separate **private repo** and Hunch unions that store into every query and guard **locally** —
|
|
165
|
+
MCP and the pre-edit hook see your sensitive decisions/bugs/constraints — while your public
|
|
166
|
+
`.hunch/` stays clean. It's **opt-in and default-off** (unset the var → fully inert), and
|
|
167
|
+
**leak-safe by construction**: committed files and the CI PR comment render *public-only*, so a
|
|
168
|
+
private record can't reach a public surface. Record sensitive items with `private: true`
|
|
169
|
+
(`hunch_record_decision` / `hunch_record_correction`); `hunch doctor` shows whether the overlay
|
|
170
|
+
is on. → [docs](https://hunch-pi.vercel.app/docs#private)
|
|
171
|
+
|
|
151
172
|
## Continuous learning (CI)
|
|
152
173
|
|
|
153
174
|
The decision half of the loop is automatic (the post-commit hook). Light up the bug/constraint
|
package/dist/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
18
|
-
import { relative } from "node:path";
|
|
18
|
+
import { relative, resolve } from "node:path";
|
|
19
19
|
import { Command } from "commander";
|
|
20
20
|
import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
21
21
|
import { looksLikeCorrection, CORRECTION_NUDGE } from "../core/correction.js";
|
|
@@ -545,13 +545,14 @@ program
|
|
|
545
545
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
546
546
|
program
|
|
547
547
|
.command("check")
|
|
548
|
-
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard.")
|
|
548
|
+
.description("Flag changes that touch a do-not-break invariant — the local guardrail AND the CI/PR Constraint Guard. Also flags (advisory) symbols you add that already exist elsewhere — possible re-implementation/sprawl.")
|
|
549
549
|
.option("--staged", "check git staged files (default)")
|
|
550
550
|
.option("--commit <sha>", "check a specific commit's files")
|
|
551
551
|
.option("--base <ref>", "check a PR/branch: files changed vs <ref> (e.g. origin/main) — for CI")
|
|
552
552
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
553
553
|
.option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
|
|
554
554
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
555
|
+
.option("--public-only", "exclude the private overlay (HUNCH_PRIVATE_DIR) from the report — use for any output that may be posted publicly (the CI PR comment passes this)")
|
|
555
556
|
.action((opts) => {
|
|
556
557
|
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
557
558
|
if (sources.length > 1)
|
|
@@ -575,12 +576,14 @@ program
|
|
|
575
576
|
return;
|
|
576
577
|
}
|
|
577
578
|
// DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
|
|
578
|
-
// code) +
|
|
579
|
-
//
|
|
579
|
+
// code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
|
|
580
|
+
// hardened strict gate + causal `why` citations — all assembled by the shared
|
|
581
|
+
// store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
580
582
|
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
581
583
|
const report = store.buildCheckReport(files, diff, {
|
|
582
584
|
strict: !!opts.strict,
|
|
583
585
|
lastChange: (f) => lastChangeDate(f, root),
|
|
586
|
+
publicOnly: !!opts.publicOnly,
|
|
584
587
|
});
|
|
585
588
|
if (opts.blast && !markdown) {
|
|
586
589
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
@@ -1040,6 +1043,10 @@ program
|
|
|
1040
1043
|
}
|
|
1041
1044
|
const c = store.reindex().counts;
|
|
1042
1045
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
1046
|
+
const privDir = process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
1047
|
+
console.log(privDir
|
|
1048
|
+
? `private: on → ${resolve(privDir)} (local overlay — unioned into queries; never committed or posted publicly)`
|
|
1049
|
+
: dim(`private: off — set HUNCH_PRIVATE_DIR to overlay a separate private memory repo for sensitive records`));
|
|
1043
1050
|
// Semantic search is opt-in and local. Report availability + coverage without
|
|
1044
1051
|
// loading the model (selectEmbedder only probes; embeddingStats just counts rows).
|
|
1045
1052
|
const emb = await selectEmbedder();
|
package/dist/core/paths.js
CHANGED
|
@@ -22,6 +22,21 @@ export function hunchPaths(root) {
|
|
|
22
22
|
dir: (kind) => join(hunch, kind),
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
+
/** Build paths for a hunch-layout directory given DIRECTLY — i.e. `hunchDir` IS
|
|
26
|
+
* the dir holding the kind subdirs (decisions/, bugs/, …). Used for an external
|
|
27
|
+
* PRIVATE overlay store (HUNCH_PRIVATE_DIR), which lives in a separate repo the
|
|
28
|
+
* user controls rather than under the current repo's `.hunch/`. */
|
|
29
|
+
export function hunchPathsForDir(hunchDir) {
|
|
30
|
+
const hunch = resolve(hunchDir);
|
|
31
|
+
return {
|
|
32
|
+
root: dirname(hunch),
|
|
33
|
+
hunch,
|
|
34
|
+
sqlite: join(hunch, "hunch.sqlite"),
|
|
35
|
+
manifest: join(hunch, "manifest.json"),
|
|
36
|
+
config: join(hunch, "config.json"),
|
|
37
|
+
dir: (kind) => join(hunch, kind),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
25
40
|
/** Walk up from `start` to find the nearest repo containing a .hunch/ dir,
|
|
26
41
|
* else the nearest git repo, else `start`. Lets `hunch` run from subdirs. */
|
|
27
42
|
export function findRoot(start = process.cwd()) {
|
|
@@ -46,9 +46,14 @@ jobs:
|
|
|
46
46
|
|
|
47
47
|
- name: Run Constraint Guard
|
|
48
48
|
id: guard
|
|
49
|
+
# The report is posted as a public PR comment, so it MUST stay public-only:
|
|
50
|
+
# --public-only excludes any private overlay, and HUNCH_PRIVATE_DIR is neutralized
|
|
51
|
+
# here as defense-in-depth. Never wire a private memory store into CI.
|
|
52
|
+
env:
|
|
53
|
+
HUNCH_PRIVATE_DIR: ""
|
|
49
54
|
run: |
|
|
50
55
|
set +e
|
|
51
|
-
hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown > hunch-report.md
|
|
56
|
+
hunch check --base "origin/\${{ github.base_ref }}" --strict --format markdown --public-only > hunch-report.md
|
|
52
57
|
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
|
53
58
|
set -e
|
|
54
59
|
|
|
@@ -19,6 +19,10 @@ const ENTRIES = [
|
|
|
19
19
|
".hunch/*.sqlite-wal",
|
|
20
20
|
".hunch/*.sqlite-journal",
|
|
21
21
|
".hunch/**/*.tmp*",
|
|
22
|
+
// A local PRIVATE overlay store (HUNCH_PRIVATE_DIR) for sensitive memory — never
|
|
23
|
+
// committed. This is the conventional in-repo path; point the env elsewhere for a
|
|
24
|
+
// fully separate private repo.
|
|
25
|
+
".hunch-private/",
|
|
22
26
|
];
|
|
23
27
|
export function ensureGitignore(root) {
|
|
24
28
|
const path = join(root, ".gitignore");
|
package/dist/mcp/server.js
CHANGED
|
@@ -223,7 +223,7 @@ export function buildServer(root) {
|
|
|
223
223
|
// -- hunch_record_decision (write-back) -----------------------------------
|
|
224
224
|
server.registerTool("hunch_record_decision", {
|
|
225
225
|
title: "Record a decision (write-back)",
|
|
226
|
-
description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it.",
|
|
226
|
+
description: "Persist a new Decision (ADR) into Hunch with provenance. Use after making a non-trivial design choice so future sessions are grounded in it. Set private:true to keep a SENSITIVE decision out of a (possibly public) repo — it is written to the HUNCH_PRIVATE_DIR overlay store and stays queryable locally, never committed here.",
|
|
227
227
|
inputSchema: {
|
|
228
228
|
decision: z.object({
|
|
229
229
|
title: z.string(),
|
|
@@ -236,6 +236,7 @@ export function buildServer(root) {
|
|
|
236
236
|
status: z.enum(["proposed", "accepted", "rejected", "superseded"]).optional(),
|
|
237
237
|
commit: z.string().optional(),
|
|
238
238
|
supersedes: z.string().optional().describe("id of a decision this one replaces — closes its valid-time window (invalidate, don't delete)"),
|
|
239
|
+
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — for sensitive decisions kept out of a public repo. Errors if no private store is configured."),
|
|
239
240
|
}),
|
|
240
241
|
},
|
|
241
242
|
}, async ({ decision }) => {
|
|
@@ -251,7 +252,9 @@ export function buildServer(root) {
|
|
|
251
252
|
const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
|
|
252
253
|
// Preserve the ADR lineage: upgrading an auto-draft yields the composite
|
|
253
254
|
// provenance the design specifies.
|
|
254
|
-
|
|
255
|
+
// Public-only lookup; skip it for a private write so a private decision never
|
|
256
|
+
// inherits fields from a same-id PUBLIC record (and vice-versa).
|
|
257
|
+
const existing = decision.private ? undefined : store.json.get("decisions", id);
|
|
255
258
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
256
259
|
? "llm_draft+human_confirmed"
|
|
257
260
|
: "human_confirmed";
|
|
@@ -277,14 +280,22 @@ export function buildServer(root) {
|
|
|
277
280
|
provenance: { source, confidence: 0.95, evidence: (decision.related_files ?? existing?.provenance.evidence ?? []).map(toPosixTarget) },
|
|
278
281
|
date: now,
|
|
279
282
|
};
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
283
|
+
// Route the write: private records go to the HUNCH_PRIVATE_DIR overlay (never
|
|
284
|
+
// the committed repo); everything else to the public store. putPrivate throws
|
|
285
|
+
// if no private store is configured, so "private" can't silently fall public.
|
|
286
|
+
if (decision.private)
|
|
287
|
+
store.putPrivate("decisions", rec);
|
|
288
|
+
else
|
|
289
|
+
store.json.put("decisions", rec);
|
|
290
|
+
// Invalidate, don't delete: closing the superseded decision's valid-time window
|
|
291
|
+
// (+ a supersedes edge) preserves the why-it-changed trail. Supersede operates on
|
|
292
|
+
// the public store, so skip it for a private record (a v1 limitation, not a leak).
|
|
293
|
+
const superseded = decision.supersedes && !decision.private ? store.supersede(decision.supersedes, rec) : null;
|
|
284
294
|
store.reindex();
|
|
285
295
|
const supNote = superseded ? ` Superseded ${superseded.id} (window closed at ${rec.valid_from}).` : "";
|
|
286
296
|
const note = decision.commit && !fullSha ? ` (note: commit "${decision.commit}" could not be resolved — recorded as a standalone decision, not linked to a commit)` : "";
|
|
287
|
-
|
|
297
|
+
const where = decision.private ? " [PRIVATE overlay — not committed to this repo]" : "";
|
|
298
|
+
return ok(`Recorded decision ${id}: "${rec.title}" (status ${rec.status}, ${source}).${where}${supNote}${note}`);
|
|
288
299
|
}
|
|
289
300
|
catch (e) {
|
|
290
301
|
return err(`Failed to record decision: ${e.message}`);
|
|
@@ -302,19 +313,26 @@ export function buildServer(root) {
|
|
|
302
313
|
type: z.enum(["security", "performance", "correctness", "architecture", "compliance"]).optional(),
|
|
303
314
|
rationale: z.string().optional().describe("Why it must hold."),
|
|
304
315
|
source_decision: z.string().optional().describe("id of a decision this correction derives from."),
|
|
316
|
+
private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
|
|
305
317
|
},
|
|
306
318
|
}, async (input) => {
|
|
307
319
|
try {
|
|
308
320
|
if (!input.rule || !input.rule.trim())
|
|
309
321
|
return err("rule is required — state the invariant in plain words.");
|
|
310
322
|
const rec = buildCorrectionConstraint(input, new Date().toISOString());
|
|
311
|
-
|
|
312
|
-
|
|
323
|
+
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
324
|
+
// never rendered into the public CI comment, which is public-only by construction).
|
|
325
|
+
const existing = input.private ? undefined : store.json.get("constraints", rec.id);
|
|
326
|
+
if (input.private)
|
|
327
|
+
store.putPrivate("constraints", rec);
|
|
328
|
+
else
|
|
329
|
+
store.json.put("constraints", rec);
|
|
313
330
|
store.reindex();
|
|
314
331
|
const enforce = rec.severity === "blocking"
|
|
315
332
|
? "blocks a DIRECT edit to its scope at strict firmness, and fails a PR whose diff touches that scope (CI guard); blast-radius hits and lower firmness stay advisory"
|
|
316
333
|
: "flags violating edits and PRs (advisory)";
|
|
317
|
-
|
|
334
|
+
const where = input.private ? " [PRIVATE overlay — not committed to this repo]" : "";
|
|
335
|
+
return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.`);
|
|
318
336
|
}
|
|
319
337
|
catch (e) {
|
|
320
338
|
return err(`Failed to record correction: ${e.message}`);
|
|
@@ -323,7 +341,7 @@ export function buildServer(root) {
|
|
|
323
341
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
324
342
|
server.registerTool("hunch_merge_verdict", {
|
|
325
343
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
326
|
-
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory)
|
|
344
|
+
description: "Before opening or merging a PR, replay a diff against engineering memory and return ONE verdict — BLOCK / WARN / PASS. For each invariant DIRECTLY in scope it cites WHY the guard exists (the decision that motivated it + the bug whose root cause spawned it); it also lists invariants reached via blast radius (near, advisory), any deliberately-retired code the diff re-introduces, and symbols the diff adds that are already defined elsewhere in the graph (possible re-implementation/sprawl, advisory). Deterministic, no LLM. Omit base AND commit to check STAGED changes; pass base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
|
|
327
345
|
inputSchema: {
|
|
328
346
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
329
347
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - bugLineage(): bugs matching a symptom/symbol + their lineage
|
|
11
11
|
* - fragility(): ranked fragility report with evidence
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { toPosixTarget, hunchPathsForDir } from "../core/paths.js";
|
|
14
15
|
import { ENTITY_KINDS } from "../core/types.js";
|
|
15
16
|
import { openDb } from "./db.js";
|
|
16
17
|
import { RESET_SQL, embedHash } from "./schema.js";
|
|
@@ -23,10 +24,51 @@ import { analyzeDiff } from "../extractors/diff.js";
|
|
|
23
24
|
export class HunchStore {
|
|
24
25
|
paths;
|
|
25
26
|
json;
|
|
27
|
+
/** Optional PRIVATE overlay (HUNCH_PRIVATE_DIR) — a second store in a repo the
|
|
28
|
+
* user controls. Unioned into reads via recs(); never written by public paths. */
|
|
29
|
+
privateJson;
|
|
30
|
+
/** When true, recs() ignores the private overlay (public-only). Set transiently by
|
|
31
|
+
* buildCheckReport({publicOnly}) so any PUBLICLY-POSTED report (the CI PR comment)
|
|
32
|
+
* can never render a private record — a publicly-posted output is a leak surface
|
|
33
|
+
* equal to a committed file (dec_d7bad4ccb7). */
|
|
34
|
+
suppressPrivate = false;
|
|
26
35
|
_db = null;
|
|
27
36
|
constructor(paths) {
|
|
28
37
|
this.paths = paths;
|
|
29
38
|
this.json = new JsonStore(paths);
|
|
39
|
+
const priv = process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
40
|
+
if (priv)
|
|
41
|
+
this.privateJson = new JsonStore(hunchPathsForDir(resolve(priv)));
|
|
42
|
+
}
|
|
43
|
+
/** Merged read: public ∪ private overlay (private wins on id collision). Every
|
|
44
|
+
* QUERY / REINDEX path uses this so MCP + the guards see private memory. Public-
|
|
45
|
+
* artifact writers keep using this.json.loadAll (public-only) so a private record
|
|
46
|
+
* can never reach a committed file — see dec_d7bad4ccb7. */
|
|
47
|
+
recs(kind) {
|
|
48
|
+
const pub = this.json.loadAll(kind);
|
|
49
|
+
const priv = this.suppressPrivate ? undefined : this.privateJson?.loadAll(kind);
|
|
50
|
+
if (!priv?.length)
|
|
51
|
+
return pub;
|
|
52
|
+
const byId = new Map();
|
|
53
|
+
for (const r of pub)
|
|
54
|
+
byId.set(r.id, r);
|
|
55
|
+
for (const r of priv)
|
|
56
|
+
byId.set(r.id, r);
|
|
57
|
+
return [...byId.values()];
|
|
58
|
+
}
|
|
59
|
+
/** Whether a private overlay store is configured (HUNCH_PRIVATE_DIR is set). */
|
|
60
|
+
get hasPrivate() {
|
|
61
|
+
return !!this.privateJson;
|
|
62
|
+
}
|
|
63
|
+
/** Write a record into the PRIVATE overlay (never the public repo). Throws if no
|
|
64
|
+
* HUNCH_PRIVATE_DIR is configured, so a "private" write can never silently land
|
|
65
|
+
* in the public `.hunch/`. */
|
|
66
|
+
putPrivate(kind, record) {
|
|
67
|
+
if (!this.privateJson) {
|
|
68
|
+
throw new Error("No private store configured — set HUNCH_PRIVATE_DIR to a directory Hunch can write sensitive records into.");
|
|
69
|
+
}
|
|
70
|
+
this.privateJson.ensureDirs();
|
|
71
|
+
return this.privateJson.put(kind, record);
|
|
30
72
|
}
|
|
31
73
|
get db() {
|
|
32
74
|
if (!this._db)
|
|
@@ -50,7 +92,7 @@ export class HunchStore {
|
|
|
50
92
|
const fts = (ref, kind, title, body) => {
|
|
51
93
|
insFts.run(ref, kind, title, body ?? "");
|
|
52
94
|
};
|
|
53
|
-
const comps = this.
|
|
95
|
+
const comps = this.recs("components");
|
|
54
96
|
const insComp = db.prepare(`INSERT INTO components VALUES (@id,@kind,@name,@responsibility,@paths,@status,@owners,@fragility,@ps,@pc,@pe,@created_at,@updated_at)`);
|
|
55
97
|
for (const c of comps) {
|
|
56
98
|
insComp.run({
|
|
@@ -62,14 +104,14 @@ export class HunchStore {
|
|
|
62
104
|
fts(c.id, "components", c.name, `${c.responsibility} ${c.paths.join(" ")}`);
|
|
63
105
|
}
|
|
64
106
|
counts.components = comps.length;
|
|
65
|
-
const edges = this.
|
|
107
|
+
const edges = this.recs("edges");
|
|
66
108
|
const insEdge = db.prepare(`INSERT INTO edges VALUES (@id,@from,@to,@type,@reason,@strength,@ps,@pc,@pe)`);
|
|
67
109
|
for (const e of edges) {
|
|
68
110
|
insEdge.run({ id: e.id, from: e.from, to: e.to, type: e.type, reason: e.reason, strength: e.strength,
|
|
69
111
|
ps: e.provenance.source, pc: e.provenance.confidence, pe: JSON.stringify(e.provenance.evidence) });
|
|
70
112
|
}
|
|
71
113
|
counts.edges = edges.length;
|
|
72
|
-
const syms = this.
|
|
114
|
+
const syms = this.recs("symbols");
|
|
73
115
|
const insSym = db.prepare(`INSERT INTO symbols VALUES (@id,@file,@name,@kind,@sh,@calls,@called_by,@loc,@churn,@bug,@fanin,@fanout,@last)`);
|
|
74
116
|
for (const s of syms) {
|
|
75
117
|
insSym.run({ id: s.id, file: s.file, name: s.name, kind: s.kind, sh: s.signature_hash,
|
|
@@ -79,7 +121,7 @@ export class HunchStore {
|
|
|
79
121
|
fts(s.id, "symbols", `${s.name} (${s.kind})`, s.file);
|
|
80
122
|
}
|
|
81
123
|
counts.symbols = syms.length;
|
|
82
|
-
const decs = this.
|
|
124
|
+
const decs = this.recs("decisions");
|
|
83
125
|
const insDec = db.prepare(`INSERT INTO decisions VALUES (@id,@title,@status,@context,@decision,@cons,@alts,@rc,@rf,@sup,@cbb,@commit,@ps,@pc,@pe,@date)`);
|
|
84
126
|
for (const d of decs) {
|
|
85
127
|
insDec.run({ id: d.id, title: d.title, status: d.status, context: d.context, decision: d.decision,
|
|
@@ -90,7 +132,7 @@ export class HunchStore {
|
|
|
90
132
|
fts(d.id, "decisions", d.title, `${d.context} ${d.decision} ${d.consequences.join(" ")}`);
|
|
91
133
|
}
|
|
92
134
|
counts.decisions = decs.length;
|
|
93
|
-
const bugs = this.
|
|
135
|
+
const bugs = this.recs("bugs");
|
|
94
136
|
const insBug = db.prepare(`INSERT INTO bugs VALUES (@id,@title,@symptom,@rc,@sev,@status,@af,@as,@lin,@ps,@pc,@pe)`);
|
|
95
137
|
for (const b of bugs) {
|
|
96
138
|
insBug.run({ id: b.id, title: b.title, symptom: b.symptom, rc: b.root_cause, sev: b.severity, status: b.status,
|
|
@@ -99,7 +141,7 @@ export class HunchStore {
|
|
|
99
141
|
fts(b.id, "bugs", b.title, `${b.symptom} ${b.root_cause}`);
|
|
100
142
|
}
|
|
101
143
|
counts.bugs = bugs.length;
|
|
102
|
-
const cons = this.
|
|
144
|
+
const cons = this.recs("constraints");
|
|
103
145
|
const insCon = db.prepare(`INSERT INTO constraints VALUES (@id,@type,@statement,@scope,@sev,@enf,@rat,@sd,@viol,@ps,@pc,@pe)`);
|
|
104
146
|
for (const c of cons) {
|
|
105
147
|
insCon.run({ id: c.id, type: c.type, statement: c.statement, scope: JSON.stringify(c.scope),
|
|
@@ -297,11 +339,11 @@ export class HunchStore {
|
|
|
297
339
|
* history-inclusive view (backward-compatible default). */
|
|
298
340
|
why(target, opts = {}) {
|
|
299
341
|
target = toPosixTarget(target);
|
|
300
|
-
const decisions = this.
|
|
301
|
-
const bugs = this.
|
|
302
|
-
const constraints = this.
|
|
303
|
-
const symbols = this.
|
|
304
|
-
const components = this.
|
|
342
|
+
const decisions = this.recs("decisions");
|
|
343
|
+
const bugs = this.recs("bugs");
|
|
344
|
+
const constraints = this.recs("constraints");
|
|
345
|
+
const symbols = this.recs("symbols");
|
|
346
|
+
const components = this.recs("components");
|
|
305
347
|
const asOf = opts.asOf;
|
|
306
348
|
const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
|
|
307
349
|
const symIds = new Set(matchedSymbols.map((s) => s.id));
|
|
@@ -384,7 +426,7 @@ export class HunchStore {
|
|
|
384
426
|
* longer enforced. Pass `{ asOf }` to instead return the invariants in force at
|
|
385
427
|
* that instant (time-travel: "what must I not have broken as of commit X?"). */
|
|
386
428
|
checkConstraints(scope, opts = {}) {
|
|
387
|
-
const all = this.
|
|
429
|
+
const all = this.recs("constraints");
|
|
388
430
|
const asOf = opts.asOf;
|
|
389
431
|
return all
|
|
390
432
|
.filter((c) => c.scope.some((g) => pathMatchesGlob(scope, g) || pathMatchesGlob(g, scope) || g === scope))
|
|
@@ -403,7 +445,7 @@ export class HunchStore {
|
|
|
403
445
|
const dec = c.source_decision ? this.json.get("decisions", c.source_decision) : null;
|
|
404
446
|
if (dec)
|
|
405
447
|
out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
|
|
406
|
-
const bugs = this.
|
|
448
|
+
const bugs = this.recs("bugs");
|
|
407
449
|
// Deterministic when several bugs link one constraint (the verdict claims to be
|
|
408
450
|
// deterministic): highest severity first, then lowest id — never filesystem order.
|
|
409
451
|
const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
@@ -420,55 +462,66 @@ export class HunchStore {
|
|
|
420
462
|
* gate and a causal `why` citation per direct hit. Read-only — shared by
|
|
421
463
|
* `hunch check`, the CI guard, and hunch_merge_verdict so they never drift. */
|
|
422
464
|
buildCheckReport(files, diff, opts) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const e = near.get(c.id) ?? { c, via: [] };
|
|
437
|
-
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
438
|
-
near.set(c.id, e);
|
|
465
|
+
// publicOnly excludes the private overlay from THIS report — required for any output
|
|
466
|
+
// that may be posted publicly (the CI PR comment), since a posted comment is a leak
|
|
467
|
+
// surface equal to a committed file. Local `hunch check` / the pre-edit hook omit it
|
|
468
|
+
// and so still enforce private constraints. See dec_d7bad4ccb7.
|
|
469
|
+
const prevSuppress = this.suppressPrivate;
|
|
470
|
+
this.suppressPrivate = opts.publicOnly ?? false;
|
|
471
|
+
try {
|
|
472
|
+
const direct = new Map();
|
|
473
|
+
for (const f of files)
|
|
474
|
+
for (const c of this.checkConstraints(f)) {
|
|
475
|
+
const e = direct.get(c.id) ?? { c, files: [] };
|
|
476
|
+
e.files.push(f);
|
|
477
|
+
direct.set(c.id, e);
|
|
439
478
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
const
|
|
479
|
+
const near = new Map();
|
|
480
|
+
for (const f of files)
|
|
481
|
+
for (const b of this.blastRadiusFiles(f))
|
|
482
|
+
for (const c of this.checkConstraints(b.file)) {
|
|
483
|
+
if (direct.has(c.id))
|
|
484
|
+
continue;
|
|
485
|
+
const e = near.get(c.id) ?? { c, via: [] };
|
|
486
|
+
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
487
|
+
near.set(c.id, e);
|
|
488
|
+
}
|
|
489
|
+
const an = analyzeDiff(diff);
|
|
490
|
+
const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
491
|
+
const staleRecords = opts.strict && opts.lastChange ? this.staleness(opts.lastChange) : [];
|
|
492
|
+
const staleIds = new Set(staleRecords.filter((s) => s.kind === "constraint").map((s) => s.id));
|
|
493
|
+
const staleDecisionIds = new Set(staleRecords.filter((s) => s.kind === "decision").map((s) => s.id));
|
|
494
|
+
const vetoes = this.vetoHits(an, files, staleDecisionIds);
|
|
495
|
+
const redundant = this.redundantSymbols(an.addedSymbols, files, {
|
|
496
|
+
movedFrom: [...an.filesRenamed.map((r) => r.from), ...an.filesDeleted],
|
|
497
|
+
removedNames: new Set(an.removedSymbols.map((s) => s.name)),
|
|
498
|
+
});
|
|
499
|
+
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
500
|
+
const stale = staleIds.has(c.id);
|
|
501
|
+
const strictBlocks = isStrictBlocker(c, stale);
|
|
502
|
+
return {
|
|
503
|
+
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
504
|
+
files: fs, strictBlocks,
|
|
505
|
+
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
506
|
+
why: this.causalChain(c.id),
|
|
507
|
+
};
|
|
508
|
+
});
|
|
453
509
|
return {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
510
|
+
fileCount: files.length,
|
|
511
|
+
strict: opts.strict,
|
|
512
|
+
direct: directReport,
|
|
513
|
+
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
514
|
+
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
515
|
+
vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
|
|
516
|
+
redundant,
|
|
517
|
+
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
518
|
+
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
519
|
+
vetoBlocking: vetoes.filter((v) => v.blocks).length,
|
|
458
520
|
};
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
direct: directReport,
|
|
464
|
-
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
465
|
-
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
466
|
-
vetoes: vetoes.map((v) => ({ decision: v.decision, title: v.title, alternative: v.alternative, chosen: v.chosen, tier: v.tier, evidence: v.evidence, blocking: v.blocks })),
|
|
467
|
-
redundant,
|
|
468
|
-
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
469
|
-
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
470
|
-
vetoBlocking: vetoes.filter((v) => v.blocks).length,
|
|
471
|
-
};
|
|
521
|
+
}
|
|
522
|
+
finally {
|
|
523
|
+
this.suppressPrivate = prevSuppress;
|
|
524
|
+
}
|
|
472
525
|
}
|
|
473
526
|
/** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
|
|
474
527
|
* ADDS whose name already exists in the indexed graph in a file NOT touched by the
|
|
@@ -503,7 +556,7 @@ export class HunchStore {
|
|
|
503
556
|
// test fixture or a separate sub-project (test/, vscode-extension/, site/) is not
|
|
504
557
|
// sprawl in the source under change — different roots, different ownership.
|
|
505
558
|
const roots = new Set(files.map((f) => f.split("/")[0]));
|
|
506
|
-
const symbols = this.
|
|
559
|
+
const symbols = this.recs("symbols");
|
|
507
560
|
const out = [];
|
|
508
561
|
const seen = new Set();
|
|
509
562
|
for (const sc of added) {
|
|
@@ -567,9 +620,9 @@ export class HunchStore {
|
|
|
567
620
|
if (!addedSyms.size && !addedDeps.size)
|
|
568
621
|
return [];
|
|
569
622
|
const fileRelevant = (related) => related.some((f) => files.some((x) => pathRelated(x, f)));
|
|
570
|
-
const decisions = this.
|
|
623
|
+
const decisions = this.recs("decisions");
|
|
571
624
|
// decisions tied to an active blocking constraint via source_decision
|
|
572
|
-
const blockingDec = new Set(this.
|
|
625
|
+
const blockingDec = new Set(this.recs("constraints")
|
|
573
626
|
.filter((c) => c.severity === "blocking" && c.status !== "retired" && c.source_decision)
|
|
574
627
|
.map((c) => c.source_decision));
|
|
575
628
|
const out = [];
|
|
@@ -613,7 +666,7 @@ export class HunchStore {
|
|
|
613
666
|
const addedDeps = new Set(an.addedDeps);
|
|
614
667
|
const out = [];
|
|
615
668
|
const seen = new Set(); // dedup: one hit per decision+alternative
|
|
616
|
-
for (const d of this.
|
|
669
|
+
for (const d of this.recs("decisions")) {
|
|
617
670
|
if (d.superseded_by || d.status === "superseded")
|
|
618
671
|
continue; // in-force only
|
|
619
672
|
const tripwires = d.rejected_tripwires ?? [];
|
|
@@ -683,7 +736,7 @@ export class HunchStore {
|
|
|
683
736
|
* available at edit time, so this surfaces the risk as context, not a block. */
|
|
684
737
|
retiredForFile(file) {
|
|
685
738
|
const out = [];
|
|
686
|
-
for (const d of this.
|
|
739
|
+
for (const d of this.recs("decisions")) {
|
|
687
740
|
if (d.superseded_by || d.status === "superseded")
|
|
688
741
|
continue;
|
|
689
742
|
if (!d.retired.symbols.length && !d.retired.deps.length)
|
|
@@ -696,7 +749,7 @@ export class HunchStore {
|
|
|
696
749
|
}
|
|
697
750
|
/** Bugs matching a symptom (FTS over bugs) or a symbol, with lineage (hunch_bug_lineage). */
|
|
698
751
|
bugLineage(symptomOrSymbol) {
|
|
699
|
-
const bugs = this.
|
|
752
|
+
const bugs = this.recs("bugs");
|
|
700
753
|
const direct = bugs.filter((b) => b.affected_symbols.includes(symptomOrSymbol) || b.affected_files.includes(symptomOrSymbol));
|
|
701
754
|
if (direct.length)
|
|
702
755
|
return direct;
|
|
@@ -711,8 +764,8 @@ export class HunchStore {
|
|
|
711
764
|
}
|
|
712
765
|
/** Ranked fragility report (hunch fragile). fragility = weighted churn + bugs + fan-in. */
|
|
713
766
|
fragility(limit = 15) {
|
|
714
|
-
const syms = this.
|
|
715
|
-
const bugs = this.
|
|
767
|
+
const syms = this.recs("symbols");
|
|
768
|
+
const bugs = this.recs("bugs");
|
|
716
769
|
// bug counts per symbol from actual bug records (authoritative over stale metric)
|
|
717
770
|
const bugBySym = new Map();
|
|
718
771
|
for (const b of bugs)
|
|
@@ -749,7 +802,7 @@ export class HunchStore {
|
|
|
749
802
|
}
|
|
750
803
|
/** All edges (for graph export). */
|
|
751
804
|
allEdges() {
|
|
752
|
-
return this.
|
|
805
|
+
return this.recs("edges");
|
|
753
806
|
}
|
|
754
807
|
/** Drift detection (DESIGN §9 "staleness kills trust"): a decision/constraint
|
|
755
808
|
* is STALE when a file in its scope changed AFTER it was last verified. The
|
|
@@ -771,9 +824,9 @@ export class HunchStore {
|
|
|
771
824
|
if (newest)
|
|
772
825
|
out.push({ kind, id, last_verified: verified, changed_at: newest, files: files.slice(0, 8) });
|
|
773
826
|
};
|
|
774
|
-
for (const d of this.
|
|
827
|
+
for (const d of this.recs("decisions"))
|
|
775
828
|
check("decision", d.id, d.related_files, d.provenance.last_verified);
|
|
776
|
-
for (const c of this.
|
|
829
|
+
for (const c of this.recs("constraints"))
|
|
777
830
|
check("constraint", c.id, c.scope, c.provenance.last_verified);
|
|
778
831
|
return out.sort((a, b) => b.changed_at.localeCompare(a.changed_at));
|
|
779
832
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
|