@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.
@@ -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,
@@ -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: upgrading an auto-draft yields the composite
450
- // provenance the design specifies.
451
- // Public-only lookup; skip it for a private write so a private decision never
452
- // inherits fields from a same-id PUBLIC record (and vice-versa).
453
- const existing = decision.private ? undefined : store.json.get("decisions", id);
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" ? undefined : store.json.get("constraints", rec.id);
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 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.",
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 && commit)
611
- return err("Pass at most one of base/commit (omit both to check staged changes).");
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 AND commit for staged changes.",
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 && commit)
653
- return err("Pass at most one of base/commit (omit both for staged changes).");
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) {
@@ -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 c = this.json.get("constraints", constraintId);
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 ? this.json.get("decisions", c.source_decision) : null;
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
- const existing = store.json.get("decisions", id);
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
- const wantVerify = !!(opts.verify || opts.deep);
89
- const provider = opts.deep
90
- ? (await selectEnsemble({ samples: opts.samples })) ?? await selectProvider()
91
- : opts.force || opts.verify || isSignificant(meta, analysis, codeFiles)
92
- ? await selectProvider()
93
- : new DeterministicProvider();
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
- const provider = await selectProvider();
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 prior = findRecurrence(store, `${draft.title} ${draft.symptom} ${draft.root_cause}`, id);
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.json.loadAll("bugs")) {
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.4.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).",