@davesheffer/hunch 0.16.1 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/cli/index.js +16 -4
- package/dist/core/paths.js +15 -0
- package/dist/integrations/ciAction.js +6 -1
- package/dist/integrations/gitignore.js +4 -0
- package/dist/integrations/hooks.js +8 -4
- package/dist/mcp/server.js +28 -10
- package/dist/store/hunchStore.js +124 -71
- package/dist/synthesis/synthesize.js +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,6 +158,17 @@ is **OS-agnostic**: paths are stored in POSIX form and an installed Hunch regist
|
|
|
158
158
|
server by package name, so Windows / macOS / Linux teammates share one memory without
|
|
159
159
|
per-machine fixups. → [docs](https://hunch-pi.vercel.app/docs#team)
|
|
160
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
|
+
|
|
161
172
|
## Continuous learning (CI)
|
|
162
173
|
|
|
163
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";
|
|
@@ -64,6 +64,7 @@ program
|
|
|
64
64
|
.option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
|
|
65
65
|
.option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
|
|
66
66
|
.option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
|
|
67
|
+
.option("--private-sync", "post-commit synthesis writes captured decisions into the private overlay (HUNCH_PRIVATE_DIR), never the public repo")
|
|
67
68
|
.action((opts) => {
|
|
68
69
|
// Validate --firmness up front, before any side effects (indexing, git hooks,
|
|
69
70
|
// .mcp.json) or opening the store — a bad value must not leave a half-init.
|
|
@@ -92,8 +93,8 @@ program
|
|
|
92
93
|
console.log(` ⚠ ${res.skipped} file(s) could not be parsed (skipped)`);
|
|
93
94
|
}
|
|
94
95
|
if (isGitRepo(root)) {
|
|
95
|
-
const h = installPostCommitHook(root, inv.shell);
|
|
96
|
-
console.log(` ✓ post-commit hook ${h.action} (learning loop)`);
|
|
96
|
+
const h = installPostCommitHook(root, inv.shell, { private: opts.privateSync });
|
|
97
|
+
console.log(` ✓ post-commit hook ${h.action} (learning loop)${opts.privateSync ? " — syncs to the private overlay" : ""}`);
|
|
97
98
|
const m = installMergeDriver(root, inv.shell);
|
|
98
99
|
console.log(` ✓ team merge driver ${m.action}`);
|
|
99
100
|
// Auto-install the pre-commit guard by default (advisory: flags invariants
|
|
@@ -223,12 +224,17 @@ program
|
|
|
223
224
|
.option("--from-hook", "invoked by the git hook")
|
|
224
225
|
.option("--quiet", "minimal output")
|
|
225
226
|
.option("--force", "re-synthesize even if a decision already exists for the commit")
|
|
227
|
+
.option("--private", "write the synthesized decision into the private overlay (HUNCH_PRIVATE_DIR), not the public repo — for a repo whose memory is kept private")
|
|
226
228
|
.action(async (sha, opts) => {
|
|
227
229
|
const { store, root } = storeFor();
|
|
228
230
|
if (!isGitRepo(root))
|
|
229
231
|
return opts.quiet ? undefined : fail("sync needs a git repo");
|
|
232
|
+
if (opts.private && !store.hasPrivate) {
|
|
233
|
+
store.close();
|
|
234
|
+
return opts.quiet ? undefined : fail("--private needs HUNCH_PRIVATE_DIR set to a private store");
|
|
235
|
+
}
|
|
230
236
|
store.json.ensureDirs();
|
|
231
|
-
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force });
|
|
237
|
+
const r = await syncCommit(store, root, sha ?? headSha(root), { force: opts.force, private: opts.private });
|
|
232
238
|
if (r.status === "written") {
|
|
233
239
|
store.reindex();
|
|
234
240
|
// Don't rewrite CLAUDE.md from the hook — it would dirty the working tree
|
|
@@ -552,6 +558,7 @@ program
|
|
|
552
558
|
.option("--strict", "exit non-zero ONLY on a direct, high-confidence, non-stale blocking invariant (near/stale/low-confidence stay advisory)")
|
|
553
559
|
.option("--format <fmt>", "output: text (default) | markdown (a PR comment)", "text")
|
|
554
560
|
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
561
|
+
.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
562
|
.action((opts) => {
|
|
556
563
|
const sources = [opts.commit && "--commit", opts.base && "--base", opts.staged && "--staged"].filter(Boolean);
|
|
557
564
|
if (sources.length > 1)
|
|
@@ -582,6 +589,7 @@ program
|
|
|
582
589
|
const report = store.buildCheckReport(files, diff, {
|
|
583
590
|
strict: !!opts.strict,
|
|
584
591
|
lastChange: (f) => lastChangeDate(f, root),
|
|
592
|
+
publicOnly: !!opts.publicOnly,
|
|
585
593
|
});
|
|
586
594
|
if (opts.blast && !markdown) {
|
|
587
595
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
@@ -1041,6 +1049,10 @@ program
|
|
|
1041
1049
|
}
|
|
1042
1050
|
const c = store.reindex().counts;
|
|
1043
1051
|
console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
|
|
1052
|
+
const privDir = process.env.HUNCH_PRIVATE_DIR?.trim();
|
|
1053
|
+
console.log(privDir
|
|
1054
|
+
? `private: on → ${resolve(privDir)} (local overlay — unioned into queries; never committed or posted publicly)`
|
|
1055
|
+
: dim(`private: off — set HUNCH_PRIVATE_DIR to overlay a separate private memory repo for sensitive records`));
|
|
1044
1056
|
// Semantic search is opt-in and local. Report availability + coverage without
|
|
1045
1057
|
// loading the model (selectEmbedder only probes; embeddingStats just counts rows).
|
|
1046
1058
|
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");
|
|
@@ -9,17 +9,21 @@ import { join, isAbsolute } from "node:path";
|
|
|
9
9
|
import { hooksDir } from "../extractors/git.js";
|
|
10
10
|
const MARK = "# >>> hunch post-commit >>>";
|
|
11
11
|
const ENDMARK = "# <<< hunch post-commit <<<";
|
|
12
|
-
function block(invocation) {
|
|
12
|
+
function block(invocation, opts = {}) {
|
|
13
|
+
// --private routes the auto-synthesized decision into the HUNCH_PRIVATE_DIR overlay
|
|
14
|
+
// instead of the public repo. The hook script is local (.git/hooks/), never committed,
|
|
15
|
+
// so a repo whose memory is kept private leaves no trace of this in the public tree.
|
|
16
|
+
const priv = opts.private ? " --private" : "";
|
|
13
17
|
return [
|
|
14
18
|
MARK,
|
|
15
19
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
16
20
|
" export HUNCH_SYNC=1",
|
|
17
|
-
` ( ${invocation} sync --from-hook --quiet >/dev/null 2>&1 || true ) &`,
|
|
21
|
+
` ( ${invocation} sync --from-hook --quiet${priv} >/dev/null 2>&1 || true ) &`,
|
|
18
22
|
"fi",
|
|
19
23
|
ENDMARK,
|
|
20
24
|
].join("\n");
|
|
21
25
|
}
|
|
22
|
-
export function installPostCommitHook(root, invocation) {
|
|
26
|
+
export function installPostCommitHook(root, invocation, opts = {}) {
|
|
23
27
|
const dir = hooksDir(root);
|
|
24
28
|
// `git rev-parse --git-path hooks` returns a path relative to the repo in a
|
|
25
29
|
// normal checkout, but an ABSOLUTE one inside a linked worktree (the shared
|
|
@@ -28,7 +32,7 @@ export function installPostCommitHook(root, invocation) {
|
|
|
28
32
|
const abs = isAbsolute(dir) ? dir : join(root, dir);
|
|
29
33
|
mkdirSync(abs, { recursive: true });
|
|
30
34
|
const hookPath = join(abs, "post-commit");
|
|
31
|
-
const blk = block(invocation);
|
|
35
|
+
const blk = block(invocation, opts);
|
|
32
36
|
if (!existsSync(hookPath)) {
|
|
33
37
|
writeFileSync(hookPath, `#!/bin/sh\n${blk}\n`);
|
|
34
38
|
chmodSync(hookPath, 0o755);
|
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}`);
|
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
|
}
|
|
@@ -119,7 +119,12 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
119
119
|
},
|
|
120
120
|
date: meta.date, // the commit date
|
|
121
121
|
};
|
|
122
|
-
|
|
122
|
+
// Route to the PRIVATE overlay when asked (post-commit sync in a repo whose memory
|
|
123
|
+
// is kept private) — keeps auto-captured decisions out of the public repo entirely.
|
|
124
|
+
if (opts.private)
|
|
125
|
+
store.putPrivate("decisions", decision);
|
|
126
|
+
else
|
|
127
|
+
store.json.put("decisions", decision);
|
|
123
128
|
return { status: "written", decision, provider: provider.name };
|
|
124
129
|
}
|
|
125
130
|
/** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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.",
|