@davesheffer/hunch 0.5.0 → 0.8.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 CHANGED
@@ -56,13 +56,21 @@ Either way you then type `hunch …`. From a source checkout without `npm link`,
56
56
  `node dist/cli/index.js …` (or `npm run hunch -- …` to run via tsx). The rest of this
57
57
  README uses `hunch` for brevity.
58
58
 
59
- ### 2. (Recommended) make the `claude` CLI available
59
+ ### 2. (Recommended) make a coding-assistant CLI available
60
60
 
61
- Hunch's LLM synthesis is billed to your **Claude Pro/Max subscription** through the
62
- `claude` CLI — **never** the pay-per-token API. If `claude --version` works in your
63
- terminal, you get full LLM-quality capture for free. If it doesn't, Hunch still works
64
- using a deterministic structural heuristic (lower-confidence drafts). `hunch doctor`
65
- tells you which mode you're in.
61
+ Hunch's LLM synthesis is billed to **your subscription** through a coding-assistant
62
+ CLI — **never** a pay-per-token API key (the API key is stripped from the child env).
63
+ Hunch auto-detects the first one present, in this order:
64
+
65
+ | CLI | Subscription | Detected by |
66
+ |---|---|---|
67
+ | `claude` (Claude Code) | Claude Pro/Max | `claude --version` |
68
+ | `codex` (OpenAI Codex) | ChatGPT Plus/Pro | `codex --version` |
69
+ | `cursor-agent` (Cursor) | Cursor | `cursor-agent --version` |
70
+
71
+ If none is installed, Hunch still works using a deterministic structural heuristic
72
+ (lower-confidence drafts). `hunch doctor` tells you which mode you're in; force one
73
+ with `HUNCH_SYNTH_PROVIDER=claude-cli|codex-cli|cursor-agent|deterministic`.
66
74
 
67
75
  ### 3. Initialize the repo you want a memory for
68
76
 
@@ -102,15 +110,34 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
102
110
  (dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
103
111
  `hunch_context` (surgical minimal slice for a task), `hunch_record_decision` (write-back).
104
112
 
113
+ ### Works with any MCP assistant
114
+
115
+ The Hunch MCP server is **client-agnostic** — one `.hunch/` graph powers every
116
+ assistant. `hunch init` scaffolds each tool's MCP config + ambient grounding so
117
+ they all consult the same memory:
118
+
119
+ | Assistant | MCP config | Grounding file |
120
+ |---|---|---|
121
+ | Claude Code | `.mcp.json` | `CLAUDE.md` + `/hunch-*` slash commands |
122
+ | Cursor | `.cursor/mcp.json` | `.cursor/rules/hunch.mdc` (always-applied) |
123
+ | VS Code (Copilot) | `.vscode/mcp.json` | `.github/copilot-instructions.md` |
124
+ | Codex CLI | `.codex/config.toml` | `AGENTS.md` |
125
+ | Anything else | — | `AGENTS.md` (cross-tool standard) |
126
+
127
+ Each writer **merges** into existing files (other MCP servers and your own prose are
128
+ preserved) and is idempotent. Opt out with `hunch init --no-providers`.
129
+
105
130
  **Through the CLI** — the same graph, from your terminal:
106
131
 
107
132
  | Command | What |
108
133
  |---|---|
109
- | `hunch init [--enforce]` | scaffold `.hunch/`, index, install hook + merge driver, wire up Claude Code (`--enforce` adds a pre-commit invariant guard) |
134
+ | `hunch init` | scaffold `.hunch/`, index, install hook + merge driver, auto-install the advisory pre-commit guard, install the **Claude Code agent hooks**, and wire up **every assistant** (Claude Code, Cursor, VS Code/Copilot, Codex, AGENTS.md). Flags: `--no-enforce`, `--enforce-strict`, `--no-providers`, `--no-agent-hooks`, `--firmness <level>` |
110
135
  | `hunch index` | parse repo → symbols / edges / components (deterministic, no LLM) |
111
136
  | `hunch backfill --since 90d` | replay git history → seed decisions |
112
137
  | `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
113
138
  | `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
139
+ | `hunch record-constraint "<statement>" [--scope <globs>] [--severity advisory\|warning\|blocking] [--type …] [--rationale <t>] [--source-decision <id>]` | record an invariant the code must not break (what `hunch check` + the strict agent hook enforce) |
140
+ | `hunch firmness [off\|advisory\|firm\|strict]` | get/set how firmly the agent hook enforces Hunch before edits (no arg prints the current level) |
114
141
  | `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
115
142
  | `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
116
143
  | `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
@@ -125,6 +152,37 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
125
152
  | `hunch doctor` | environment diagnostics (git, auth mode, schema version, counts) |
126
153
  | `hunch mcp` | start the MCP server over stdio (Claude Code connects here) |
127
154
 
155
+ ## Grounding the agent automatically (firmness)
156
+
157
+ Telling an assistant "consult Hunch first" in a prompt is advisory — it drifts. `hunch
158
+ init` instead installs two **Claude Code agent hooks** (in `.claude/settings.json`) so the
159
+ grounding is enforced by the harness, not by the model's memory:
160
+
161
+ - **Before every edit** (`PreToolUse` on `Edit`/`Write`/`MultiEdit`) Hunch injects the
162
+ relevant slice for the file being touched — its decisions, invariants, bug history, and
163
+ blast radius — straight into the model's context.
164
+ - **On every prompt** (`UserPromptSubmit`) it reminds the agent to query Hunch.
165
+
166
+ How hard it pushes is one committed knob — set it once, it applies to the whole team:
167
+
168
+ ```bash
169
+ hunch firmness # print the current level
170
+ hunch firmness strict # change it (takes effect on the next edit; no restart)
171
+ ```
172
+
173
+ | Level | Before an edit |
174
+ |---|---|
175
+ | `off` | nothing (hook is a no-op) |
176
+ | `advisory` *(default)* | inject the relevant Hunch slice as context |
177
+ | `firm` | advisory **+** explicitly flag invariants in the file's scope |
178
+ | `strict` | firm **+** **deny** an edit that hits a *blocking* invariant (directly or via blast radius), feeding the invariant back as the refusal reason |
179
+
180
+ The hook never breaks your flow: any error or unrecognized input emits nothing and exits
181
+ 0, and it stays silent on files Hunch hasn't learned yet. `strict` only bites once you have
182
+ **blocking** constraints recorded (`hunch record-constraint … --severity blocking`) — with
183
+ none, every level degrades to context-only. Opt out of the hooks entirely with `hunch init
184
+ --no-agent-hooks`.
185
+
128
186
  ## Semantic search (optional)
129
187
 
130
188
  By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
package/dist/cli/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
+ import { relative } from "node:path";
18
19
  import { Command } from "commander";
19
20
  import { hunchPaths, findRoot } from "../core/paths.js";
20
21
  import { HunchStore } from "../store/hunchStore.js";
@@ -27,8 +28,12 @@ import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles
27
28
  import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
28
29
  import { installMergeDriver } from "../integrations/mergeDriver.js";
29
30
  import { updateClaudeMd } from "../integrations/claudemd.js";
30
- import { writeMcpJson, writeSlashCommands } from "../integrations/scaffold.js";
31
+ import { writeMcpJson, writeSlashCommands, installClaudeHooks } from "../integrations/scaffold.js";
32
+ import { scaffoldProviders } from "../integrations/providers.js";
31
33
  import { formatContext } from "../core/format.js";
34
+ import { readConfig, writeConfig, FIRMNESS_LEVELS, isFirmness } from "../core/config.js";
35
+ import { blockingInScope } from "../core/hookpolicy.js";
36
+ import { constraintId } from "../core/ids.js";
32
37
  import { readManifest, writeManifest, SCHEMA_VERSION } from "../core/migrate.js";
33
38
  import { mergeHunchJson } from "../store/merge.js";
34
39
  import { planCompaction } from "../store/compact.js";
@@ -49,7 +54,15 @@ program
49
54
  .option("--no-index", "skip the initial repo index")
50
55
  .option("--no-enforce", "do not install the advisory pre-commit constraint guard")
51
56
  .option("--enforce-strict", "make the pre-commit guard FAIL the commit on a blocking invariant (direct or near)")
57
+ .option("--no-providers", "skip scaffolding non-Claude assistant configs (Cursor / VS Code / Codex / AGENTS.md)")
58
+ .option("--no-agent-hooks", "skip installing the Claude Code agent hooks (.claude/settings.json)")
59
+ .option("--firmness <level>", "agent-hook firmness: off | advisory | firm | strict")
52
60
  .action((opts) => {
61
+ // Validate --firmness up front, before any side effects (indexing, git hooks,
62
+ // .mcp.json) or opening the store — a bad value must not leave a half-init.
63
+ if (opts.firmness !== undefined && !isFirmness(opts.firmness)) {
64
+ return fail(`--firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
65
+ }
53
66
  const root = findRoot();
