@davesheffer/hunch 0.1.0 → 0.1.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/dist/cli/index.js CHANGED
@@ -177,11 +177,11 @@ program
177
177
  let hits;
178
178
  let how = "";
179
179
  if (opts.semantic) {
180
- // Resolve once; if semantic isn't actually usable, say so and degrade to FTS
181
- // (rather than silently returning identical keyword results under a flag).
180
+ // Same gate hybridSearch uses internally (store.semanticReady), so the flag's
181
+ // messaging can't drift from what actually runs. If unusable, say so and use FTS
182
+ // rather than silently returning identical keyword results under the flag.
182
183
  const emb = await selectEmbedder();
183
- const cov = emb ? store.embeddingStats(emb.id) : null;
184
- if (!emb || !cov || cov.embedded === 0) {
184
+ if (!store.semanticReady(emb)) {
185
185
  console.log("· semantic search isn't enabled yet — run `hunch embed` (using keyword search for now).\n");
186
186
  hits = store.search(q, 12);
187
187
  }
package/dist/core/ids.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * git diff of `.hunch/` stays minimal. Decisions/bugs use a content hash too,
4
4
  * so the learning loop is idempotent for the same commit. */
5
5
  import { createHash } from "node:crypto";
6
- function shortHash(input, len = 10) {
6
+ export function shortHash(input, len = 10) {
7
7
  return createHash("sha1").update(input).digest("hex").slice(0, len);
8
8
  }
9
9
  /** Full sha1 (used for signature_hash etc.). */
@@ -32,36 +32,31 @@ function installedPackage() {
32
32
  }
33
33
  return null;
34
34
  }
35
- /** Run `fn` with stray stdout writes rerouted to stderr. transformers.js / its
36
- * backends may log during model load; on the MCP stdio channel a single stray
37
- * byte on stdout corrupts JSON-RPC. The guard is active only for the duration of
38
- * `fn` (model load), which is the only noisy phase. */
39
- async function withStdoutGuarded(fn) {
40
- const orig = process.stdout.write.bind(process.stdout);
41
- const toErr = process.stderr.write.bind(process.stderr);
42
- process.stdout.write = toErr;
43
- try {
44
- return await fn();
45
- }
46
- finally {
47
- process.stdout.write = orig;
48
- }
49
- }
50
35
  export class TransformersEmbedder {
51
36
  dim = 384;
52
37
  id = "all-MiniLM-L6-v2";
53
38
  extractor = null;
54
39
  load() {
55
40
  if (!this.extractor) {
56
- this.extractor = withStdoutGuarded(async () => {
41
+ const p = (async () => {
57
42
  const pkg = installedPackage();
58
43
  if (!pkg)
59
44
  throw new Error("transformers.js not installed");
60
45
  // String var (not a literal) so tsc doesn't require the optional dep to be
61
46
  // present to typecheck/build, and the import resolves at runtime when it is.
47
+ // transformers.js logs only via an opt-in progress_callback (which we never
48
+ // pass) and onnxruntime warns to stderr, so the model load never writes to
49
+ // stdout — safe for the MCP JSON-RPC stdio channel without redirecting it.
62
50
  const mod = (await import(pkg));
63
51
  return (await mod.pipeline("feature-extraction", HF_MODEL));
64
- });
52
+ })();
53
+ this.extractor = p;
54
+ // Never cache a REJECTED load: a transient failure (network blip during the
55
+ // first model download, a missing/incompatible native backend) must not poison
56
+ // the embedder for the rest of a long-lived (MCP) process. Reset so the next
57
+ // call retries from scratch.
58
+ p.catch(() => { if (this.extractor === p)
59
+ this.extractor = null; });
65
60
  }
66
61
  return this.extractor;
67
62
  }
@@ -135,6 +135,11 @@ export class HunchStore {
135
135
  * (doc_hash mismatch). Model-free and cheap; run at the end of every reindex()
136
136
  * so vectors track the JSON truth without ever being reset. Returns the count. */
137
137
  pruneStaleEmbeddings() {
138
+ // Lean-install fast path: no vectors → nothing to reconcile. This runs at the
139
+ // end of EVERY reindex() (a hot path), so skip the full doc scan + per-doc hash
140
+ // unless embeddings actually exist.
141
+ if (this.db.prepare(`SELECT count(*) c FROM embeddings`).get().c === 0)
142
+ return 0;
138
143
  const live = new Map(); // ref -> current doc_hash
139
144
  for (const d of this.searchDocs())
140
145
  live.set(d.ref, embedHash(d.title, d.body));
@@ -157,6 +162,12 @@ export class HunchStore {
157
162
  const embedded = this.db.prepare(`SELECT count(*) c FROM embeddings WHERE model = ?`).get(model).c;
158
163
  return { embedded, total };
159
164
  }
165
+ /** The SINGLE gate for "can semantic search run right now": an embedder exists and
166
+ * it has at least one stored vector. Used by both hybridSearch and the CLI so the
167
+ * definition can't drift between them. */
168
+ semanticReady(embedder) {
169
+ return !!embedder && this.db.prepare(`SELECT count(*) c FROM embeddings WHERE model = ?`).get(embedder.id).c > 0;
170
+ }
160
171
  /** Generate/refresh embeddings for every doc missing an up-to-date vector for
161
172
  * this embedder's model. Batched + flushed per batch so a Ctrl-C leaves a
162
173
  * coherent partial index that a re-run resumes. Assumes reindex() ran first. */
@@ -170,22 +181,25 @@ export class HunchStore {
170
181
  const todo = docs.filter((d) => current.get(d.ref) !== d.hash);
171
182
  const ins = this.db.prepare(`INSERT OR REPLACE INTO embeddings (ref, kind, model, dim, doc_hash, vec) VALUES (?,?,?,?,?,?)`);
172
183
  const batchSize = opts.batch ?? 32;
173
- let done = 0;
184
+ let embedded = 0; // ACTUAL rows written (a batch may yield fewer vectors than docs)
185
+ let attempted = 0;
174
186
  for (let i = 0; i < todo.length; i += batchSize) {
175
187
  const slice = todo.slice(i, i + batchSize);
176
188
  const vecs = await embedder.embed(slice.map((d) => `${d.title}\n${d.body}`));
177
189
  const tx = this.db.transaction(() => {
178
190
  slice.forEach((d, j) => {
179
191
  const v = vecs[j];
180
- if (v)
192
+ if (v) {
181
193
  ins.run(d.ref, d.kind, model, embedder.dim, d.hash, vecToBlob(v));
194
+ embedded++;
195
+ }
182
196
  });
183
197
  });
184
198
  tx();
185
- done += slice.length;
186
- opts.onProgress?.(done, todo.length);
199
+ attempted += slice.length;
200
+ opts.onProgress?.(attempted, todo.length);
187
201
  }
188
- return { embedded: todo.length, skipped: docs.length - todo.length, total: docs.length };
202
+ return { embedded, skipped: docs.length - todo.length, total: docs.length };
189
203
  }
190
204
  /** Hybrid search (hunch_query / `hunch query --semantic`): FTS bm25 fused with
191
205
  * cosine over stored embeddings via Reciprocal Rank Fusion. Degrades to pure
@@ -194,40 +208,54 @@ export class HunchStore {
194
208
  * `embedder: null` to FORCE FTS-only without auto-selecting. */
195
209
  async hybridSearch(query, limit = 12, opts = {}) {
196
210
  const embedder = opts.embedder !== undefined ? opts.embedder : await selectEmbedder();
197
- if (!embedder)
198
- return this.search(query, limit);
199
- const count = this.db.prepare(`SELECT count(*) c FROM embeddings WHERE model = ?`).get(embedder.id).c;
200
- if (count === 0)
211
+ if (!this.semanticReady(embedder))
201
212
  return this.search(query, limit);
202
213
  const fts = this.search(query, Math.max(limit, 50));
203
- let qvec;
204
214
  try {
205
- [qvec] = await embedder.embed([query]);
215
+ // The whole semantic leg (query embedding + decode + cosine + fuse) is guarded:
216
+ // any failure — model load, a corrupt/dim-mismatched vector — degrades to the
217
+ // lexical results rather than failing the query.
218
+ const [qvec] = await embedder.embed([query]);
219
+ if (!qvec)
220
+ return fts.slice(0, limit);
221
+ const sem = this.cosineRank(qvec, embedder.id, 50);
222
+ return this.rrfFuse(fts, sem, limit);
206
223
  }
207
224
  catch {
208
- return fts.slice(0, limit); // embedding failed at query time → lexical only
209
- }
210
- if (!qvec)
211
225
  return fts.slice(0, limit);
212
- const sem = this.cosineRank(qvec, embedder.id, 50);
213
- return this.rrfFuse(fts, sem, limit);
226
+ }
214
227
  }
215
228
  /** Brute-force exact cosine top-n over stored vectors for one model. Vectors are
216
- * pre-normalized, so cosine == dot product. */
229
+ * pre-normalized, so cosine == dot product. Scoped to `dim = qvec.length` so a
230
+ * row stored at a different dimension (model id reused at a new dim) can never
231
+ * drive an out-of-bounds BLOB read; any with an unexpected byte length are
232
+ * skipped defensively rather than crashing the query. */
217
233
  cosineRank(qvec, model, n) {
218
- const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ?`).all(model);
219
234
  const dim = qvec.length;
220
- const scored = rows.map((r) => {
235
+ const rows = this.db.prepare(`SELECT ref, kind, vec FROM embeddings WHERE model = ? AND dim = ?`).all(model, dim);
236
+ const scored = [];
237
+ for (const r of rows) {
238
+ if (r.vec.byteLength !== dim * 4)
239
+ continue; // corrupt/legacy row — skip, don't read past it
221
240
  const v = blobToVec(r.vec, dim);
222
241
  let dot = 0;
223
242
  for (let i = 0; i < dim; i++)
224
243
  dot += qvec[i] * v[i];
225
- return { ref: r.ref, kind: r.kind, score: dot };
226
- });
244
+ scored.push({ ref: r.ref, kind: r.kind, score: dot });
245
+ }
227
246
  scored.sort((a, b) => b.score - a.score);
228
- return scored.slice(0, n).map((s) => {
229
- const row = this.db.prepare(`SELECT title, body FROM search WHERE ref = ?`).get(s.ref);
230
- return { ref: s.ref, kind: s.kind, title: row?.title ?? s.ref, snippet: (row?.body ?? "").slice(0, 120), score: s.score };
247
+ const top = scored.slice(0, n);
248
+ // Hydrate title/snippet for the top-n in ONE query (not a per-row SELECT).
249
+ const meta = new Map();
250
+ if (top.length) {
251
+ const placeholders = top.map(() => "?").join(",");
252
+ for (const row of this.db.prepare(`SELECT ref, title, body FROM search WHERE ref IN (${placeholders})`).all(...top.map((s) => s.ref))) {
253
+ meta.set(row.ref, { title: row.title, body: row.body });
254
+ }
255
+ }
256
+ return top.map((s) => {
257
+ const m = meta.get(s.ref);
258
+ return { ref: s.ref, kind: s.kind, title: m?.title ?? s.ref, snippet: (m?.body ?? "").slice(0, 120), score: s.score };
231
259
  });
232
260
  }
233
261
  /** Rank-based Reciprocal Rank Fusion of the FTS and semantic lists. Ranks (not
@@ -6,95 +6,96 @@
6
6
  * — we only need them indexed where we query them. Search is a single unified
7
7
  * FTS5 table; the graph is plain tables walked with recursive CTEs.
8
8
  */
9
- import { createHash } from "node:crypto";
9
+ import { shortHash } from "../core/ids.js";
10
10
  /** Canonical content hash of the exact title+body that fed both FTS and the
11
11
  * embedding for a doc. Stored in `embeddings.doc_hash` so reindex can tell, with
12
12
  * NO model loaded, whether a stored vector is stale (its source text changed).
13
- * The NUL separator keeps the title/body boundary unambiguous. */
13
+ * The NUL separator keeps the title/body boundary unambiguous. Reuses the shared
14
+ * sha1-truncate idiom from core/ids so the hashing scheme lives in one place. */
14
15
  export function embedHash(title, body) {
15
- return createHash("sha1").update(title).update("\x00").update(body ?? "").digest("hex").slice(0, 16);
16
+ return shortHash(`${title}\x00${body ?? ""}`, 16);
16
17
  }
17
- export const SCHEMA_SQL = /* sql */ `
18
- PRAGMA journal_mode = WAL;
19
- PRAGMA foreign_keys = OFF;
20
-
21
- CREATE TABLE IF NOT EXISTS components (
22
- id TEXT PRIMARY KEY,
23
- kind TEXT, name TEXT, responsibility TEXT,
24
- paths TEXT, status TEXT, owners TEXT,
25
- fragility REAL,
26
- prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
27
- created_at TEXT, updated_at TEXT
28
- );
29
-
30
- CREATE TABLE IF NOT EXISTS edges (
31
- id TEXT PRIMARY KEY,
32
- "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
33
- prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
34
- );
35
- CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
36
- CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
37
- CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
38
-
39
- CREATE TABLE IF NOT EXISTS symbols (
40
- id TEXT PRIMARY KEY,
41
- file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
42
- calls TEXT, called_by TEXT,
43
- loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
44
- last_changed TEXT
45
- );
46
- CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
47
- CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
48
-
49
- CREATE TABLE IF NOT EXISTS decisions (
50
- id TEXT PRIMARY KEY,
51
- title TEXT, status TEXT, context TEXT, decision TEXT,
52
- consequences TEXT, alternatives_rejected TEXT,
53
- related_components TEXT, related_files TEXT,
54
- supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
55
- prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
56
- date TEXT
57
- );
58
-
59
- CREATE TABLE IF NOT EXISTS bugs (
60
- id TEXT PRIMARY KEY,
61
- title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
62
- affected_files TEXT, affected_symbols TEXT, lineage TEXT,
63
- prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
64
- );
65
-
66
- CREATE TABLE IF NOT EXISTS constraints (
67
- id TEXT PRIMARY KEY,
68
- type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
69
- rationale TEXT, source_decision TEXT, violations TEXT,
70
- prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
71
- );
72
-
73
- -- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
74
- CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
75
- ref UNINDEXED, -- entity id
76
- kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
77
- title,
78
- body,
79
- tokenize = 'porter unicode61'
80
- );
81
-
82
- -- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
83
- -- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
84
- -- runs RESET on nearly every path (MCP startup, every query/context), so resetting
85
- -- embeddings here would wipe them constantly and make the feature a no-op. Staleness
86
- -- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
87
- -- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
88
- CREATE TABLE IF NOT EXISTS embeddings (
89
- ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
90
- PRIMARY KEY (ref, model)
91
- );
18
+ export const SCHEMA_SQL = /* sql */ `
19
+ PRAGMA journal_mode = WAL;
20
+ PRAGMA foreign_keys = OFF;
21
+
22
+ CREATE TABLE IF NOT EXISTS components (
23
+ id TEXT PRIMARY KEY,
24
+ kind TEXT, name TEXT, responsibility TEXT,
25
+ paths TEXT, status TEXT, owners TEXT,
26
+ fragility REAL,
27
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
28
+ created_at TEXT, updated_at TEXT
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS edges (
32
+ id TEXT PRIMARY KEY,
33
+ "from" TEXT, "to" TEXT, type TEXT, reason TEXT, strength REAL,
34
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
35
+ );
36
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges("from");
37
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges("to");
38
+ CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(type);
39
+
40
+ CREATE TABLE IF NOT EXISTS symbols (
41
+ id TEXT PRIMARY KEY,
42
+ file TEXT, name TEXT, kind TEXT, signature_hash TEXT,
43
+ calls TEXT, called_by TEXT,
44
+ loc INTEGER, churn_90d INTEGER, bug_count INTEGER, fan_in INTEGER, fan_out INTEGER,
45
+ last_changed TEXT
46
+ );
47
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file);
48
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
49
+
50
+ CREATE TABLE IF NOT EXISTS decisions (
51
+ id TEXT PRIMARY KEY,
52
+ title TEXT, status TEXT, context TEXT, decision TEXT,
53
+ consequences TEXT, alternatives_rejected TEXT,
54
+ related_components TEXT, related_files TEXT,
55
+ supersedes TEXT, caused_by_bug TEXT, "commit" TEXT,
56
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT,
57
+ date TEXT
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS bugs (
61
+ id TEXT PRIMARY KEY,
62
+ title TEXT, symptom TEXT, root_cause TEXT, severity TEXT, status TEXT,
63
+ affected_files TEXT, affected_symbols TEXT, lineage TEXT,
64
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
65
+ );
66
+
67
+ CREATE TABLE IF NOT EXISTS constraints (
68
+ id TEXT PRIMARY KEY,
69
+ type TEXT, statement TEXT, scope TEXT, severity TEXT, enforcement TEXT,
70
+ rationale TEXT, source_decision TEXT, violations TEXT,
71
+ prov_source TEXT, prov_confidence REAL, prov_evidence TEXT
72
+ );
73
+
74
+ -- Unified full-text search across every entity. Rebuilt on index; bm25-ranked.
75
+ CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
76
+ ref UNINDEXED, -- entity id
77
+ kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
78
+ title,
79
+ body,
80
+ tokenize = 'porter unicode61'
81
+ );
82
+
83
+ -- Local semantic-search vectors (opt-in; written by \`hunch embed\`). One row per
84
+ -- (ref, model); vec is a Float32 BLOB. DELIBERATELY NOT in RESET_SQL: reindex()
85
+ -- runs RESET on nearly every path (MCP startup, every query/context), so resetting
86
+ -- embeddings here would wipe them constantly and make the feature a no-op. Staleness
87
+ -- is tracked by doc_hash and reconciled by pruneStaleEmbeddings() instead. Recall is
88
+ -- exact brute-force cosine in JS (graphs are small); sqlite-vec only past ~100k rows.
89
+ CREATE TABLE IF NOT EXISTS embeddings (
90
+ ref TEXT, kind TEXT, model TEXT, dim INTEGER, doc_hash TEXT, vec BLOB,
91
+ PRIMARY KEY (ref, model)
92
+ );
92
93
  `;
93
94
  /** Drop derived data (used before a full reindex). NOTE: embeddings is omitted on
94
95
  * purpose — see the embeddings table comment above. */
95
- export const RESET_SQL = /* sql */ `
96
- DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
97
- DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
98
- DELETE FROM search;
96
+ export const RESET_SQL = /* sql */ `
97
+ DELETE FROM components; DELETE FROM edges; DELETE FROM symbols;
98
+ DELETE FROM decisions; DELETE FROM bugs; DELETE FROM constraints;
99
+ DELETE FROM search;
99
100
  `;
100
101
  //# sourceMappingURL=schema.js.map
@@ -16,11 +16,86 @@
16
16
  * Every provider returns the same shape so the rest of the system never knows
17
17
  * (or cares) which one ran.
18
18
  */
19
- import { execFile } from "node:child_process";
20
- import { promisify } from "node:util";
19
+ import { spawn } from "node:child_process";
21
20
  import { tmpdir } from "node:os";
22
21
  import { summarizeDiff } from "../extractors/diff.js";
23
- const pexec = promisify(execFile);
22
+ const IS_WIN = process.platform === "win32";
23
+ /**
24
+ * Run a command, optionally feeding `input` to its stdin, and resolve its
25
+ * stdout. Uses spawn (not execFile) so we can:
26
+ * 1. Pass untrusted content (the prompt/diff) via STDIN, never as an argv
27
+ * element — so a `shell:true` resolution can't shell-interpret it.
28
+ * 2. Resolve Windows shims: the npm `claude` is a `.cmd`/`.ps1`, which
29
+ * `execFile` (CreateProcess, *.exe only) cannot launch → it threw ENOENT
30
+ * and made the CLI provider look unavailable on Windows. `shell:true` on
31
+ * win32 routes through cmd.exe so the shim resolves. Safe here because
32
+ * every argv we pass is a trusted, space-free flag (the prompt is stdin).
33
+ */
34
+ export function pexecIn(cmd, args, opts = {}) {
35
+ return new Promise((resolve, reject) => {
36
+ // Windows: launch through cmd.exe so the `claude` .cmd/.ps1 shim resolves,
37
+ // and pass the whole line as ONE shell string (args are trusted, space-free
38
+ // flags) — avoids Node's DEP0190 warning for `args + shell:true`. The prompt
39
+ // is never here; it goes via stdin below. POSIX: no shell, argv as-is.
40
+ const child = IS_WIN
41
+ ? spawn([cmd, ...args].join(" "), {
42
+ shell: true,
43
+ env: opts.env,
44
+ cwd: opts.cwd,
45
+ windowsHide: true,
46
+ })
47
+ : spawn(cmd, args, {
48
+ env: opts.env,
49
+ cwd: opts.cwd,
50
+ windowsHide: true,
51
+ });
52
+ const max = opts.maxBuffer ?? 16 * 1024 * 1024;
53
+ let out = "";
54
+ let err = "";
55
+ let outLen = 0;
56
+ let settled = false;
57
+ const done = (fn) => {
58
+ if (settled)
59
+ return;
60
+ settled = true;
61
+ if (timer)
62
+ clearTimeout(timer);
63
+ fn();
64
+ };
65
+ const timer = opts.timeout
66
+ ? setTimeout(() => {
67
+ child.kill();
68
+ done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
69
+ }, opts.timeout)
70
+ : null;
71
+ child.on("error", (e) => done(() => reject(e)));
72
+ child.stdout.on("data", (d) => {
73
+ outLen += d.length;
74
+ if (outLen > max) {
75
+ child.kill();
76
+ done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
77
+ return;
78
+ }
79
+ out += d.toString();
80
+ });
81
+ child.stderr.on("data", (d) => {
82
+ err += d.toString();
83
+ });
84
+ child.on("close", (code) => {
85
+ done(() => {
86
+ if (code === 0)
87
+ resolve({ stdout: out });
88
+ else
89
+ reject(new Error(`"${cmd}" exited ${code}: ${err.slice(0, 300)}`));
90
+ });
91
+ });
92
+ // Feed stdin (the prompt) then close it; commands with no input just get EOF.
93
+ if (opts.input != null)
94
+ child.stdin.write(opts.input);
95
+ child.stdin.on("error", () => { }); // ignore EPIPE if the child exits early
96
+ child.stdin.end();
97
+ });
98
+ }
24
99
  const SYSTEM = `You are the synthesis engine of an Engineering Memory OS. You turn raw
25
100
  developer activity (a git commit diff, or a test failure) into a single structured
26
101
  "why" record. Be precise and evidence-grounded; never invent facts not supported by
@@ -66,7 +141,7 @@ class ClaudeCliProvider {
66
141
  model = process.env.HUNCH_SYNTH_MODEL || "haiku";
67
142
  async available() {
68
143
  try {
69
- await pexec("claude", ["--version"], { timeout: 8000 });
144
+ await pexecIn("claude", ["--version"], { timeout: 8000 });
70
145
  return true;
71
146
  }
72
147
  catch {
@@ -87,8 +162,11 @@ class ClaudeCliProvider {
87
162
  // repo's own hunch MCP server / CLAUDE.md on every commit (cheaper, and no
88
163
  // risk of the synthesis call recursing through the Hunch). Auth lives in the
89
164
  // user's home config, not cwd, so this doesn't affect subscription billing.
90
- const args = ["-p", prompt, "--output-format", "json", "--model", this.model, "--max-turns", "1"];
91
- const { stdout } = await pexec("claude", args, {
165
+ // Prompt goes via STDIN (-p reads piped stdin), never argv keeps untrusted
166
+ // diff content out of any shell the spawn helper uses on Windows.
167
+ const args = ["-p", "--output-format", "json", "--model", this.model, "--max-turns", "1"];
168
+ const { stdout } = await pexecIn("claude", args, {
169
+ input: prompt,
92
170
  env: childEnv,
93
171
  cwd: tmpdir(),
94
172
  maxBuffer: 16 * 1024 * 1024,
package/package.json CHANGED
@@ -1,68 +1,68 @@
1
- {
2
- "name": "@davesheffer/hunch",
3
- "version": "0.1.0",
4
- "license": "MIT",
5
- "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
- "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
7
- "homepage": "https://hunch.sh",
8
- "repository": {
9
- "type": "git",
10
- "url": "git+https://github.com/davesheffer/hunch.git"
11
- },
12
- "bugs": {
13
- "url": "https://github.com/davesheffer/hunch/issues"
14
- },
15
- "type": "module",
16
- "bin": {
17
- "hunch": "dist/cli/index.js"
18
- },
19
- "files": [
20
- "dist/**/*.js"
21
- ],
22
- "publishConfig": {
23
- "access": "public"
24
- },
25
- "keywords": [
26
- "claude-code",
27
- "mcp",
28
- "engineering-memory",
29
- "knowledge-graph",
30
- "code-intelligence",
31
- "ai",
32
- "developer-tools"
33
- ],
34
- "engines": {
35
- "node": ">=20"
36
- },
37
- "scripts": {
38
- "clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
39
- "build": "npm run clean && tsc -p tsconfig.json",
40
- "dev": "tsx src/cli/index.ts",
41
- "hunch": "tsx src/cli/index.ts",
42
- "test": "tsx --test test/*.test.ts",
43
- "typecheck": "tsc -p tsconfig.json --noEmit",
44
- "prepublishOnly": "npm run build"
45
- },
46
- "dependencies": {
47
- "@modelcontextprotocol/sdk": "^1.29.0",
48
- "better-sqlite3": "12.9.0",
49
- "commander": "^15.0.0",
50
- "tree-sitter": "0.21.1",
51
- "tree-sitter-typescript": "^0.23.2",
52
- "zod": "^4.4.3"
53
- },
54
- "devDependencies": {
55
- "@types/better-sqlite3": "^7.6.13",
56
- "@types/node": "^20.19.0",
57
- "tsx": "^4.22.4",
58
- "typescript": "^5.9.3"
59
- },
60
- "peerDependencies": {
61
- "@huggingface/transformers": ">=3"
62
- },
63
- "peerDependenciesMeta": {
64
- "@huggingface/transformers": {
65
- "optional": true
66
- }
67
- }
68
- }
1
+ {
2
+ "name": "@davesheffer/hunch",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
6
+ "description": "Hunch — an Engineering Memory OS: a persistent, git-native reasoning graph over a codebase, exposed to Claude Code via MCP.",
7
+ "homepage": "https://hunch.sh",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/davesheffer/hunch.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/davesheffer/hunch/issues"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "hunch": "dist/cli/index.js"
18
+ },
19
+ "files": [
20
+ "dist/**/*.js"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "keywords": [
26
+ "claude-code",
27
+ "mcp",
28
+ "engineering-memory",
29
+ "knowledge-graph",
30
+ "code-intelligence",
31
+ "ai",
32
+ "developer-tools"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "scripts": {
38
+ "clean": "node --input-type=commonjs -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
39
+ "build": "npm run clean && tsc -p tsconfig.json",
40
+ "dev": "tsx src/cli/index.ts",
41
+ "hunch": "tsx src/cli/index.ts",
42
+ "test": "tsx --test test/*.test.ts",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "prepublishOnly": "npm run build"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/sdk": "^1.29.0",
48
+ "better-sqlite3": "12.9.0",
49
+ "commander": "^15.0.0",
50
+ "tree-sitter": "0.21.1",
51
+ "tree-sitter-typescript": "^0.23.2",
52
+ "zod": "^4.4.3"
53
+ },
54
+ "devDependencies": {
55
+ "@types/better-sqlite3": "^7.6.13",
56
+ "@types/node": "^20.19.0",
57
+ "tsx": "^4.22.4",
58
+ "typescript": "^5.9.3"
59
+ },
60
+ "peerDependencies": {
61
+ "@huggingface/transformers": ">=3"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@huggingface/transformers": {
65
+ "optional": true
66
+ }
67
+ }
68
+ }