@davesheffer/hunch 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -2
- package/dist/cli/index.js +6 -46
- package/dist/core/checkreport.js +33 -1
- package/dist/mcp/server.js +36 -1
- package/dist/store/hunchStore.js +73 -0
- package/dist/synthesis/provider.js +15 -3
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -109,8 +109,10 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
|
109
109
|
`hunch_check_constraints`, `hunch_get_dependents` (blast radius), `hunch_blast_radius`
|
|
110
110
|
(dependent files + near-violations a change could break indirectly), `hunch_bug_lineage`,
|
|
111
111
|
`hunch_context` (surgical minimal slice for a task), `hunch_timeline` (a target's decision
|
|
112
|
-
history over time), `
|
|
113
|
-
|
|
112
|
+
history over time), `hunch_merge_verdict` (a cited BLOCK/WARN/PASS over a diff — see below),
|
|
113
|
+
and the write-backs `hunch_record_decision`, `hunch_record_correction` (turn a correction into
|
|
114
|
+
an enforced invariant — see below). `hunch_why` and `hunch_context` take an optional `as_of`
|
|
115
|
+
(commit/tag/branch) to **time-travel** the graph to a past state.
|
|
114
116
|
|
|
115
117
|
### Works with any MCP assistant
|
|
116
118
|
|
|
@@ -220,6 +222,31 @@ repo-wide (`**`) rule is only blocking when you pass `applies_to_all`, so one co
|
|
|
220
222
|
can't silently gate the whole tree. Because it's the *same* constraint machinery, a
|
|
221
223
|
correction is enforced exactly like a hand-authored invariant — see firmness above.
|
|
222
224
|
|
|
225
|
+
## Causal Merge Verdict: does this change re-open a closed bug?
|
|
226
|
+
|
|
227
|
+
A diff-only reviewer (CodeRabbit, Greptile) sees *what* changed. It can't see that the line
|
|
228
|
+
you're deleting is the fix for an incident, or that the symbol you're re-adding was
|
|
229
|
+
deliberately retired. Hunch can — because it holds the **why**.
|
|
230
|
+
|
|
231
|
+
`hunch_merge_verdict` (MCP tool) and `hunch check` replay a diff against the graph and return
|
|
232
|
+
one verdict — **BLOCK / WARN / PASS** — that *cites the reasoning*, not just the rule:
|
|
233
|
+
|
|
234
|
+
```text
|
|
235
|
+
VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a known bug.
|
|
236
|
+
|
|
237
|
+
⛔ pay() must verify the session before charging — con_pay
|
|
238
|
+
🧠 why: "Charge must verify the session first" (dec_pay)
|
|
239
|
+
🐞 guards against: Double-charge on unverified session — pay() charged without verifying (bug_trunc)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
It's **deterministic** (no LLM): for every invariant *directly* in scope it walks
|
|
243
|
+
constraint → `source_decision` → the bug whose root cause spawned it; it flags invariants
|
|
244
|
+
reached transitively (blast radius, advisory) and any deliberately-retired code the diff
|
|
245
|
+
re-introduces. BLOCK fires only on a direct, high-confidence, non-stale **blocking** invariant
|
|
246
|
+
or a blocking-linked regression — near-hits stay advisory, so it's safe as a merge gate. Call
|
|
247
|
+
it before opening a PR (`{}` checks staged changes; pass `base: "origin/main"` for the range);
|
|
248
|
+
the CI Constraint Guard renders the same cited verdict as a PR comment.
|
|
249
|
+
|
|
223
250
|
## Semantic search (optional)
|
|
224
251
|
|
|
225
252
|
By default `hunch query` and the `hunch_query` MCP tool use fast keyword (FTS) search —
|
package/dist/cli/index.js
CHANGED
|
@@ -26,8 +26,6 @@ import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesi
|
|
|
26
26
|
import { parseTestReport } from "../extractors/testreport.js";
|
|
27
27
|
import { selectProvider } from "../synthesis/provider.js";
|
|
28
28
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles, asOfDate, stagedDiff, commitDiff, rangeFiles, rangeDiff, revExists } from "../extractors/git.js";
|
|
29
|
-
import { analyzeDiff } from "../extractors/diff.js";
|
|
30
|
-
import { isStrictBlocker } from "../core/strictgate.js";
|
|
31
29
|
import { renderText, renderMarkdown, reportFailsStrict } from "../core/checkreport.js";
|
|
32
30
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
33
31
|
import { installMergeDriver } from "../integrations/mergeDriver.js";
|
|
@@ -566,52 +564,14 @@ program
|
|
|
566
564
|
store.close();
|
|
567
565
|
return;
|
|
568
566
|
}
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
for (const c of store.checkConstraints(f)) {
|
|
573
|
-
const e = direct.get(c.id) ?? { c, files: [] };
|
|
574
|
-
e.files.push(f);
|
|
575
|
-
direct.set(c.id, e);
|
|
576
|
-
}
|
|
577
|
-
// 2) NEAR — reached only through the blast radius (a guarded dependency changed).
|
|
578
|
-
const near = new Map();
|
|
579
|
-
for (const f of files)
|
|
580
|
-
for (const b of store.blastRadiusFiles(f))
|
|
581
|
-
for (const c of store.checkConstraints(b.file)) {
|
|
582
|
-
if (direct.has(c.id))
|
|
583
|
-
continue; // already a direct hit
|
|
584
|
-
const e = near.get(c.id) ?? { c, via: [] };
|
|
585
|
-
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
586
|
-
near.set(c.id, e);
|
|
587
|
-
}
|
|
588
|
-
// 3) REGRESSION — does the diff RE-ADD something an in-force decision retired?
|
|
567
|
+
// DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
|
|
568
|
+
// code) + the hardened strict gate + causal `why` citations — all assembled by
|
|
569
|
+
// the shared store.buildCheckReport (also used by the hunch_merge_verdict tool).
|
|
589
570
|
const diff = opts.commit ? commitDiff(opts.commit, root) : opts.base ? rangeDiff(opts.base, root) : stagedDiff(root);
|
|
590
|
-
const
|
|
591
|
-
const regHits = store.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
592
|
-
// Hardened strict gate (strictgate.ts): only DIRECT + high-confidence + non-stale
|
|
593
|
-
// can fail. near/stale/low-confidence stay advisory — safe on a shared repo / PR.
|
|
594
|
-
const staleConstraintIds = opts.strict
|
|
595
|
-
? new Set(store.staleness((f) => lastChangeDate(f, root)).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
596
|
-
: new Set();
|
|
597
|
-
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
598
|
-
const stale = staleConstraintIds.has(c.id);
|
|
599
|
-
const strictBlocks = isStrictBlocker(c, stale);
|
|
600
|
-
return {
|
|
601
|
-
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
602
|
-
files: fs, strictBlocks,
|
|
603
|
-
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
604
|
-
};
|
|
605
|
-
});
|
|
606
|
-
const report = {
|
|
607
|
-
fileCount: files.length,
|
|
571
|
+
const report = store.buildCheckReport(files, diff, {
|
|
608
572
|
strict: !!opts.strict,
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
612
|
-
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
613
|
-
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
614
|
-
};
|
|
573
|
+
lastChange: (f) => lastChangeDate(f, root),
|
|
574
|
+
});
|
|
615
575
|
if (opts.blast && !markdown) {
|
|
616
576
|
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
617
577
|
for (const f of files) {
|
package/dist/core/checkreport.js
CHANGED
|
@@ -11,6 +11,36 @@ export function reportFailsStrict(r) {
|
|
|
11
11
|
return r.strict && (r.strictBlockers > 0 || r.regBlocking > 0);
|
|
12
12
|
}
|
|
13
13
|
const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
|
|
14
|
+
const clip = (s, n = 160) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
|
|
15
|
+
/** The deterministic VERDICT for a merge: block (a hard gate fired), warn (touches
|
|
16
|
+
* memory but nothing hard-blocks), or pass (touches no recorded memory at all). */
|
|
17
|
+
export function verdict(r) {
|
|
18
|
+
if (r.strictBlockers > 0 || r.regBlocking > 0)
|
|
19
|
+
return "block";
|
|
20
|
+
return reportIsClean(r) ? "pass" : "warn";
|
|
21
|
+
}
|
|
22
|
+
/** Causal "why" citation, terminal form (appended to a direct hit). */
|
|
23
|
+
function whyText(why) {
|
|
24
|
+
if (!why)
|
|
25
|
+
return "";
|
|
26
|
+
const out = [];
|
|
27
|
+
if (why.decision)
|
|
28
|
+
out.push(`\n ↳ why: “${why.decision.title}” (${why.decision.id})${why.decision.decision ? ` — ${clip(why.decision.decision)}` : ""}`);
|
|
29
|
+
if (why.bug)
|
|
30
|
+
out.push(`\n ↳ guards against: ${why.bug.title} — ${clip(why.bug.root_cause)} (${why.bug.id})`);
|
|
31
|
+
return out.join("");
|
|
32
|
+
}
|
|
33
|
+
/** Causal "why" citation, markdown form (returns bullet lines). */
|
|
34
|
+
function whyMd(why) {
|
|
35
|
+
if (!why)
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
if (why.decision)
|
|
39
|
+
out.push(` - 🧠 _why:_ “${why.decision.title}” (\`${why.decision.id}\`)`);
|
|
40
|
+
if (why.bug)
|
|
41
|
+
out.push(` - 🐞 _guards against:_ ${clip(why.bug.title, 100)} — ${clip(why.bug.root_cause)} (\`${why.bug.id}\`)`);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
14
44
|
// ---------------------------------------------------------------------------
|
|
15
45
|
// Terminal text (unchanged from the inline CLI output it replaces)
|
|
16
46
|
// ---------------------------------------------------------------------------
|
|
@@ -25,7 +55,7 @@ export function renderText(r) {
|
|
|
25
55
|
const note = r.strict && c.severity === "blocking" && !c.strictBlocks
|
|
26
56
|
? c.downgrade === "stale" ? " (advisory: stale)" : " (advisory: low confidence)"
|
|
27
57
|
: "";
|
|
28
|
-
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
58
|
+
out.push(` ${mark(c.severity)} [${c.severity}] ${c.statement}${note}\n ${c.id} · in: ${c.files.join(", ")}\n rationale: ${c.rationale || "—"}${whyText(c.why)}`);
|
|
29
59
|
}
|
|
30
60
|
}
|
|
31
61
|
if (r.near.length) {
|
|
@@ -74,6 +104,8 @@ export function renderMarkdown(r) {
|
|
|
74
104
|
out.push(` - in: ${c.files.map((f) => `\`${f}\``).join(", ")}`);
|
|
75
105
|
if (c.rationale)
|
|
76
106
|
out.push(` - _${c.rationale}_`);
|
|
107
|
+
for (const line of whyMd(c.why))
|
|
108
|
+
out.push(line);
|
|
77
109
|
}
|
|
78
110
|
out.push("");
|
|
79
111
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -14,8 +14,9 @@ import { HunchStore } from "../store/hunchStore.js";
|
|
|
14
14
|
import { selectEmbedder } from "../store/embedder.js";
|
|
15
15
|
import { decisionId } from "../core/ids.js";
|
|
16
16
|
import { buildCorrectionConstraint } from "../core/correction.js";
|
|
17
|
-
import { revParse, asOfDate } from "../extractors/git.js";
|
|
17
|
+
import { revParse, asOfDate, revExists, lastChangeDate, rangeFiles, rangeDiff, commitFiles, commitDiff, stagedFiles, stagedDiff } from "../extractors/git.js";
|
|
18
18
|
import { formatContext } from "../core/format.js";
|
|
19
|
+
import { renderMarkdown, verdict } from "../core/checkreport.js";
|
|
19
20
|
const ok = (text) => ({ content: [{ type: "text", text }] });
|
|
20
21
|
const err = (text) => ({ content: [{ type: "text", text }], isError: true });
|
|
21
22
|
// Read-side token budgets: every tool result is injected into a Claude Code
|
|
@@ -317,6 +318,40 @@ export function buildServer(root) {
|
|
|
317
318
|
return err(`Failed to record correction: ${e.message}`);
|
|
318
319
|
}
|
|
319
320
|
});
|
|
321
|
+
// -- hunch_merge_verdict (Causal Merge Verdict — read-only, client-agnostic) --
|
|
322
|
+
server.registerTool("hunch_merge_verdict", {
|
|
323
|
+
title: "Causal merge verdict: is this change safe against the recorded WHY?",
|
|
324
|
+
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) and any deliberately-retired code the diff re-introduces. 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.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
base: z.string().optional().describe("Diff against this base ref (e.g. origin/main) — for a PR/branch."),
|
|
327
|
+
commit: z.string().optional().describe("Diff a single commit (sha/ref). Omit base AND commit to check staged changes."),
|
|
328
|
+
},
|
|
329
|
+
}, async ({ base, commit }) => {
|
|
330
|
+
try {
|
|
331
|
+
if (base && commit)
|
|
332
|
+
return err("Pass at most one of base/commit (omit both to check staged changes).");
|
|
333
|
+
if (base && !revExists(base, root))
|
|
334
|
+
return err(`base ref "${base}" does not resolve (in CI, fetch the base branch first).`);
|
|
335
|
+
if (commit && !revExists(commit, root))
|
|
336
|
+
return err(`commit "${commit}" does not resolve.`);
|
|
337
|
+
const files = commit ? commitFiles(commit, root) : base ? rangeFiles(base, root) : stagedFiles(root);
|
|
338
|
+
const scope = commit ? `commit ${commit}` : base ? `${base}..HEAD` : "staged changes";
|
|
339
|
+
if (!files.length)
|
|
340
|
+
return ok(`VERDICT: ✅ PASS — no changed files in ${scope}.`);
|
|
341
|
+
const diff = commit ? commitDiff(commit, root) : base ? rangeDiff(base, root) : stagedDiff(root);
|
|
342
|
+
const report = store.buildCheckReport(files, diff, { strict: true, lastChange: (f) => lastChangeDate(f, root) });
|
|
343
|
+
const v = verdict(report);
|
|
344
|
+
const head = v === "block"
|
|
345
|
+
? "VERDICT: ⛔ BLOCK — this change breaks a recorded invariant or re-opens a known bug."
|
|
346
|
+
: v === "warn"
|
|
347
|
+
? "VERDICT: ⚠ WARN — this change touches engineering memory; review the cited why below before merge."
|
|
348
|
+
: "VERDICT: ✅ PASS — touches no recorded invariants and re-introduces nothing deliberately retired.";
|
|
349
|
+
return ok(`${head}\n(scope: ${scope}, ${files.length} file(s))\n\n${renderMarkdown(report)}`);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
return err(`Failed to compute merge verdict: ${e.message}`);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
320
355
|
return server;
|
|
321
356
|
}
|
|
322
357
|
function provLine(record) {
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -18,6 +18,8 @@ import { selectEmbedder } from "./embedder.js";
|
|
|
18
18
|
import { JsonStore } from "./jsonStore.js";
|
|
19
19
|
import { pathMatchesGlob } from "../core/glob.js";
|
|
20
20
|
import { edgeId } from "../core/ids.js";
|
|
21
|
+
import { isStrictBlocker } from "../core/strictgate.js";
|
|
22
|
+
import { analyzeDiff } from "../extractors/diff.js";
|
|
21
23
|
export class HunchStore {
|
|
22
24
|
paths;
|
|
23
25
|
json;
|
|
@@ -389,6 +391,77 @@ export class HunchStore {
|
|
|
389
391
|
.filter((c) => (asOf ? inWindow(c.valid_from, c.valid_to, asOf) : c.status !== "retired"))
|
|
390
392
|
.sort((a, b) => sev(b.severity) - sev(a.severity));
|
|
391
393
|
}
|
|
394
|
+
/** The causal chain behind a constraint — the WHY a diff-only reviewer can't see.
|
|
395
|
+
* Deterministic graph join: constraint → source_decision (the decision that
|
|
396
|
+
* motivated the guard) → the bug whose root cause spawned it (via
|
|
397
|
+
* lineage.spawned_constraint, else the source decision's caused_by_bug). Read-only. */
|
|
398
|
+
causalChain(constraintId) {
|
|
399
|
+
const out = { constraint_id: constraintId };
|
|
400
|
+
const c = this.json.get("constraints", constraintId);
|
|
401
|
+
if (!c)
|
|
402
|
+
return out;
|
|
403
|
+
const dec = c.source_decision ? this.json.get("decisions", c.source_decision) : null;
|
|
404
|
+
if (dec)
|
|
405
|
+
out.decision = { id: dec.id, title: dec.title, decision: dec.decision };
|
|
406
|
+
const bugs = this.json.loadAll("bugs");
|
|
407
|
+
// Deterministic when several bugs link one constraint (the verdict claims to be
|
|
408
|
+
// deterministic): highest severity first, then lowest id — never filesystem order.
|
|
409
|
+
const SEV = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
410
|
+
const linked = bugs
|
|
411
|
+
.filter((b) => b.lineage?.spawned_constraint === constraintId)
|
|
412
|
+
.sort((a, b) => (SEV[b.severity] ?? 0) - (SEV[a.severity] ?? 0) || a.id.localeCompare(b.id));
|
|
413
|
+
const bug = linked[0] ?? (dec?.caused_by_bug ? bugs.find((b) => b.id === dec.caused_by_bug) : undefined);
|
|
414
|
+
if (bug)
|
|
415
|
+
out.bug = { id: bug.id, title: bug.title, root_cause: bug.root_cause };
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
/** Assemble a CheckReport from a diff: direct invariant hits, near hits (blast
|
|
419
|
+
* radius), and regressions (re-added retired code), with the hardened strict
|
|
420
|
+
* gate and a causal `why` citation per direct hit. Read-only — shared by
|
|
421
|
+
* `hunch check`, the CI guard, and hunch_merge_verdict so they never drift. */
|
|
422
|
+
buildCheckReport(files, diff, opts) {
|
|
423
|
+
const direct = new Map();
|
|
424
|
+
for (const f of files)
|
|
425
|
+
for (const c of this.checkConstraints(f)) {
|
|
426
|
+
const e = direct.get(c.id) ?? { c, files: [] };
|
|
427
|
+
e.files.push(f);
|
|
428
|
+
direct.set(c.id, e);
|
|
429
|
+
}
|
|
430
|
+
const near = new Map();
|
|
431
|
+
for (const f of files)
|
|
432
|
+
for (const b of this.blastRadiusFiles(f))
|
|
433
|
+
for (const c of this.checkConstraints(b.file)) {
|
|
434
|
+
if (direct.has(c.id))
|
|
435
|
+
continue;
|
|
436
|
+
const e = near.get(c.id) ?? { c, via: [] };
|
|
437
|
+
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
438
|
+
near.set(c.id, e);
|
|
439
|
+
}
|
|
440
|
+
const an = analyzeDiff(diff);
|
|
441
|
+
const regHits = this.regressionHits({ symbols: an.addedSymbols.map((s) => s.name), deps: an.addedDeps }, files);
|
|
442
|
+
const staleIds = opts.strict && opts.lastChange
|
|
443
|
+
? new Set(this.staleness(opts.lastChange).filter((s) => s.kind === "constraint").map((s) => s.id))
|
|
444
|
+
: new Set();
|
|
445
|
+
const directReport = [...direct.values()].map(({ c, files: fs }) => {
|
|
446
|
+
const stale = staleIds.has(c.id);
|
|
447
|
+
const strictBlocks = isStrictBlocker(c, stale);
|
|
448
|
+
return {
|
|
449
|
+
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
450
|
+
files: fs, strictBlocks,
|
|
451
|
+
downgrade: c.severity === "blocking" && !strictBlocks ? (stale ? "stale" : "low-confidence") : undefined,
|
|
452
|
+
why: this.causalChain(c.id),
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
return {
|
|
456
|
+
fileCount: files.length,
|
|
457
|
+
strict: opts.strict,
|
|
458
|
+
direct: directReport,
|
|
459
|
+
near: [...near.values()].map(({ c, via }) => ({ id: c.id, severity: c.severity ?? "advisory", statement: c.statement, via })),
|
|
460
|
+
regressions: regHits.map((h) => ({ kind: h.kind, name: h.name, decision: h.decision, title: h.title, reason: h.reason, blocking: h.blocking })),
|
|
461
|
+
strictBlockers: directReport.filter((d) => d.strictBlocks).length,
|
|
462
|
+
regBlocking: regHits.filter((h) => h.blocking).length,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
392
465
|
/** Time-travel: the decision history for a target — every decision touching it,
|
|
393
466
|
* newest-first, with its valid-time window and supersession links. Answers
|
|
394
467
|
* "what did we believe, and when/why did it change?" (hunch_timeline). */
|
|
@@ -177,6 +177,18 @@ class CliSynthProvider {
|
|
|
177
177
|
return draft;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
+
// A model id comes from a HUNCH_*_MODEL env var and ends up as an argv token that,
|
|
181
|
+
// on Windows, pexecIn joins into the cmd.exe line (shell:true, to resolve the npm
|
|
182
|
+
// `.cmd` shim). The untrusted prompt always travels via stdin — never argv — so the
|
|
183
|
+
// ONLY non-literal token reaching that shell line is this model id. Reject anything
|
|
184
|
+
// with whitespace or a cmd.exe metacharacter so a poisoned env var can't smuggle
|
|
185
|
+
// `& evil.exe` into the line; fall back to the provider default rather than crash on
|
|
186
|
+
// a mere typo. (pexecIn itself stays general — callers may legitimately pass a
|
|
187
|
+
// pre-quoted path token — so the guard lives here, at the untrusted-input source.)
|
|
188
|
+
const MODEL_RE = /^[A-Za-z0-9._:/-]+$/;
|
|
189
|
+
export function safeModel(v, fallback) {
|
|
190
|
+
return v && MODEL_RE.test(v) ? v : fallback;
|
|
191
|
+
}
|
|
180
192
|
// --------------------------------------------------------------------------
|
|
181
193
|
// Provider A: headless `claude -p` CLI — billed to the user's Claude subscription
|
|
182
194
|
// --------------------------------------------------------------------------
|
|
@@ -184,7 +196,7 @@ class ClaudeCliProvider extends CliSynthProvider {
|
|
|
184
196
|
name = "claude-cli";
|
|
185
197
|
// Default to the `haiku` alias (cheap/fast, and survives model retirements)
|
|
186
198
|
// rather than a pinned dated id; override with HUNCH_SYNTH_MODEL if needed.
|
|
187
|
-
model = process.env.HUNCH_SYNTH_MODEL
|
|
199
|
+
model = safeModel(process.env.HUNCH_SYNTH_MODEL, "haiku");
|
|
188
200
|
async available() {
|
|
189
201
|
try {
|
|
190
202
|
await pexecIn("claude", ["--version"], { timeout: 8000 });
|
|
@@ -242,7 +254,7 @@ class ClaudeCliProvider extends CliSynthProvider {
|
|
|
242
254
|
// --------------------------------------------------------------------------
|
|
243
255
|
class CodexCliProvider extends CliSynthProvider {
|
|
244
256
|
name = "codex-cli";
|
|
245
|
-
model = process.env.HUNCH_CODEX_MODEL; // omit → codex uses its configured default
|
|
257
|
+
model = safeModel(process.env.HUNCH_CODEX_MODEL, undefined); // omit → codex uses its configured default
|
|
246
258
|
async available() {
|
|
247
259
|
try {
|
|
248
260
|
await pexecIn("codex", ["--version"], { timeout: 8000 });
|
|
@@ -266,7 +278,7 @@ class CodexCliProvider extends CliSynthProvider {
|
|
|
266
278
|
// --------------------------------------------------------------------------
|
|
267
279
|
class CursorCliProvider extends CliSynthProvider {
|
|
268
280
|
name = "cursor-agent";
|
|
269
|
-
model = process.env.HUNCH_CURSOR_MODEL;
|
|
281
|
+
model = safeModel(process.env.HUNCH_CURSOR_MODEL, undefined);
|
|
270
282
|
async available() {
|
|
271
283
|
try {
|
|
272
284
|
await pexecIn("cursor-agent", ["--version"], { timeout: 8000 });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
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.",
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"dist/**/*.js"
|
|
21
21
|
],
|
|
22
22
|
"publishConfig": {
|
|
23
|
-
"access": "public"
|
|
23
|
+
"access": "public",
|
|
24
|
+
"provenance": true
|
|
24
25
|
},
|
|
25
26
|
"keywords": [
|
|
26
27
|
"claude-code",
|