54
67
  const paths = hunchPaths(root);
55
68
  const store = new HunchStore(paths);
@@ -88,6 +101,26 @@ program
88
101
  console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
89
102
  const cmd = updateClaudeMd(root, store);
90
103
  console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
104
+ // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
105
+ // level even before the user runs `hunch firmness` (--firmness validated above).
106
+ const firmness = writeConfig(paths, opts.firmness ? { firmness: opts.firmness } : {}).firmness;
107
+ // Agent hooks: ground the assistant in Hunch automatically (PreToolUse injects
108
+ // context before edits; UserPromptSubmit reminds). Reads firmness at run time.
109
+ if (opts.agentHooks !== false) {
110
+ const a = installClaudeHooks(root, `${inv.shell} hook`);
111
+ console.log(` ✓ Claude Code agent hooks ${a.action} (firmness: ${firmness} — change with \`hunch firmness <level>\`)`);
112
+ }
113
+ // Multi-assistant compatibility: the MCP server is client-agnostic, so wire up
114
+ // Cursor / VS Code (Copilot) / Codex / AGENTS.md to the same .hunch/ graph.
115
+ if (opts.providers !== false) {
116
+ const ps = scaffoldProviders(root, inv.mcp, store);
117
+ const ok = ps.filter((p) => !p.error);
118
+ const total = ok.reduce((a, p) => a + p.files.length, 0);
119
+ console.log(` ✓ wrote ${total} multi-assistant config file(s) → ${ok.map((p) => p.assistant).join(", ")}`);
120
+ for (const p of ps)
121
+ if (p.error)
122
+ console.log(` ⚠ skipped ${p.assistant}: ${p.error}`);
123
+ }
91
124
  store.close();
92
125
  console.log("\nNext: make a commit (the hook captures a decision), then ask Claude Code \"why is X built this way?\"");
93
126
  console.log("Cold start? Seed from history: hunch backfill --since 90d");
@@ -324,6 +357,41 @@ program
324
357
  console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
325
358
  store.close();
326
359
  });
360
+ // ---- record-constraint (human-authored invariant) -------------------------
361
+ program
362
+ .command("record-constraint")
363
+ .description("Record an invariant the codebase must not break — what `hunch check` and the strict agent hook enforce.")
364
+ .argument("<statement>", 'the invariant, e.g. "vectors are derived, never the source of truth"')
365
+ .option("--scope <globs>", "comma-separated path/glob(s) it applies to (e.g. src/store/**)", "")
366
+ .option("--severity <s>", "advisory | warning | blocking", "warning")
367
+ .option("--type <t>", "security | performance | correctness | architecture | compliance", "correctness")
368
+ .option("--rationale <text>", "why it must hold", "")
369
+ .option("--source-decision <id>", "decision id this derives from")
370
+ .option("--enforcement <e>", "advisory_v1 | ci | manual", "advisory_v1")
371
+ .action((statement, opts) => {
372
+ const SEV = ["advisory", "warning", "blocking"];
373
+ if (!SEV.includes(opts.severity))
374
+ return fail(`--severity must be one of: ${SEV.join(", ")}`);
375
+ const { store, root } = storeFor();
376
+ store.json.ensureDirs();
377
+ const scope = opts.scope.split(",").map((s) => s.trim()).filter(Boolean);
378
+ const c = store.json.put("constraints", {
379
+ id: constraintId(statement),
380
+ type: opts.type,
381
+ statement,
382
+ scope,
383
+ severity: opts.severity,
384
+ enforcement: opts.enforcement,
385
+ rationale: opts.rationale,
386
+ source_decision: opts.sourceDecision ?? null,
387
+ violations: [],
388
+ provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: new Date().toISOString() },
389
+ });
390
+ store.reindex();
391
+ updateClaudeMd(root, store);
392
+ console.log(`✓ recorded ${c.severity} constraint ${c.id}: "${c.statement}" (scope: ${scope.join(", ") || "repo"})`);
393
+ store.close();
394
+ });
327
395
  // ---- test (failure-learning loop) -----------------------------------------
328
396
  program
329
397
  .command("test")
@@ -522,6 +590,87 @@ program
522
590
  process.stdout.write(formatContext(store.assembleContext(target, Number(opts.budget))));
523
591
  store.close();
524
592
  });
