@davesheffer/hunch 1.4.1 → 1.5.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 +91 -281
- package/dist/cli/index.js +219 -29
- package/dist/core/autoreview.js +52 -0
- package/dist/core/drift.js +25 -3
- package/dist/core/refrepair.js +33 -0
- package/dist/extractors/git.js +31 -1
- package/dist/integrations/hooks.js +4 -0
- package/dist/mcp/server.js +23 -22
- package/dist/store/hunchStore.js +26 -5
- package/dist/synthesis/provider.js +58 -0
- package/dist/synthesis/synthesize.js +32 -17
- package/package.json +1 -1
package/dist/extractors/git.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* No LLM here — just parsing what git already knows. */
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { isAbsolute, resolve, join, basename, dirname } from "node:path";
|
|
5
|
-
import { mkdirSync, rmSync, statSync, realpathSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, rmSync, statSync, realpathSync, readFileSync } from "node:fs";
|
|
6
6
|
function git(args, cwd, maxBuffer = 64 * 1024 * 1024) {
|
|
7
7
|
// stdio: capture stdout, silence stderr (so "no commits yet" etc. don't leak).
|
|
8
8
|
return execFileSync("git", args, {
|
|
@@ -377,6 +377,14 @@ export function stagedFiles(cwd) {
|
|
|
377
377
|
const out = gitSafe(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], cwd);
|
|
378
378
|
return out ? out.split("\n").filter(Boolean) : [];
|
|
379
379
|
}
|
|
380
|
+
/** Files changed anywhere in the working tree compared with HEAD: both staged
|
|
381
|
+
* and unstaged tracked files, plus untracked files. This powers the local,
|
|
382
|
+
* pre-commit Change Gate; it never mutates the index or asks an agent/model. */
|
|
383
|
+
export function workingFiles(cwd) {
|
|
384
|
+
const changed = gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
|
|
385
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
|
|
386
|
+
return [...new Set([...changed, ...untracked])].sort();
|
|
387
|
+
}
|
|
380
388
|
/** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
|
|
381
389
|
* unfetched/typo'd ref instead of silently diffing against nothing (a vacuous
|
|
382
390
|
* CI pass), since the diff helpers below swallow git errors to "". */
|
|
@@ -408,6 +416,28 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
|
|
|
408
416
|
const out = gitSafe(["diff", "--cached", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
409
417
|
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
410
418
|
}
|
|
419
|
+
/** Unified diff of the complete local working tree vs HEAD. Git's normal diff
|
|
420
|
+
* includes both staged and unstaged tracked edits; untracked text files are
|
|
421
|
+
* appended as synthetic additions so guards can also see their added symbols.
|
|
422
|
+
* Binary/unreadable files remain in workingFiles (scope checks still apply) but
|
|
423
|
+
* intentionally contribute no synthetic content to regression analysis. */
|
|
424
|
+
export function workingDiff(cwd, maxBytes = 60_000) {
|
|
425
|
+
let out = gitSafe(["diff", "HEAD", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
|
|
426
|
+
const tracked = new Set(gitSafe(["diff", "HEAD", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
|
|
427
|
+
const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
|
|
428
|
+
for (const file of untracked) {
|
|
429
|
+
try {
|
|
430
|
+
const text = readFileSync(join(cwd, file), "utf8");
|
|
431
|
+
if (text.includes("\0"))
|
|
432
|
+
continue;
|
|
433
|
+
const lines = text.split("\n");
|
|
434
|
+
const add = lines.map((line) => `+${line}`).join("\n");
|
|
435
|
+
out += `${out ? "\n" : ""}diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${add}\n`;
|
|
436
|
+
}
|
|
437
|
+
catch { /* unreadable / directory / binary: scope-only is still safe */ }
|
|
438
|
+
}
|
|
439
|
+
return out.length > maxBytes ? out.slice(0, maxBytes) + "\n…(diff truncated)…" : out;
|
|
440
|
+
}
|
|
411
441
|
/** Resolve a time-travel ref (commit / tag / branch / HEAD~n) to the ISO author-
|
|
412
442
|
* date of that commit — the instant valid-time windows are filtered against.
|
|
413
443
|
* Undefined if it can't be resolved (not a git repo, or an unknown ref). Single
|
|
@@ -20,6 +20,10 @@ function block(invocation, opts = {}) {
|
|
|
20
20
|
MARK,
|
|
21
21
|
'if [ -z "$HUNCH_SYNC" ]; then',
|
|
22
22
|
" export HUNCH_SYNC=1",
|
|
23
|
+
// A split-private capture must not make a storage-private promise and then
|
|
24
|
+
// ship the commit diff to a subscription CLI. Shared overlays are a separate
|
|
25
|
+
// team policy, so only the explicit local-only mode forces deterministic.
|
|
26
|
+
...(opts.localOnly ? [" export HUNCH_SYNTH_PROVIDER=deterministic"] : []),
|
|
23
27
|
` ( ${invocation} sync --from-hook --quiet${priv}${commit} >/dev/null 2>&1 || true ) &`,
|
|
24
28
|
"fi",
|
|
25
29
|
ENDMARK,
|
package/dist/mcp/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
17
|
import { knownRepoDeps } from "../synthesis/tripwires.js";
|
|
18
18
|
import { refreshExistingGrounding } from "../integrations/providers.js";
|
|
19
|
-
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, pullHunch } from "../extractors/git.js";
|
|
19
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff, workingFiles, workingDiff, pullHunch } from "../extractors/git.js";
|
|
20
20
|
import { flushCapture } from "../integrations/sync.js";
|
|
21
21
|
import { ensureTeamOverlay } from "../integrations/team.js";
|
|
22
22
|
import { formatContext, formatStructure } from "../core/format.js";
|
|
@@ -446,11 +446,11 @@ export function buildServer(root) {
|
|
|
446
446
|
const resolved = decision.commit ? revParse(decision.commit, root) : null;
|
|
447
447
|
const fullSha = resolved && /^[0-9a-f]{40}$/.test(resolved) ? resolved : null;
|
|
448
448
|
const id = fullSha ? decisionId(fullSha) : decisionId(`manual:${decision.title}`);
|
|
449
|
-
// Preserve the ADR lineage
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
const existing =
|
|
449
|
+
// Preserve the ADR lineage from the SAME home this write will use. A private
|
|
450
|
+
// re-record must retain its own optional fields, but must never inherit a
|
|
451
|
+
// same-id public record (and vice versa).
|
|
452
|
+
const home = store.captureHome(!!decision.private);
|
|
453
|
+
const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
|
|
454
454
|
const source = existing && existing.provenance.source.includes("llm_draft")
|
|
455
455
|
? "llm_draft+human_confirmed"
|
|
456
456
|
: "human_confirmed";
|
|
@@ -482,7 +482,6 @@ export function buildServer(root) {
|
|
|
482
482
|
// private:false, so the guard must key its incumbent lookup on HOME, not on
|
|
483
483
|
// the flag — keying on the flag let a shared-mode supersede of a public
|
|
484
484
|
// incumbent pass the guard and then no-op the close (two live decisions).
|
|
485
|
-
const home = store.captureHome(!!decision.private);
|
|
486
485
|
// Decision-grounding uniqueness guard (§4 Enforcement): never create a SECOND
|
|
487
486
|
// live decision for one topic. Exclude ONLY the incumbent this write will
|
|
488
487
|
// actually close — one resolvable in the SAME store the write lands in. A
|
|
@@ -571,7 +570,7 @@ export function buildServer(root) {
|
|
|
571
570
|
// Private corrections go to the overlay (enforced locally via the merged read,
|
|
572
571
|
// never rendered into the public CI comment, which is public-only by construction).
|
|
573
572
|
const home = store.captureHome(!!input.private);
|
|
574
|
-
const existing = home === "private" ?
|
|
573
|
+
const existing = home === "private" ? store.getPrivateRec("constraints", rec.id) : store.json.get("constraints", rec.id);
|
|
575
574
|
if (home === "private")
|
|
576
575
|
store.putPrivate("constraints", rec);
|
|
577
576
|
else
|
|
@@ -600,24 +599,25 @@ export function buildServer(root) {
|
|
|
600
599
|
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
601
600
|
server.registerTool("hunch_merge_verdict", {
|
|
602
601
|
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
603
|
-
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
|
|
602
|
+
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, commit, and working to check STAGED changes; pass working:true for all local changes, base (e.g. origin/main) for a PR range, or commit for a single commit. Call this before merging a widely-scoped change.",
|
|
604
603
|
inputSchema: {
|
|
605
604
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
606
605
|
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
|
606
|
+
working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
|
|
607
607
|
},
|
|
608
|
-
}, async ({ base, commit }) => {
|
|
608
|
+
}, async ({ base, commit, working }) => {
|
|
609
609
|
try {
|
|
610
|
-
if (base
|
|
611
|
-
return err("Pass at most one of base/commit (omit
|
|
610
|
+
if ([base, commit, working].filter(Boolean).length > 1)
|
|
611
|
+
return err("Pass at most one of base/commit/working (omit all to check staged changes).");
|
|
612
612
|
if (base && !revExists(base, root))
|
|
613
613
|
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
614
614
|
if (commit && !revExists(commit, root))
|
|
615
615
|
return err(`commit "${commit}" does not resolve.`);
|
|
616
|
-
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
617
|
-
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
616
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
617
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
618
618
|
if (!files.length)
|
|
619
619
|
return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
|
|
620
|
-
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
620
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
|
|
621
621
|
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
622
622
|
const v = verdict(report);
|
|
623
623
|
const head = v === "block"
|
|
@@ -642,24 +642,25 @@ export function buildServer(root) {
|
|
|
642
642
|
// -- hunch_pr_impact (read-only impact surface — advisory, never gates) ----
|
|
643
643
|
server.registerTool("hunch_pr_impact", {
|
|
644
644
|
title: "PR impact: the dependency + memory surface of a change",
|
|
645
|
-
description: "Given a change (staged, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base
|
|
645
|
+
description: "Given a change (staged, working tree, a branch vs base, or a single commit), return its IMPACT SURFACE: the files whose code transitively depends on the changed files, the invariants directly in scope and those reached via blast radius, and the recorded decisions concerning the touched files. Read-only and advisory — use hunch_merge_verdict for the gate. Call before review to know what a PR can break and which recorded intent it touches. Omit base, commit, and working for staged changes.",
|
|
646
646
|
inputSchema: {
|
|
647
647
|
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
648
648
|
commit: z.string().optional().describe("Impact of a single commit (sha/ref). Omit base AND commit for staged changes."),
|
|
649
|
+
working: z.boolean().optional().describe("Include all working-tree changes vs HEAD (staged, unstaged, and untracked files)."),
|
|
649
650
|
},
|
|
650
|
-
}, async ({ base, commit }) => {
|
|
651
|
+
}, async ({ base, commit, working }) => {
|
|
651
652
|
try {
|
|
652
|
-
if (base
|
|
653
|
-
return err("Pass at most one of base/commit (omit
|
|
653
|
+
if ([base, commit, working].filter(Boolean).length > 1)
|
|
654
|
+
return err("Pass at most one of base/commit/working (omit all for staged changes).");
|
|
654
655
|
if (base && !revExists(base, root))
|
|
655
656
|
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
656
657
|
if (commit && !revExists(commit, root))
|
|
657
658
|
return err(`commit "${commit}" does not resolve.`);
|
|
658
|
-
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
659
|
-
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
659
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : working ? workingFiles(root) : stagedFiles(root);
|
|
660
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : working ? "working changes" : "staged changes";
|
|
660
661
|
if (!files.length)
|
|
661
662
|
return ok(`No changed files in ${scope}.`);
|
|
662
|
-
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
663
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : working ? workingDiff(root) : stagedDiff(root);
|
|
663
664
|
return ok(renderImpact(store.prImpact(files, diff), scope));
|
|
664
665
|
}
|
|
665
666
|
catch (e) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -98,6 +98,12 @@ export class HunchStore {
|
|
|
98
98
|
getRec(kind, id) {
|
|
99
99
|
return this.privateJson?.get(kind, id) ?? this.json.get(kind, id);
|
|
100
100
|
}
|
|
101
|
+
/** Read a record only from the configured private overlay. Callers that must
|
|
102
|
+
* preserve privacy boundaries (for example, an explicit `--private` repair)
|
|
103
|
+
* should use this instead of overlay-first `getRec`. */
|
|
104
|
+
getPrivateRec(kind, id) {
|
|
105
|
+
return this.privateJson?.get(kind, id);
|
|
106
|
+
}
|
|
101
107
|
/** Update an EXISTING record in the store that holds it — an overlay record must never
|
|
102
108
|
* fork a public copy on update (and vice versa). Falls back to captureHome routing for
|
|
103
109
|
* a record that exists nowhere yet. */
|
|
@@ -109,6 +115,14 @@ export class HunchStore {
|
|
|
109
115
|
return this.json.put(kind, record);
|
|
110
116
|
return this.putCapture(kind, record);
|
|
111
117
|
}
|
|
118
|
+
/** Delete an existing record from its actual home. The review/curation path
|
|
119
|
+
* uses this so rejecting a private draft cannot silently leave it behind or
|
|
120
|
+
* accidentally target a public record with the same id. */
|
|
121
|
+
deleteWhereItLives(kind, id) {
|
|
122
|
+
if (this.privateJson?.get(kind, id))
|
|
123
|
+
return this.privateJson.delete(kind, id);
|
|
124
|
+
return this.json.delete(kind, id);
|
|
125
|
+
}
|
|
112
126
|
/** The private-overlay config from the gitignored `.hunch/local.json` (per-machine,
|
|
113
127
|
* never committed). Tolerant: returns {} on missing/invalid so reads never crash.
|
|
114
128
|
* `autoCommit` is tri-state: true/false when the file says so, undefined when unset.
|
|
@@ -161,6 +175,12 @@ export class HunchStore {
|
|
|
161
175
|
byId.set(r.id, r);
|
|
162
176
|
return [...byId.values()];
|
|
163
177
|
}
|
|
178
|
+
/** Records from exactly one storage home (no public/private union). Capture
|
|
179
|
+
* paths use this for identity/lineage checks so a private record can never
|
|
180
|
+
* inherit or disclose relationships from an identically-shaped public record. */
|
|
181
|
+
recsInHome(kind, home) {
|
|
182
|
+
return home === "private" ? (this.privateJson?.loadAll(kind) ?? []) : this.json.loadAll(kind);
|
|
183
|
+
}
|
|
164
184
|
/** Whether a private overlay store is configured (HUNCH_PRIVATE_DIR is set). */
|
|
165
185
|
get hasPrivate() {
|
|
166
186
|
return !!this.privateJson;
|
|
@@ -883,13 +903,14 @@ export class HunchStore {
|
|
|
883
903
|
* lineage.spawned_constraint, else the source decision's caused_by_bug). Read-only. */
|
|
884
904
|
causalChain(constraintId) {
|
|
885
905
|
const out = { constraint_id: constraintId };
|
|
886
|
-
const
|
|
906
|
+
const get = (kind, id) => this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
|
|
907
|
+
const c = get("constraints", constraintId);
|
|
887
908
|
if (!c)
|
|
888
909
|
return out;
|
|
889
|
-
const dec = c.source_decision ?
|
|
910
|
+
const dec = c.source_decision ? get("decisions", c.source_decision) : null;
|
|
890
911
|
if (dec)
|
|
891
912
|
out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
|
|
892
|
-
const bugs = this.recs("bugs");
|
|
913
|
+
const bugs = this.suppressPrivate ? this.json.loadAll("bugs") : this.recs("bugs");
|
|
893
914
|
// Deterministic when several bugs link one constraint (the verdict claims to be
|
|
894
915
|
// deterministic): highest severity first, then lowest id — never filesystem order.
|
|
895
916
|
const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
@@ -1201,7 +1222,7 @@ export class HunchStore {
|
|
|
1201
1222
|
/** Resolve a veto's causal citation: the bug whose root cause spawned the decision
|
|
1202
1223
|
* (decision → caused_by_bug). Distinct from causalChain, which is constraint-keyed. */
|
|
1203
1224
|
vetoWhy(bugId) {
|
|
1204
|
-
const bug = this.json.get("bugs", bugId);
|
|
1225
|
+
const bug = this.suppressPrivate ? this.json.get("bugs", bugId) : this.getRec("bugs", bugId);
|
|
1205
1226
|
return bug ? { bug: { id: bug.id, title: bug.title, root_cause: bug.root_cause } } : undefined;
|
|
1206
1227
|
}
|
|
1207
1228
|
/** Veto check for a LIVE edit (the agent pre-edit hook): no diff exists yet, so
|
|
@@ -1284,7 +1305,7 @@ export class HunchStore {
|
|
|
1284
1305
|
/** Convenience: load a single entity from JSON by id (any kind). */
|
|
1285
1306
|
resolve(id) {
|
|
1286
1307
|
for (const kind of ENTITY_KINDS) {
|
|
1287
|
-
const rec = this.json.get(kind, id);
|
|
1308
|
+
const rec = this.suppressPrivate ? this.json.get(kind, id) : this.getRec(kind, id);
|
|
1288
1309
|
if (rec)
|
|
1289
1310
|
return { kind, record: rec };
|
|
1290
1311
|
}
|
|
@@ -131,6 +131,20 @@ const BUG_TOOL = {
|
|
|
131
131
|
required: ["title", "symptom", "root_cause", "severity"],
|
|
132
132
|
},
|
|
133
133
|
};
|
|
134
|
+
const RELEVANCE_TOOL = {
|
|
135
|
+
name: "emit_relevance",
|
|
136
|
+
description: "Judge whether an auto-drafted decision is worth keeping in the memory graph.",
|
|
137
|
+
input_schema: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: {
|
|
140
|
+
relevant: { type: "boolean", description: "true if this records a REAL, reusable design choice worth keeping. false if it is noise: a mechanical restatement of the diff, a trivial/obvious change, or content unsupported by the evidence." },
|
|
141
|
+
confidence: { type: "number", description: "0..1 confidence in the relevant call. Be honest; low when unsure." },
|
|
142
|
+
duplicate_of: { type: ["string", "null"], description: "id (dec_...) of an existing decision this merely restates, from the EXISTING DECISIONS list. null if none." },
|
|
143
|
+
reason: { type: "string", description: "one short line justifying the call." },
|
|
144
|
+
},
|
|
145
|
+
required: ["relevant", "confidence", "duplicate_of", "reason"],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
134
148
|
const VERIFY_TOOL = {
|
|
135
149
|
name: "emit_verdict",
|
|
136
150
|
description: "Emit a skeptical audit of a synthesized decision against its commit.",
|
|
@@ -217,6 +231,16 @@ class CliSynthProvider {
|
|
|
217
231
|
throw new Error(`${this.name}: no usable verdict JSON in output`);
|
|
218
232
|
return verdict;
|
|
219
233
|
}
|
|
234
|
+
/** Judge whether an auto-drafted decision is worth keeping (for auto-review).
|
|
235
|
+
* Same subscription-only run() path (API keys stripped). Throws on unusable
|
|
236
|
+
* output so the caller can degrade to a keep-for-human verdict. */
|
|
237
|
+
async judgeDraft(draft, existing) {
|
|
238
|
+
const text = await this.run(`${RELEVANCE_SYSTEM}\n\n${relevancePrompt(draft, existing)}\n\n${jsonInstruction(RELEVANCE_TOOL.input_schema)}`);
|
|
239
|
+
const verdict = relevanceFromText(text);
|
|
240
|
+
if (!verdict)
|
|
241
|
+
throw new Error(`${this.name}: no usable relevance JSON in output`);
|
|
242
|
+
return verdict;
|
|
243
|
+
}
|
|
220
244
|
}
|
|
221
245
|
// A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
|
|
222
246
|
// on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
|
|
@@ -607,6 +631,40 @@ function verifyPrompt(input, draft) {
|
|
|
607
631
|
`\nReturn grounded (0..1) and the VERBATIM alternatives_rejected / consequences the evidence does NOT support.`,
|
|
608
632
|
].filter(Boolean).join("\n\n");
|
|
609
633
|
}
|
|
634
|
+
const RELEVANCE_SYSTEM = `You are a strict curator for an Engineering Memory OS. You are given ONE auto-drafted
|
|
635
|
+
decision and a list of decisions ALREADY in the graph. Decide if the draft is worth keeping:
|
|
636
|
+
a REAL, reusable design choice (an architectural or policy decision a future engineer would
|
|
637
|
+
want to know). Mark it NOT relevant if it merely restates what the diff mechanically did, is
|
|
638
|
+
trivial/obvious, or is a near-duplicate of an existing decision (name that decision's id in
|
|
639
|
+
duplicate_of). When genuinely unsure, keep it (relevant=true, low confidence) — deletion is
|
|
640
|
+
destructive.`;
|
|
641
|
+
function relevancePrompt(draft, existing) {
|
|
642
|
+
const ex = existing.length
|
|
643
|
+
? existing.map((e) => ` ${e.id}: ${e.title} — ${e.decision.slice(0, 160)}`).join("\n")
|
|
644
|
+
: " (none)";
|
|
645
|
+
return [
|
|
646
|
+
`DRAFT UNDER REVIEW (id ${draft.id}):`,
|
|
647
|
+
` title: ${draft.title}`,
|
|
648
|
+
` decision: ${(draft.decision ?? "").slice(0, 800)}`,
|
|
649
|
+
(draft.alternatives_rejected ?? []).length ? ` alternatives_rejected:\n${(draft.alternatives_rejected ?? []).map((a) => ` - ${a}`).join("\n")}` : "",
|
|
650
|
+
(draft.related_files ?? []).length ? ` related_files: ${(draft.related_files ?? []).join(", ")}` : "",
|
|
651
|
+
`\nEXISTING DECISIONS (candidates for duplicate_of):\n${ex}`,
|
|
652
|
+
`\nReturn relevant, confidence (0..1), duplicate_of (an existing id or null), and a one-line reason.`,
|
|
653
|
+
].filter(Boolean).join("\n\n");
|
|
654
|
+
}
|
|
655
|
+
/** Map model text → RelevanceVerdict, or null when nothing usable parses (→ the
|
|
656
|
+
* caller keeps the draft for a human). Tolerant of missing/loose fields. */
|
|
657
|
+
export function relevanceFromText(text) {
|
|
658
|
+
for (const obj of extractJsonObjects(text)) {
|
|
659
|
+
if (typeof obj.relevant !== "boolean")
|
|
660
|
+
continue; // the one required signal
|
|
661
|
+
const dup = typeof obj.duplicate_of === "string" && obj.duplicate_of.trim() ? obj.duplicate_of.trim() : null;
|
|
662
|
+
const conf = typeof obj.confidence === "number" ? clamp01(obj.confidence) : 0.5;
|
|
663
|
+
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
|
|
664
|
+
return { relevant: obj.relevant, confidence: conf, duplicate_of: dup, reason };
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
610
668
|
/** Map model text → VerifyVerdict, or null when nothing usable parses (→ the caller
|
|
611
669
|
* keeps the un-audited draft). Tolerant of arrays-as-strings and missing fields. */
|
|
612
670
|
export function verdictFromText(text) {
|
|
@@ -45,7 +45,11 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
45
45
|
// Seed the id from the COMMIT (stable across runs), not the LLM-generated title
|
|
46
46
|
// (which varies) — so re-syncing a commit updates rather than dupes.
|
|
47
47
|
const id = decisionId(meta.sha);
|
|
48
|
-
|
|
48
|
+
// Check the store this capture WILL write to. Looking only in the public store
|
|
49
|
+
// made private/shared re-syncs re-draft the same commit and let `--force`
|
|
50
|
+
// overwrite a human-confirmed overlay decision.
|
|
51
|
+
const home = store.captureHome(!!opts.private);
|
|
52
|
+
const existing = home === "private" ? store.getPrivateRec("decisions", id) : store.json.get("decisions", id);
|
|
49
53
|
// Never clobber a human-confirmed decision with a low-confidence auto-draft —
|
|
50
54
|
// even under --force. Skip BEFORE synthesizing so we never pay for a draft we'd
|
|
51
55
|
// throw away (the old order drafted first, then discarded it here).
|
|
@@ -85,12 +89,19 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
85
89
|
// back to the normal single-provider path when no CLI is available. Opt-in only.
|
|
86
90
|
// --verify forces the LLM provider (auditing a deterministic draft is pointless) and,
|
|
87
91
|
// like --deep, runs the Critic pass below. Subscription-only throughout (con_2ce3f2a547).
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// An explicit private capture is storage-private AND local-only by default:
|
|
93
|
+
// never send a sensitive diff to a subscription CLI just to create a draft.
|
|
94
|
+
// Shared mode remains an explicit team policy and keeps its existing provider
|
|
95
|
+
// behavior unless the caller asked for a private capture.
|
|
96
|
+
const localOnly = opts.localOnly ?? !!opts.private;
|
|
97
|
+
const wantVerify = !localOnly && !!(opts.verify || opts.deep);
|
|
98
|
+
const provider = localOnly
|
|
99
|
+
? new DeterministicProvider()
|
|
100
|
+
: opts.deep
|
|
101
|
+
? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
|
|
102
|
+
: opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
|
|
103
|
+
? await selectProvider()
|
|
104
|
+
: new DeterministicProvider();
|
|
94
105
|
const input = { subject: meta.subject, body: meta.body, files: codeFiles, diff, analysis };
|
|
95
106
|
let draft = await draftDecisionSafe(provider, input);
|
|
96
107
|
// The Critic pass: audit the draft against the commit, PRUNE unsupported alternatives
|
|
@@ -177,7 +188,7 @@ export async function syncCommit(store, root, sha, opts = {}) {
|
|
|
177
188
|
return { status: "written", decision, provider: provider.name };
|
|
178
189
|
}
|
|
179
190
|
/** Capture a Bug from a test failure. Suspects are ranked churn×recency×fan-in. */
|
|
180
|
-
export async function recordFailure(store, root, failure) {
|
|
191
|
+
export async function recordFailure(store, root, failure, opts = {}) {
|
|
181
192
|
const symbols = store.json.loadAll("symbols");
|
|
182
193
|
const ranked = rankSuspects(symbols, failure.message);
|
|
183
194
|
// Prefer symbols actually named in the failure — so unrelated failures don't
|
|
@@ -185,7 +196,10 @@ export async function recordFailure(store, root, failure) {
|
|
|
185
196
|
const msg = failure.message.toLowerCase();
|
|
186
197
|
const mentioned = ranked.filter((s) => msg.includes(s.name.toLowerCase()));
|
|
187
198
|
const suspects = (mentioned.length ? mentioned : ranked).slice(0, 6);
|
|
188
|
-
|
|
199
|
+
// A private bug may contain a stack trace, customer data, or secrets. Keep the
|
|
200
|
+
// whole capture local unless the caller deliberately routes it through a shared
|
|
201
|
+
// (non-private) workflow.
|
|
202
|
+
const provider = opts.private ? new DeterministicProvider() : await selectProvider();
|
|
189
203
|
const input = {
|
|
190
204
|
test: failure.test,
|
|
191
205
|
message: failure.message,
|
|
@@ -198,7 +212,8 @@ export async function recordFailure(store, root, failure) {
|
|
|
198
212
|
const id = bugId(failure.test);
|
|
199
213
|
// recurrence = a DIFFERENT prior bug with a similar symptom (not this same one).
|
|
200
214
|
// Query text mirrors the corpus side (title+symptom+root_cause) for symmetry.
|
|
201
|
-
const
|
|
215
|
+
const home = store.captureHome(!!opts.private);
|
|
216
|
+
const prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id, home);
|
|
202
217
|
const affectedFiles = [...new Set(suspects.map((s) => s.file))];
|
|
203
218
|
const bug = {
|
|
204
219
|
id,
|
|
@@ -223,12 +238,12 @@ export async function recordFailure(store, root, failure) {
|
|
|
223
238
|
evidence: [`test:${failure.test}`, ...affectedFiles.slice(0, 6)],
|
|
224
239
|
},
|
|
225
240
|
};
|
|
226
|
-
store.putCapture("bugs", bug);
|
|
241
|
+
store.putCapture("bugs", bug, opts.private);
|
|
227
242
|
// Promotion (DESIGN §4): a recurrence or a SUBSTANTIATED high-severity bug raises
|
|
228
243
|
// a regression Constraint to stop it coming back, and bumps fragility.
|
|
229
244
|
let constraint;
|
|
230
245
|
if (shouldPromoteConstraint(draft.severity, bug.root_cause, !!prior)) {
|
|
231
|
-
constraint = promoteConstraint(store, bug);
|
|
246
|
+
constraint = promoteConstraint(store, bug, opts.private);
|
|
232
247
|
bug.lineage.spawned_constraint = constraint.id;
|
|
233
248
|
store.putWhereItLives("bugs", bug); // re-persist with the link, in the same home
|
|
234
249
|
}
|
|
@@ -253,7 +268,7 @@ export async function captureTestRun(store, root, input) {
|
|
|
253
268
|
}
|
|
254
269
|
const results = [];
|
|
255
270
|
for (const f of failures) {
|
|
256
|
-
const r = await recordFailure(store, root, f);
|
|
271
|
+
const r = await recordFailure(store, root, f, { private: input.private });
|
|
257
272
|
results.push({ bug: r.bug, constraint: r.constraint });
|
|
258
273
|
}
|
|
259
274
|
let sha = null;
|
|
@@ -284,7 +299,7 @@ export function shouldPromoteConstraint(severity, rootCause, isRecurrence) {
|
|
|
284
299
|
return severe && rootCause.trim().length > 0;
|
|
285
300
|
}
|
|
286
301
|
/** Turn a bug into an advisory regression constraint scoped to its files. */
|
|
287
|
-
function promoteConstraint(store, bug) {
|
|
302
|
+
function promoteConstraint(store, bug, isPrivate = false) {
|
|
288
303
|
const scope = bug.affected_files.length ? bug.affected_files : ["**"];
|
|
289
304
|
const statement = `Regression guard: "${bug.title}" must not recur.`;
|
|
290
305
|
const con = {
|
|
@@ -304,7 +319,7 @@ function promoteConstraint(store, bug) {
|
|
|
304
319
|
valid_to: null,
|
|
305
320
|
provenance: { source: "derived", confidence: Math.min(0.9, bug.provenance.confidence + 0.2), evidence: [`bug:${bug.id}`] },
|
|
306
321
|
};
|
|
307
|
-
return store.putCapture("constraints", con);
|
|
322
|
+
return store.putCapture("constraints", con, isPrivate);
|
|
308
323
|
}
|
|
309
324
|
/** Bump fragility on components owning the affected files. */
|
|
310
325
|
function raiseFragility(store, files) {
|
|
@@ -366,13 +381,13 @@ export function salientTerms(text) {
|
|
|
366
381
|
/** Recurrence = a DIFFERENT prior bug whose salient terms overlap strongly with
|
|
367
382
|
* this one (in-memory, no FTS/reindex dependency, threshold-gated to avoid the
|
|
368
383
|
* over-broad OR false positives). Returns the best match above threshold. */
|
|
369
|
-
function findRecurrence(store, text, excludeId) {
|
|
384
|
+
function findRecurrence(store, text, excludeId, home) {
|
|
370
385
|
const want = salientTerms(text);
|
|
371
386
|
if (want.size === 0)
|
|
372
387
|
return undefined;
|
|
373
388
|
let best;
|
|
374
389
|
let bestScore = 0;
|
|
375
|
-
for (const b of store.
|
|
390
|
+
for (const b of store.recsInHome("bugs", home)) {
|
|
376
391
|
if (b.id === excludeId)
|
|
377
392
|
continue;
|
|
378
393
|
// symmetric with the query side (which now also includes root_cause)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Architectural Conformance for AI-generated code: a git-native graph that deterministically blocks AI changes which break your architecture — the semantic invariants (layering, must-reach, dependency direction) pattern-SAST can't express — grounded in the decisions and bugs behind each rule, across any MCP assistant (Claude Code, Cursor, Copilot, Windsurf, Codex).",
|