@davesheffer/hunch 1.4.2 → 1.6.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.
@@ -137,6 +137,32 @@ function writeJson(file, obj) {
137
137
  writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
138
138
  return file;
139
139
  }
140
+ /** Provider hook commands live in tracked config files, so use the structured
141
+ * invocation (the same portable npx package reference as MCP) rather than a
142
+ * machine-local CLI path. JSON quoting is accepted by POSIX shells and keeps
143
+ * paths with spaces intact for source/dev installs. */
144
+ function hookCommand(inv, provider) {
145
+ return [...[inv.command], ...inv.args, "hook", "--provider", provider].map((part) => JSON.stringify(part)).join(" ");
146
+ }
147
+ function isHunchProviderHook(entry) {
148
+ const e = entry && typeof entry === "object" ? entry : null;
149
+ const command = typeof e?.command === "string" ? e.command : "";
150
+ return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command) && /\bhook\b/.test(command);
151
+ }
152
+ /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
153
+ * We replace only old Hunch commands and leave every foreign hook in place. */
154
+ function writeHookConfig(file, entries) {
155
+ const json = readJsonObj(file);
156
+ const hooks = json.hooks && typeof json.hooks === "object" && !Array.isArray(json.hooks)
157
+ ? json.hooks
158
+ : {};
159
+ for (const [event, next] of Object.entries(entries)) {
160
+ const old = Array.isArray(hooks[event]) ? hooks[event] : [];
161
+ hooks[event] = [...old.filter((entry) => !isHunchProviderHook(entry)), ...next];
162
+ }
163
+ json.hooks = hooks;
164
+ return writeJson(file, json);
165
+ }
140
166
  /** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
141
167
  export function writeCursorMcp(root, inv) {
142
168
  const file = join(root, ".cursor", "mcp.json");
@@ -154,15 +180,15 @@ export function writeVscodeMcp(root, inv) {
154
180
  json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
155
181
  return writeJson(file, json);
156
182
  }
157
- /** Google Antigravity's MCP config is GLOBAL (user home), not project-local — and the
158
- * dir moved between versions (`antigravity/` vs `config/`). Resolve adaptively: an
159
- * existing config wins, else an existing parent dir, else null (Antigravity not
160
- * installed we never create a global config for an absent tool). `home` is injectable
161
- * for tests so we never touch the real ~/.gemini. */
183
+ /** Google Antigravity's global MCP config moved between releases. Resolve
184
+ * adaptively: an existing config wins, else an existing parent dir, else null
185
+ * (Antigravity not installed we never create a global config for an absent
186
+ * tool). The current project-local config is handled separately below. `home`
187
+ * is injectable for tests so we never touch the real ~/.gemini. */
162
188
  export function antigravityMcpFile(home = homedir()) {
163
189
  const candidates = [
164
- join(home, ".gemini", "antigravity", "mcp_config.json"),
165
190
  join(home, ".gemini", "config", "mcp_config.json"),
191
+ join(home, ".gemini", "antigravity", "mcp_config.json"), // legacy
166
192
  ];
167
193
  for (const c of candidates)
168
194
  if (existsSync(c))
@@ -185,6 +211,16 @@ export function writeAntigravityMcp(inv, home = homedir()) {
185
211
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
186
212
  return writeJson(file, json);
187
213
  }
214
+ /** Current Antigravity IDE/CLI project config. Unlike a global config this is
215
+ * committed with the repository, so every clone gets the same private/local
216
+ * Hunch server without touching a user's home directory. */
217
+ export function writeAntigravityWorkspaceMcp(root, inv) {
218
+ const file = join(root, ".agents", "mcp_config.json");
219
+ const json = readJsonObj(file);
220
+ json.mcpServers = json.mcpServers ?? {};
221
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
222
+ return writeJson(file, json);
223
+ }
188
224
  const TOML_START = "# >>> hunch mcp (managed) >>>";
189
225
  const TOML_END = "# <<< hunch mcp <<<";
190
226
  /** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
@@ -249,6 +285,22 @@ export function writeWindsurfMcp(root, inv) {
249
285
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
250
286
  return writeJson(file, json);
251
287
  }
288
+ /** Current Windsurf also discovers a user config at ~/.codeium/windsurf. Only
289
+ * touch it when the tool is already installed/configured; Hunch never creates a
290
+ * global configuration for an application the user does not have. */
291
+ export function windsurfMcpFile(home = homedir()) {
292
+ const file = join(home, ".codeium", "windsurf", "mcp_config.json");
293
+ return existsSync(file) || existsSync(dirname(file)) ? file : null;
294
+ }
295
+ export function writeWindsurfGlobalMcp(inv, home = homedir()) {
296
+ const file = windsurfMcpFile(home);
297
+ if (!file)
298
+ return null;
299
+ const json = readJsonObj(file);
300
+ json.mcpServers = json.mcpServers ?? {};
301
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
302
+ return writeJson(file, json);
303
+ }
252
304
  /** Windsurf project rule (.windsurf/rules/hunch.md). `trigger: always_on` keeps the
253
305
  * Hunch grounding in Cascade's context for every request. Fully managed (overwritten). */
254
306
  export function writeWindsurfRule(root, store) {
@@ -258,6 +310,86 @@ export function writeWindsurfRule(root, store) {
258
310
  writeFileSync(file, body);
259
311
  return file;
260
312
  }
313
+ /** Cursor's hook API is beta, but its project-level config accepts this standard
314
+ * event map. Context delivery is opportunistic; the always-on rule and MCP
315
+ * registration remain the durable grounding path if a Cursor build suppresses
316
+ * a hook's agent_message. */
317
+ export function writeCursorHooks(root, inv) {
318
+ const file = join(root, ".cursor", "hooks.json");
319
+ const command = hookCommand(inv, "cursor");
320
+ const written = writeHookConfig(file, {
321
+ sessionStart: [{ command }],
322
+ beforeSubmitPrompt: [{ command }],
323
+ preToolUse: [{ command }],
324
+ postToolUse: [{ command }],
325
+ stop: [{ command }],
326
+ });
327
+ const json = readJsonObj(written);
328
+ if (json.version === undefined) {
329
+ json.version = 1;
330
+ writeJson(written, json);
331
+ }
332
+ return written;
333
+ }
334
+ /** VS Code's native workspace hook location. It supports all lifecycle events
335
+ * Hunch needs and uses the same stdout contract as Claude Code, with different
336
+ * camelCase tool fields normalized in core/agenthook.ts. */
337
+ export function writeVscodeHooks(root, inv) {
338
+ const file = join(root, ".github", "hooks", "hunch.json");
339
+ const command = hookCommand(inv, "vscode");
340
+ return writeHookConfig(file, {
341
+ SessionStart: [{ type: "command", command }],
342
+ UserPromptSubmit: [{ type: "command", command }],
343
+ PreToolUse: [{ type: "command", command }],
344
+ PostToolUse: [{ type: "command", command }],
345
+ Stop: [{ type: "command", command }],
346
+ });
347
+ }
348
+ /** Windsurf's documented workspace hooks. It only supports deterministic
349
+ * pre-hook blocking via exit code 2, so Hunch uses rules + MCP for context and
350
+ * reserves the hook for strict edit protection and pipeline observation. */
351
+ export function writeWindsurfHooks(root, inv) {
352
+ const file = join(root, ".windsurf", "hooks.json");
353
+ const command = hookCommand(inv, "windsurf");
354
+ return writeHookConfig(file, {
355
+ pre_user_prompt: [{ command, show_output: false }],
356
+ pre_write_code: [{ command, show_output: false }],
357
+ post_write_code: [{ command, show_output: false }],
358
+ post_run_command: [{ command, show_output: false }],
359
+ });
360
+ }
361
+ function antigravityHandler(command) {
362
+ return { type: "command", command, timeout: 15 };
363
+ }
364
+ /** Antigravity keeps hook groups at the top level (not under `hooks`). Hunch
365
+ * owns only the `hunch` group and replaces its own old entries idempotently. */
366
+ export function writeAntigravityHooks(root, inv) {
367
+ const file = join(root, ".agents", "hooks.json");
368
+ const json = readJsonObj(file);
369
+ const group = json.hunch && typeof json.hunch === "object" && !Array.isArray(json.hunch)
370
+ ? json.hunch
371
+ : {};
372
+ const command = hookCommand(inv, "antigravity");
373
+ const keep = (event) => Array.isArray(group[event])
374
+ ? group[event].filter((entry) => {
375
+ const e = entry && typeof entry === "object" ? entry : null;
376
+ if (isHunchProviderHook(e))
377
+ return false;
378
+ return !Array.isArray(e?.hooks) || !e.hooks.some((hook) => isHunchProviderHook(hook));
379
+ })
380
+ : [];
381
+ group.PreInvocation = [...keep("PreInvocation"), antigravityHandler(command)];
382
+ group.PreToolUse = [
383
+ ...keep("PreToolUse"),
384
+ {
385
+ matcher: "write_to_file|replace_file_content|multi_replace_file_content",
386
+ hooks: [antigravityHandler(command)],
387
+ },
388
+ ];
389
+ group.Stop = [...keep("Stop"), antigravityHandler(command)];
390
+ json.hunch = group;
391
+ return writeJson(file, json);
392
+ }
261
393
  /** Rewrite the auto-maintained Hunch section in EVERY assistant grounding doc
262
394
  * (CLAUDE.md, AGENTS.md, Copilot instructions, Cursor + Windsurf rules) from the
263
395
  * current store — without touching the MCP/provider config files. `hunch private
@@ -300,28 +432,50 @@ export function refreshExistingGrounding(root, store) {
300
432
  }
301
433
  return changed;
302
434
  }
435
+ /** A malformed configuration for one surface (for example an MCP file) must not
436
+ * prevent the same assistant's rule or lifecycle hook from being installed. */
437
+ function runProvider(writers) {
438
+ const files = [];
439
+ const errors = [];
440
+ for (const write of writers) {
441
+ try {
442
+ const result = write();
443
+ files.push(...(Array.isArray(result) ? result : [result]));
444
+ }
445
+ catch (e) {
446
+ errors.push(e.message);
447
+ }
448
+ }
449
+ return { files, ...(errors.length ? { error: errors.join("; ") } : {}) };
450
+ }
303
451
  /** Scaffold MCP config + grounding for all supported assistants. Returns a
304
452
  * per-assistant summary for `hunch init` to print. Each assistant is isolated:
305
453
  * a writer that refuses to clobber a malformed file degrades to a warning rather
306
454
  * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
307
- export function scaffoldProviders(root, inv, store) {
455
+ export function scaffoldProviders(root, inv, store, options = {}) {
456
+ const hooks = options.agentHooks !== false;
457
+ const home = options.home;
308
458
  const tasks = [
309
- ["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
310
- ["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
311
- ["Codex CLI", () => [writeCodexConfig(root, inv)]],
312
- ["Windsurf", () => [writeWindsurfMcp(root, inv), writeWindsurfRule(root, store)]],
313
- // Antigravity reads project-root AGENTS.md for grounding (written below); its MCP
314
- // config is global + detection-gated, so it only writes when Antigravity is installed.
315
- ["Google Antigravity", () => { const f = writeAntigravityMcp(inv); return f ? [f] : []; }],
316
- ["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
459
+ ["Cursor", () => runProvider([() => writeCursorMcp(root, inv), () => writeCursorRule(root, store), ...(hooks ? [() => writeCursorHooks(root, inv)] : [])])],
460
+ ["VS Code (Copilot)", () => runProvider([() => writeVscodeMcp(root, inv), () => writeCopilotInstructions(root, store), ...(hooks ? [() => writeVscodeHooks(root, inv)] : [])])],
461
+ ["Codex CLI", () => runProvider([() => writeCodexConfig(root, inv)])],
462
+ ["Windsurf", () => {
463
+ return runProvider([
464
+ () => writeWindsurfMcp(root, inv),
465
+ () => writeWindsurfRule(root, store),
466
+ ...(hooks ? [() => writeWindsurfHooks(root, inv)] : []),
467
+ () => { const global = writeWindsurfGlobalMcp(inv, home); return global ?? []; },
468
+ ]);
469
+ }],
470
+ ["Google Antigravity", () => {
471
+ return runProvider([
472
+ () => writeAntigravityWorkspaceMcp(root, inv),
473
+ ...(hooks ? [() => writeAntigravityHooks(root, inv)] : []),
474
+ () => { const global = writeAntigravityMcp(inv, home); return global ?? []; },
475
+ ]);
476
+ }],
477
+ ["Any (AGENTS.md)", () => runProvider([() => writeAgentsMd(root, store)])],
317
478
  ];
318
- return tasks.map(([assistant, run]) => {
319
- try {
320
- return { assistant, files: run() };
321
- }
322
- catch (e) {
323
- return { assistant, files: [], error: e.message };
324
- }
325
- });
479
+ return tasks.map(([assistant, run]) => ({ assistant, ...run() }));
326
480
  }
327
481
  //# sourceMappingURL=providers.js.map
@@ -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)