593
+ // ---- firmness (agent-hook enforcement level) ------------------------------
594
+ program
595
+ .command("firmness")
596
+ .description("Get or set how firmly the Claude Code agent hook enforces Hunch before edits.")
597
+ .argument("[level]", "off | advisory | firm | strict (omit to print the current level)")
598
+ .action((level) => {
599
+ const paths = hunchPaths(findRoot());
600
+ if (!level) {
601
+ console.log(`firmness: ${readConfig(paths).firmness}`);
602
+ console.log(`levels: ${FIRMNESS_LEVELS.join(" | ")} (set with: hunch firmness <level>)`);
603
+ return;
604
+ }
605
+ if (!isFirmness(level)) {
606
+ return fail(`firmness must be one of: ${FIRMNESS_LEVELS.join(", ")}`);
607
+ }
608
+ const next = writeConfig(paths, { firmness: level }).firmness;
609
+ console.log(`✓ firmness set to ${next} (takes effect on the next edit — no Claude Code restart needed).`);
610
+ });
611
+ // ---- hook (Claude Code agent-hook handler) --------------------------------
612
+ program
613
+ .command("hook")
614
+ .description("Claude Code hook handler: inject relevant Hunch context before edits (and, at strict firmness, deny edits that hit a blocking invariant). Reads the hook event JSON on stdin.")
615
+ .action(async () => {
616
+ // A hook MUST NEVER break the agent: on ANY error or unrecognized input we
617
+ // emit nothing and exit 0 (the action defers to Claude Code's normal flow).
618
+ let store = null;
619
+ try {
620
+ const evt = JSON.parse(await readStdin());
621
+ const root = findRoot();
622
+ const paths = hunchPaths(root);
623
+ const firmness = readConfig(paths).firmness;
624
+ if (firmness === "off")
625
+ return;
626
+ if (evt.hook_event_name === "UserPromptSubmit") {
627
+ emitContext("UserPromptSubmit", HOOK_REMINDER);
628
+ return;
629
+ }
630
+ if (evt.hook_event_name !== "PreToolUse")
631
+ return;
632
+ const abs = evt.tool_input?.file_path;
633
+ if (!abs)
634
+ return;
635
+ const target = toRepoRel(root, abs);
636
+ // Outside the repo (".." prefix) or on another drive (absolute, e.g. "D:/…")
637
+ // → nothing for Hunch to say.
638
+ if (!target || target.startsWith("..") || /^[a-zA-Z]:/.test(target))
639
+ return;
640
+ store = new HunchStore(paths);
641
+ // strict: refuse an edit that hits a BLOCKING invariant (direct OR via blast
642
+ // radius), feeding the invariant statement back as the refusal reason. Reindex
643
+ // first so the blast radius reflects uncommitted edges — strict opts into the
644
+ // cost for correctness. Advisory/firm skip it: the hook fires on every edit,
645
+ // and decisions/constraints don't change between commits, so the committed
646
+ // index is good enough for grounding.
647
+ if (firmness === "strict") {
648
+ store.reindex();
649
+ const deny = blockingInScope(store, target);
650
+ if (deny) {
651
+ emitDeny(deny.reason);
652
+ return;
653
+ }
654
+ }
655
+ // advisory / firm / strict(non-blocking): inject the relevant Hunch slice.
656
+ const ctx = store.assembleContext(target);
657
+ const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length;
658
+ if (!hasContent)
659
+ return; // no noise on files Hunch hasn't learned yet
660
+ let text = formatContext(ctx).trim();
661
+ if (firmness !== "advisory" && ctx.constraints.length) {
662
+ const names = ctx.constraints.map((c) => `[${c.severity}] ${c.statement}`).join("; ");
663
+ text += `\n\n⚠ This file is in scope of ${ctx.constraints.length} invariant(s): ${names}. Preserve them.`;
664
+ }
665
+ emitContext("PreToolUse", text);
666
+ }
667
+ catch {
668
+ // swallow — never block an edit on a hook failure
669
+ }
670
+ finally {
671
+ store?.close();
672
+ }
673
+ });
525
674
  // ---- review (curate loop) -------------------------------------------------
526
675
  program
527
676
  .command("review")
@@ -676,16 +825,22 @@ program
676
825
  console.log(`schema: v${onDisk} (hunch v${SCHEMA_VERSION})${schemaNote}`);
677
826
  const provider = await selectProvider();
678
827
  console.log(`synthesis: ${provider.name}`);
679
- // Synthesis is billed to the user's Claude SUBSCRIPTION via the `claude` CLI,
680
- // never the pay-per-token API. Surface whatever stands between here and that.
681
- if (provider.name === "claude-cli") {
682
- const hadKey = !!(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN);
683
- console.log(` ↳ LLM synthesis billed to your Claude subscription` +
684
- (hadKey ? ` (ANTHROPIC_API_KEY in env is stripped — never billed to the API)` : ``));
828
+ // Synthesis is billed to the user's SUBSCRIPTION via a coding-assistant CLI,
829
+ // never a pay-per-token API key. Surface which one or what's missing.
830
+ const SUB = {
831
+ "claude-cli": { label: "Claude subscription (claude CLI)", strip: "ANTHROPIC_API_KEY" },
832
+ "codex-cli": { label: "ChatGPT subscription (codex CLI)", strip: "OPENAI_API_KEY" },
833
+ "cursor-agent": { label: "Cursor subscription (cursor-agent CLI)" },
834
+ };
835
+ const sub = SUB[provider.name];
836
+ if (sub) {
837
+ const hadKey = sub.strip && !!process.env[sub.strip];
838
+ console.log(` ↳ LLM synthesis billed to your ${sub.label}` +
839
+ (hadKey ? ` (${sub.strip} in env is stripped — never billed to the API)` : ``));
685
840
  }
686
- else if (provider.name === "deterministic") {
687
- console.log(dim(` ↳ no \`claude\` CLI — synthesis uses the offline heuristic (advisory, low-confidence)`));
688
- console.log(dim(` for full synthesis: install Claude Code + \`claude /login\`, or set CLAUDE_CODE_OAUTH_TOKEN (\`claude setup-token\`) for CI`));
841
+ else {
842
+ console.log(dim(` ↳ no assistant CLI found — synthesis uses the offline heuristic (advisory, low-confidence)`));
843
+ console.log(dim(` for full synthesis install one: Claude Code (\`claude /login\`), Codex (\`codex login\`), or Cursor (\`cursor-agent login\`)`));
689
844
  }
690
845
  const c = store.reindex().counts;
691
846
  console.log(`hunch: ${c.symbols} symbols, ${c.edges} edges, ${c.components} components, ${c.decisions} decisions, ${c.bugs} bugs, ${c.constraints} constraints`);
@@ -712,6 +867,37 @@ function fail(msg) {
712
867
  console.error(`error: ${msg}`);
713
868
  process.exitCode = 1;
714
869
  }
870
+ // --- agent-hook helpers (used by `hunch hook`) -----------------------------
871
+ const HOOK_REMINDER = "Hunch (engineering memory) is available for this repo. Before editing, call " +
872
+ "hunch_check_constraints(scope) for do-not-break invariants and hunch_why(target) " +
873
+ "for the rationale; use hunch_get_dependents for blast radius and hunch_bug_lineage " +
874
+ "for prior root causes. After a non-trivial choice, record it with hunch_record_decision.";
875
+ /** Read all of stdin (the hook event JSON). A TTY (no piped input) resolves to ""
876
+ * so an accidental interactive `hunch hook` exits cleanly instead of hanging. */
877
+ function readStdin() {
878
+ return new Promise((resolve) => {
879
+ if (process.stdin.isTTY)
880
+ return resolve("");
881
+ let data = "";
882
+ process.stdin.setEncoding("utf8");
883
+ process.stdin.on("data", (c) => (data += c));
884
+ process.stdin.on("end", () => resolve(data));
885
+ process.stdin.on("error", () => resolve(data));
886
+ });
887
+ }
888
+ /** Absolute edit path → repo-relative, forward-slash (constraint scopes are
889
+ * forward-slash globs even on Windows). */
890
+ function toRepoRel(root, abs) {
891
+ return relative(root, abs).split("\\").join("/");
892
+ }
893
+ function emitContext(event, text) {
894
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }));
895
+ }
896
+ function emitDeny(reason) {
897
+ process.stdout.write(JSON.stringify({
898
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
899
+ }));
900
+ }
715
901
  program.parseAsync().catch((e) => {
716
902
  try {
717
903
  openStore?.close();
@@ -0,0 +1,35 @@
1
+ /** Hunch user config (`.hunch/config.json`) — runtime knobs that are NOT schema
2
+ * state (the on-disk schema version lives in manifest.json). Committed alongside
3
+ * the graph, so a whole team shares the same settings — e.g. how firmly the
4
+ * Claude Code agent hook enforces engineering memory before an edit. */
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+ export const FIRMNESS_LEVELS = ["off", "advisory", "firm", "strict"];
8
+ export const DEFAULT_FIRMNESS = "advisory";
9
+ function defaults() {
10
+ return { firmness: DEFAULT_FIRMNESS };
11
+ }
12
+ export function isFirmness(v) {
13
+ return typeof v === "string" && FIRMNESS_LEVELS.includes(v);
14
+ }
15
+ /** Read `.hunch/config.json`. A missing/unparseable file, or an unknown firmness
16
+ * value, falls back to defaults — the hook must NEVER crash an edit over config. */
17
+ export function readConfig(paths) {
18
+ if (!existsSync(paths.config))
19
+ return defaults();
20
+ try {
21
+ const raw = JSON.parse(readFileSync(paths.config, "utf8"));
22
+ return { firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS };
23
+ }
24
+ catch {
25
+ return defaults();
26
+ }
27
+ }
28
+ /** Write `.hunch/config.json`, merging `patch` over the current on-disk config. */
29
+ export function writeConfig(paths, patch) {
30
+ const next = { ...readConfig(paths), ...patch };
31
+ mkdirSync(dirname(paths.config), { recursive: true });
32
+ writeFileSync(paths.config, JSON.stringify(next, null, 2) + "\n");
33
+ return next;
34
+ }
35
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1,22 @@
1
+ /** Return a BlockingHit if editing `file` (repo-relative) hits a blocking
2
+ * invariant directly or through its blast radius, else null. */
3
+ export function blockingInScope(store, file) {
4
+ for (const c of store.checkConstraints(file)) {
5
+ if (c.severity === "blocking") {
6
+ return {
7
+ reason: `Hunch: editing ${file} would touch a BLOCKING invariant — "${c.statement}" (${c.id}). Do not proceed unless this change is meant to modify that invariant; otherwise preserve it.`,
8
+ };
9
+ }
10
+ }
11
+ for (const b of store.blastRadiusFiles(file)) {
12
+ for (const c of store.checkConstraints(b.file)) {
13
+ if (c.severity === "blocking") {
14
+ return {
15
+ reason: `Hunch: ${file} is in the blast radius of a BLOCKING invariant — "${c.statement}" (${c.id}; via ${b.file}, ${b.via} depth ${b.depth}). Verify the invariant still holds before editing.`,
16
+ };
17
+ }
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ //# sourceMappingURL=hookpolicy.js.map
@@ -10,6 +10,7 @@ export function hunchPaths(root) {
10
10
  hunch,
11
11
  sqlite: join(hunch, "hunch.sqlite"),
12
12
  manifest: join(hunch, "manifest.json"),
13
+ config: join(hunch, "config.json"),
13
14
  dir: (kind) => join(hunch, kind),
14
15
  };
15
16
  }
@@ -3,8 +3,8 @@
3
3
  * context loaded every session for free"). We own ONLY the region between the
4
4
  * HUNCH markers — any user-authored content outside it is preserved verbatim.
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
7
- import { join } from "node:path";
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
8
  const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
9
9
  const END = "<!-- HUNCH:END -->";
10
10
  export function renderHunchSection(store) {
@@ -45,20 +45,18 @@ export function renderHunchSection(store) {
45
45
  lines.push(END);
46
46
  return lines.join("\n");
47
47
  }
48
- /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
49
- export function updateClaudeMd(root, store) {
50
- const file = join(root, "CLAUDE.md");
51
- const section = renderHunchSection(store);
48
+ /** Insert/replace the marker-delimited HUNCH section in a markdown doc, preserving
49
+ * all user-authored content outside the markers. Shared by CLAUDE.md, AGENTS.md,
50
+ * and .github/copilot-instructions.md so every assistant gets the same grounding. */
51
+ export function upsertSection(file, section, fallbackTitle) {
52
52
  let content = existsSync(file) ? readFileSync(file, "utf8") : "";
53
53
  const iStart = content.indexOf(START);
54
54
  const iEnd = content.indexOf(END);
55
55
  if (iStart >= 0 && iEnd > iStart) {
56
- // clean both-marker case: replace in place, preserving surrounding content
57
56
  content = content.slice(0, iStart) + section + content.slice(iEnd + END.length);
58
57
  }
59
58
  else if (iStart >= 0 || iEnd >= 0) {
60
- // partial/corrupt markers (only one survived, or out of order): strip every
61
- // stray marker line, then append ONE clean section — never duplicate.
59
+ // partial/corrupt markers: strip stray marker lines, then append ONE clean section.
62
60
  const body = content.split("\n").filter((l) => !l.includes(START) && !l.includes(END)).join("\n").trimEnd();
63
61
  content = body ? `${body}\n\n${section}\n` : `${section}\n`;
64
62
  }
@@ -66,11 +64,16 @@ export function updateClaudeMd(root, store) {
66
64
  content = `${content.trimEnd()}\n\n${section}\n`;
67
65
  }
68
66
  else {
69
- content = `# ${root.split("/").pop()}\n\n${section}\n`;
67
+ content = `${fallbackTitle}\n\n${section}\n`;
70
68
  }
69
+ mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
71
70
  writeFileSync(file, content);
72
71
  return file;
73
72
  }
73
+ /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
74
+ export function updateClaudeMd(root, store) {
75
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store), `# ${root.split("/").pop()}`);
76
+ }
74
77
  function sev(s) {
75
78
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
76
79
  }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Multi-assistant compatibility (DESIGN §7, extended). The Hunch MCP server is
3
+ * client-agnostic — any MCP-capable assistant can call the `hunch_*` tools. The
4
+ * only per-tool difference is HOW each one is told to launch the server and where
5
+ * its ambient grounding lives. This module scaffolds those surfaces for the major
6
+ * assistants so the same `.hunch/` graph powers all of them:
7
+ *
8
+ * Assistant | MCP config | root key | grounding file
9
+ * ------------|-------------------------|----------------|---------------------------------
10
+ * Claude Code | .mcp.json | mcpServers | CLAUDE.md (scaffold.ts)
11
+ * Cursor | .cursor/mcp.json | mcpServers | .cursor/rules/hunch.mdc
12
+ * VS Code | .vscode/mcp.json | servers (+type)| .github/copilot-instructions.md
13
+ * Codex CLI | .codex/config.toml | [mcp_servers.*]| AGENTS.md
14
+ * (any other) | — | — | AGENTS.md (cross-tool standard)
15
+ *
16
+ * Every writer MERGES into existing files (preserving other servers / user prose)
17
+ * and is idempotent, so re-running `hunch init` is safe.
18
+ */
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { join, dirname } from "node:path";
21
+ import { renderHunchSection, upsertSection } from "./claudemd.js";
22
+ /** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
23
+ * (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
24
+ * .vscode/mcp.json is JSONC, so we must tolerate comments. */
25
+ function stripJsonc(s) {
26
+ let out = "";
27
+ let inStr = false;
28
+ for (let i = 0; i < s.length; i++) {
29
+ const c = s[i];
30
+ const n = s[i + 1];
31
+ if (inStr) {
32
+ out += c;
33
+ if (c === "\\") {
34
+ out += n ?? "";
35
+ i++;
36
+ continue;
37
+ }
38
+ if (c === '"')
39
+ inStr = false;
40
+ continue;
41
+ }
42
+ if (c === '"') {
43
+ inStr = true;
44
+ out += c;
45
+ continue;
46
+ }
47
+ if (c === "/" && n === "/") {
48
+ while (i < s.length && s[i] !== "\n")
49
+ i++;
50
+ continue;
51
+ }
52
+ if (c === "/" && n === "*") {
53
+ i += 2;
54
+ while (i < s.length && !(s[i] === "*" && s[i + 1] === "/"))
55
+ i++;
56
+ i++;
57
+ continue;
58
+ }
59
+ out += c;
60
+ }
61
+ return dropTrailingCommas(out);
62
+ }
63
+ /** Remove trailing commas (`,` before `}`/`]`) — string-aware, so a comma inside
64
+ * a string value (e.g. "a,]") is never touched. A blanket regex would corrupt it
65
+ * (the same trap test/migrate.test.ts guards against). Runs on comment-free text,
66
+ * so lookahead need only skip whitespace. */
67
+ function dropTrailingCommas(s) {
68
+ let out = "";
69
+ let inStr = false;
70
+ let esc = false;
71
+ for (let i = 0; i < s.length; i++) {
72
+ const c = s[i];
73
+ if (inStr) {
74
+ out += c;
75
+ if (esc)
76
+ esc = false;
77
+ else if (c === "\\")
78
+ esc = true;
79
+ else if (c === '"')
80
+ inStr = false;
81
+ continue;
82
+ }
83
+ if (c === '"') {
84
+ inStr = true;
85
+ out += c;
86
+ continue;
87
+ }
88
+ if (c === ",") {
89
+ let j = i + 1;
90
+ while (j < s.length && /\s/.test(s[j]))
91
+ j++;
92
+ if (s[j] === "}" || s[j] === "]")
93
+ continue; // trailing comma → drop
94
+ }
95
+ out += c;
96
+ }
97
+ return out;
98
+ }
99
+ /** Read a JSON/JSONC object. Returns {} only for an ABSENT or empty file. A
100
+ * non-empty file we cannot parse THROWS — overwriting it would silently wipe the
101
+ * user's other MCP servers. */
102
+ function readJsonObj(file) {
103
+ if (!existsSync(file))
104
+ return {};
105
+ const raw = readFileSync(file, "utf8");
106
+ if (!raw.trim())
107
+ return {};
108
+ try {
109
+ const v = JSON.parse(stripJsonc(raw));
110
+ if (v && typeof v === "object" && !Array.isArray(v))
111
+ return v;
112
+ throw new Error("not a JSON object");
113
+ }
114
+ catch (e) {
115
+ throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
116
+ }
117
+ }
118
+ /** Render a string as a TOML value: a literal '…' when safe (no escaping needed —
119
+ * ideal for Windows backslash paths), else a basic "…" with escapes. */
120
+ function tomlStr(s) {
121
+ // TOML literal '…' needs no escaping (ideal for Windows backslash paths) but
122
+ // can't contain a quote or newline; otherwise a basic "…" with escapes.
123
+ if (!/['\r\n]/.test(s))
124
+ return `'${s}'`;
125
+ const esc = s
126
+ .replace(/\\/g, "\\\\")
127
+ .replace(/"/g, '\\"')
128
+ .replace(/\n/g, "\\n")
129
+ .replace(/\r/g, "\\r")
130
+ .replace(/\t/g, "\\t");
131
+ return `"${esc}"`;
132
+ }
133
+ function writeJson(file, obj) {
134
+ mkdirSync(dirname(file), { recursive: true });
135
+ writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
136
+ return file;
137
+ }
138
+ /** Cursor: .cursor/mcp.json — same `mcpServers` shape as Claude Desktop/Code. */
139
+ export function writeCursorMcp(root, inv) {
140
+ const file = join(root, ".cursor", "mcp.json");
141
+ const json = readJsonObj(file);
142
+ json.mcpServers = json.mcpServers ?? {};
143
+ json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
144
+ return writeJson(file, json);
145
+ }
146
+ /** VS Code (Copilot agent mode): .vscode/mcp.json — root key is `servers`, and
147
+ * each stdio entry carries an explicit `type: "stdio"` (VS Code's schema). */
148
+ export function writeVscodeMcp(root, inv) {
149
+ const file = join(root, ".vscode", "mcp.json");
150
+ const json = readJsonObj(file);
151
+ json.servers = json.servers ?? {};
152
+ json.servers.hunch = { type: "stdio", command: inv.command, args: [...inv.args, "mcp"] };
153
+ return writeJson(file, json);
154
+ }
155
+ const TOML_START = "# >>> hunch mcp (managed) >>>";
156
+ const TOML_END = "# <<< hunch mcp <<<";
157
+ /** Codex CLI: .codex/config.toml — `[mcp_servers.hunch]` stdio entry. We own only
158
+ * a marker-delimited block; any other TOML the user has is preserved. Paths use
159
+ * TOML single-quote LITERAL strings so Windows backslashes need no escaping. */
160
+ export function writeCodexConfig(root, inv) {
161
+ const file = join(root, ".codex", "config.toml");
162
+ const argsToml = [...inv.args, "mcp"].map(tomlStr).join(", ");
163
+ const block = [
164
+ TOML_START,
165
+ "[mcp_servers.hunch]",
166
+ `command = ${tomlStr(inv.command)}`,
167
+ `args = [${argsToml}]`,
168
+ TOML_END,
169
+ ].join("\n");
170
+ // Strip any prior managed block first, so `base` is the user's own TOML.
171
+ const content = existsSync(file) ? readFileSync(file, "utf8") : "";
172
+ const i = content.indexOf(TOML_START);
173
+ const j = content.indexOf(TOML_END);
174
+ let base;
175
+ if (i >= 0 && j > i)
176
+ base = content.slice(0, i) + content.slice(j + TOML_END.length);
177
+ else if (i >= 0 || j >= 0)
178
+ base = content.split("\n").filter((l) => !l.includes(TOML_START) && !l.includes(TOML_END)).join("\n");
179
+ else
180
+ base = content;
181
+ // A user-authored [mcp_servers.hunch] outside our block would make TWO tables of
182
+ // the same name → TOML duplicate-table error. Refuse rather than corrupt it.
183
+ if (/^\s*\[mcp_servers\.hunch\]/m.test(base)) {
184
+ throw new Error(`refusing to edit ${file}: it already defines [mcp_servers.hunch] outside Hunch's managed block. Remove it, then re-run.`);
185
+ }
186
+ base = base.trimEnd();
187
+ mkdirSync(dirname(file), { recursive: true });
188
+ writeFileSync(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
189
+ return file;
190
+ }
191
+ /** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
192
+ * set of assistants read it). Marker-delimited so user prose is preserved. */
193
+ export function writeAgentsMd(root, store) {
194
+ return upsertSection(join(root, "AGENTS.md"), renderHunchSection(store), "# AGENTS.md");
195
+ }
196
+ /** GitHub Copilot custom instructions (VS Code / github.com). Same grounding. */
197
+ export function writeCopilotInstructions(root, store) {
198
+ return upsertSection(join(root, ".github", "copilot-instructions.md"), renderHunchSection(store), "# Copilot instructions");
199
+ }
200
+ /** Cursor project rule (.mdc = frontmatter + body). `alwaysApply` keeps the Hunch
201
+ * grounding in context for every request. Fully managed by Hunch (overwritten). */
202
+ export function writeCursorRule(root, store) {
203
+ const file = join(root, ".cursor", "rules", "hunch.mdc");
204
+ const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store)}\n`;
205
+ mkdirSync(dirname(file), { recursive: true });
206
+ writeFileSync(file, body);
207
+ return file;
208
+ }
209
+ /** Scaffold MCP config + grounding for all supported assistants. Returns a
210
+ * per-assistant summary for `hunch init` to print. Each assistant is isolated:
211
+ * a writer that refuses to clobber a malformed file degrades to a warning rather
212
+ * than aborting the rest. Claude Code is handled separately by scaffold.ts. */
213
+ export function scaffoldProviders(root, inv, store) {
214
+ const tasks = [
215
+ ["Cursor", () => [writeCursorMcp(root, inv), writeCursorRule(root, store)]],
216
+ ["VS Code (Copilot)", () => [writeVscodeMcp(root, inv), writeCopilotInstructions(root, store)]],
217
+ ["Codex CLI", () => [writeCodexConfig(root, inv)]],
218
+ ["Any (AGENTS.md)", () => [writeAgentsMd(root, store)]],
219
+ ];
220
+ return tasks.map(([assistant, run]) => {
221
+ try {
222
+ return { assistant, files: run() };
223
+ }
224
+ catch (e) {
225
+ return { assistant, files: [], error: e.message };
226
+ }
227
+ });
228
+ }
229
+ //# sourceMappingURL=providers.js.map
@@ -4,7 +4,7 @@
4
4
  * - .claude/commands/* → user-triggered slash commands for the §5 workflows
5
5
  */
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
- import { join } from "node:path";
7
+ import { join, dirname } from "node:path";
8
8
  /** Merge a `hunch` server entry into .mcp.json, preserving other servers. */
9
9
  export function writeMcpJson(root, inv) {
10
10
  const file = join(root, ".mcp.json");
@@ -55,6 +55,62 @@ then produce a **fragility report with evidence**: the specific files/functions,
55
55
  the bug history behind them, their churn and fan-in, and any missing guards.
56
56
  Avoid generic advice — every claim must cite a Hunch record or metric.
57
57
  `;
58
+ /** A settings.json hook entry is Hunch's if any of its commands ends with the
59
+ * Hunch CLI entry + the `hook` subcommand (e.g. `…/index.js hook`). Matching the
60
+ * command TAIL — not the absolute path — makes re-init idempotent AND survives a
61
+ * repo-folder rename (the path before index.js changes; the tail does not). The
62
+ * leading path separator (`/` or `\`) requires `index` to be a full path segment,
63
+ * so a foreign tool's `…/myindex.js hook` isn't mistaken for ours and clobbered. */
64
+ function isHunchHook(entry) {
65
+ return !!entry.hooks?.some((h) => typeof h.command === "string" && /[\\/]index\.(js|ts)"?\s+hook\s*$/.test(h.command));
66
+ }
67
+ /**
68
+ * Install the Claude Code AGENT hooks into `.claude/settings.json` so the agent
69
+ * is grounded in Hunch automatically (not by remembering to call the tools):
70
+ * - PreToolUse (Edit|Write|MultiEdit) → inject the relevant Hunch slice before
71
+ * an edit, and (at strict firmness) deny edits that hit a blocking invariant.
72
+ * - UserPromptSubmit → remind the agent to consult Hunch.
73
+ * Both invoke `hunch hook`, which reads the firmness level from .hunch/config.json
74
+ * at run time — so changing firmness needs no settings.json edit. We own only our
75
+ * entries (matched by isHunchHook): other hooks and settings are preserved, and a
76
+ * non-empty file we cannot parse THROWS rather than clobbering the user's config.
77
+ */
78
+ export function installClaudeHooks(root, hookCmd) {
79
+ const file = join(root, ".claude", "settings.json");
80
+ const existed = existsSync(file);
81
+ let json = {};
82
+ let before = "";
83
+ if (existed) {
84
+ before = readFileSync(file, "utf8");
85
+ if (before.trim()) {
86
+ try {
87
+ const v = JSON.parse(before);
88
+ if (!v || typeof v !== "object" || Array.isArray(v))
89
+ throw new Error("not a JSON object");
90
+ json = v;
91
+ }
92
+ catch (e) {
93
+ throw new Error(`refusing to overwrite ${file}: could not parse it (${e.message}). Fix or remove it, then re-run.`);
94
+ }
95
+ }
96
+ }
97
+ json.hooks = json.hooks ?? {};
98
+ const keep = (arr) => (Array.isArray(arr) ? arr.filter((e) => !isHunchHook(e)) : []);
99
+ json.hooks.PreToolUse = [
100
+ ...keep(json.hooks.PreToolUse),
101
+ { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: hookCmd }] },
102
+ ];
103
+ json.hooks.UserPromptSubmit = [
104
+ ...keep(json.hooks.UserPromptSubmit),
105
+ { hooks: [{ type: "command", command: hookCmd }] },
106
+ ];
107
+ const next = JSON.stringify(json, null, 2) + "\n";
108
+ if (existed && before === next)
109
+ return { path: file, action: "unchanged" };
110
+ mkdirSync(dirname(file), { recursive: true });
111
+ writeFileSync(file, next);
112
+ return { path: file, action: existed ? "updated" : "created" };
113
+ }
58
114
  export function writeSlashCommands(root) {
59
115
  const dir = join(root, ".claude", "commands");
60
116
  mkdirSync(dir, { recursive: true });
@@ -132,9 +132,55 @@ const BUG_TOOL = {
132
132
  },
133
133
  };
134
134
  // --------------------------------------------------------------------------
135
+ // Base for headless-CLI SUBSCRIPTION providers. Each one drives a coding-assistant
136
+ // CLI billed to the user's own subscription (never a pay-per-token API key — see
137
+ // dec_5a7c0733f7). The prompt always goes over STDIN (never argv — keeps untrusted
138
+ // diff content out of any shell pexecIn uses on Windows), and the CLI's text output
139
+ // is handed to the SAME mappers, so the rest of the system is provider-agnostic.
140
+ // --------------------------------------------------------------------------
141
+ class CliSynthProvider {
142
+ /** Run a CLI with the prompt on stdin, stripping API-key env vars so the tool
143
+ * falls through to its SUBSCRIPTION credentials. Shared by codex/cursor. */
144
+ async runCli(bin, args, stripEnv, prompt, timeoutMs = 120_000) {
145
+ const env = { ...process.env };
146
+ for (const k of stripEnv)
147
+ delete env[k];
148
+ const { stdout } = await pexecIn(bin, args, {
149
+ input: prompt,
150
+ env,
151
+ cwd: tmpdir(),
152
+ maxBuffer: 16 * 1024 * 1024,
153
+ timeout: timeoutMs,
154
+ });
155
+ return stdout;
156
+ }
157
+ async draftDecision(input) {
158
+ const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
159
+ const draft = decisionDraftFromText(text, input.subject);
160
+ // No usable LLM JSON (truncation, refusal, prose-only, or a CLI whose output
161
+ // shape we misread) → THROW so the safe wrapper falls back to the deterministic
162
+ // provider, whose draft is honestly labeled ("inferred", low confidence).
163
+ if (!draft)
164
+ throw new Error(`${this.name}: no usable decision JSON in output`);
165
+ // For a LARGE diff the model only saw the structured summary + a sample — haircut
166
+ // the confidence and tag the source so provenance stays honest.
167
+ if (input.diff.length > LARGE_DIFF_CHARS) {
168
+ return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
169
+ }
170
+ return draft;
171
+ }
172
+ async draftBug(input) {
173
+ const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
174
+ const draft = bugDraftFromText(text, input.test, input.message);
175
+ if (!draft)
176
+ throw new Error(`${this.name}: no usable bug JSON in output`);
177
+ return draft;
178
+ }
179
+ }
180
+ // --------------------------------------------------------------------------
135
181
  // Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
136
182
  // --------------------------------------------------------------------------
137
- class ClaudeCliProvider {
183
+ class ClaudeCliProvider extends CliSynthProvider {
138
184
  name = "claude-cli";
139
185
  // Default to the `haiku` alias (cheap/fast, and survives model retirements)
140
186
  // rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
@@ -190,31 +236,54 @@ class ClaudeCliProvider {
190
236
  }
191
237
  return envelope.result ?? stdout;
192
238
  }
193
- async draftDecision(input) {
194
- const text = await this.run(`${SYSTEM}\n\n${commitPrompt(input)}\n\n${jsonInstruction(DECISION_TOOL.input_schema)}`);
195
- const draft = decisionDraftFromText(text, input.subject);
196
- // No usable LLM JSON (truncation, refusal, prose-only) → THROW so the safe
197
- // wrapper falls back to the deterministic provider, whose diff-structured
198
- // draft is both more useful AND honestly labeled ("inferred", low confidence)
199
- // than a hollow record mislabeled as an LLM draft.
200
- if (!draft)
201
- throw new Error("claude-cli: no usable decision JSON in output");
202
- // For a LARGE diff the model only saw the structured summary + a sample, not
203
- // the full patch (commitPrompt → renderDiff). The "why" is therefore lower-
204
- // fidelity than a draft made from the whole diff, so haircut the confidence
205
- // and tag the source — keeping provenance honest (a summary-sourced draft must
206
- // not masquerade as a full-fidelity llm_draft at 0.65).
207
- if (input.diff.length > LARGE_DIFF_CHARS) {
208
- return { ...draft, confidence: Math.min(draft.confidence, 0.5), source: `${draft.source}+summary` };
239
+ }
240
+ // --------------------------------------------------------------------------
241
+ // Provider B1: OpenAI Codex CLI (`codex exec`) — billed to the ChatGPT subscription
242
+ // --------------------------------------------------------------------------
243
+ class CodexCliProvider extends CliSynthProvider {
244
+ name = "codex-cli";
245
+ model = process.env.HUNCH_CODEX_MODEL; // omit codex uses its configured default
246
+ async available() {
247
+ try {
248
+ await pexecIn("codex", ["--version"], { timeout: 8000 });
249
+ return true;
250
+ }
251
+ catch {
252
+ return false;
209
253
  }
210
- return draft;
211
254
  }
212
- async draftBug(input) {
213
- const text = await this.run(`${SYSTEM}\n\n${failurePrompt(input)}\n\n${jsonInstruction(BUG_TOOL.input_schema)}`);
214
- const draft = bugDraftFromText(text, input.test, input.message);
215
- if (!draft)
216
- throw new Error("claude-cli: no usable bug JSON in output");
217
- return draft;
255
+ async run(prompt) {
256
+ // `codex exec --json -` reads the prompt from STDIN (the `-`), emits JSONL
257
+ // events. Strip OPENAI_API_KEY so it uses ChatGPT (subscription) auth, not the
258
+ // pay-per-token API — consistent with the subscription-only rule.
259
+ const args = ["exec", "--json", ...(this.model ? ["-m", this.model] : []), "-"];
260
+ const out = await this.runCli("codex", args, ["OPENAI_API_KEY"], prompt);
261
+ return extractCodexText(out);
262
+ }
263
+ }
264
+ // --------------------------------------------------------------------------
265
+ // Provider B2: Cursor Agent CLI (`cursor-agent -p`) — billed to the Cursor subscription
266
+ // --------------------------------------------------------------------------
267
+ class CursorCliProvider extends CliSynthProvider {
268
+ name = "cursor-agent";
269
+ model = process.env.HUNCH_CURSOR_MODEL;
270
+ async available() {
271
+ try {
272
+ await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
273
+ return true;
274
+ }
275
+ catch {
276
+ return false;
277
+ }
278
+ }
279
+ async run(prompt) {
280
+ // `-p --output-format text` → final answer as plain text (no event stream to
281
+ // parse). `--trust` so it runs non-interactively. Prompt over stdin. Cursor's
282
+ // CLI uses the user's Cursor login (subscription) — no API key to strip.
283
+ const args = ["-p", "--output-format", "text", "--trust", ...(this.model ? ["-m", this.model] : [])];
284
+ // Shorter timeout than the others: cursor-agent -p is reported to hang in some
285
+ // headless setups; cap the stall before degrading to the deterministic provider.
286
+ return this.runCli("cursor-agent", args, [], prompt, 45_000);
218
287
  }
219
288
  }
220
289
  // --------------------------------------------------------------------------
@@ -269,23 +338,71 @@ export class DeterministicProvider {
269
338
  };
270
339
  }
271
340
  }
272
- const PROVIDERS = [new ClaudeCliProvider(), new DeterministicProvider()];
341
+ /** Extract the final assistant message from `codex exec --json` output (newline-
342
+ * delimited JSON events). Codex tags assistant turns as `item.type ==="agent_message"`,
343
+ * but it ALSO emits `item.text` for reasoning and may append trailing events — so we
344
+ * prefer the last AGENT message and only fall back to the last any-text when none is
345
+ * tagged. If nothing parses, hand the raw output to the mapper (→ it finds the JSON
346
+ * draft or throws → deterministic fallback). Tolerant by design: drift degrades, never crashes. */
347
+ export function extractCodexText(out) {
348
+ const texts = [];
349
+ const agentTexts = [];
350
+ for (const line of out.split(/\r?\n/)) {
351
+ const t = line.trim();
352
+ if (!t.startsWith("{"))
353
+ continue;
354
+ try {
355
+ const o = JSON.parse(t);
356
+ const item = o.item;
357
+ const cand = (item?.text ?? o.text ?? o.message);
358
+ if (typeof cand === "string" && cand.trim()) {
359
+ texts.push(cand);
360
+ const ty = (item?.type ?? o.type);
361
+ if (typeof ty === "string" && /agent|assistant|message\b/.test(ty) && !/reason/.test(ty))
362
+ agentTexts.push(cand);
363
+ }
364
+ }
365
+ catch {
366
+ /* not a JSON event line — skip */
367
+ }
368
+ }
369
+ if (agentTexts.length)
370
+ return agentTexts[agentTexts.length - 1];
371
+ return texts.length ? texts[texts.length - 1] : out;
372
+ }
373
+ // Priority order: try each subscription CLI, then the always-available heuristic.
374
+ // HUNCH_SYNTH_PROVIDER forces one by name (claude-cli / codex-cli / cursor-agent /
375
+ // deterministic).
376
+ const PROVIDERS = [
377
+ new ClaudeCliProvider(),
378
+ new CodexCliProvider(),
379
+ new CursorCliProvider(),
380
+ new DeterministicProvider(),
381
+ ];
382
+ // Availability rarely changes within a process (a CLI doesn't get installed mid-run),
383
+ // and selectProvider() runs on every sync/recordFailure — so memoize each probe.
384
+ // Especially matters in the long-lived MCP server and on machines with NO assistant
385
+ // CLI, where an uncached pass spawns one failing `--version` per provider every time.
386
+ const availCache = new Map();
387
+ function isAvailable(p) {
388
+ let v = availCache.get(p.name);
389
+ if (!v) {
390
+ v = p.available().catch(() => false);
391
+ availCache.set(p.name, v);
392
+ }
393
+ return v;
394
+ }
273
395
  /** Choose the first available provider, honoring HUNCH_SYNTH_PROVIDER override. */
274
396
  export async function selectProvider() {
275
397
  const forced = process.env.HUNCH_SYNTH_PROVIDER;
276
398
  if (forced) {
277
399
  const p = PROVIDERS.find((x) => x.name === forced);
278
- if (p && (await p.available()))
400
+ if (p && (await isAvailable(p)))
279
401
  return p;
280
402
  }
281
403
  for (const p of PROVIDERS) {
282
- try {
283
- if (await p.available())
284
- return p;
285
- }
286
- catch {
287
- /* try next */
288
- }
404
+ if (await isAvailable(p))
405
+ return p;
289
406
  }
290
407
  return new DeterministicProvider();
291
408
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "0.5.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
6
  "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",