@davesheffer/hunch 0.1.0 → 0.4.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 +36 -2
- package/dist/cli/index.js +163 -29
- package/dist/core/ids.js +1 -1
- package/dist/extractors/testreport.js +87 -0
- package/dist/store/embedder.js +18 -18
- package/dist/store/hunchStore.js +72 -24
- package/dist/store/schema.js +83 -82
- package/dist/synthesis/provider.js +84 -6
- package/dist/synthesis/synthesize.js +37 -0
- package/package.json +68 -68
package/README.md
CHANGED
|
@@ -110,13 +110,14 @@ The MCP tools Claude calls under the hood: `hunch_why`, `hunch_query`,
|
|
|
110
110
|
| `hunch backfill --since 90d` | replay git history → seed decisions |
|
|
111
111
|
| `hunch sync [sha]` | turn a commit into a Decision (run automatically by the hook) |
|
|
112
112
|
| `hunch record-bug --test <id> --message <m>` | capture a Bug from a failing test |
|
|
113
|
+
| `hunch test [cmd…]` | run the suite (default `npm test`); auto-capture failures as Bugs (suspects + recurrence→Constraints), mark passing tests' bugs fixed |
|
|
113
114
|
| `hunch why <path\|symbol>` | decisions / bugs / constraints explaining a target (flags `⚠STALE`) |
|
|
114
115
|
| `hunch query "<q>" [--semantic]` | full-text + graph search (`--semantic` blends in local embeddings) |
|
|
115
116
|
| `hunch embed` | generate local embeddings for semantic recall (opt-in; needs `@huggingface/transformers`) |
|
|
116
117
|
| `hunch context <path\|symbol>` | minimal relevant slice for a task: invariants → decisions → bugs → blast radius |
|
|
117
118
|
| `hunch fragile` | ranked fragility report with evidence |
|
|
118
|
-
| `hunch check [--staged\|--commit <sha>] [--strict]` | guardrail: flag changes touching a do-not-break invariant |
|
|
119
|
-
| `hunch stale` | drift: records whose files changed after
|
|
119
|
+
| `hunch check [--staged\|--commit <sha>] [--strict] [--blast]` | guardrail: flag changes touching a do-not-break invariant **directly or via blast radius** (a guarded file that depends on what you changed); `--blast` prints the dependency fan-out |
|
|
120
|
+
| `hunch stale [--resync]` | drift: records whose files changed after last verification (`--resync` regenerates stale decisions from their commits) |
|
|
120
121
|
| `hunch review [--accept <id>\|--reject <id>]` | curate: triage / promote / drop low-confidence drafts |
|
|
121
122
|
| `hunch migrate` | upgrade `.hunch/` records to the current schema version |
|
|
122
123
|
| `hunch compact [--apply]` | prune low-value drafts to bound growth (dry-run by default) |
|
|
@@ -135,6 +136,12 @@ hunch embed # embed your records (first run
|
|
|
135
136
|
hunch query --semantic "auth token expiry" # hybrid keyword + semantic recall
|
|
136
137
|
```
|
|
137
138
|
|
|
139
|
+
> **Install it where `hunch` runs.** The runtime is resolved from `hunch`'s own
|
|
140
|
+
> `node_modules`, so match the install scope: a globally-installed `hunch` needs the
|
|
141
|
+
> global (`-g`) install above; running from a source checkout needs it in the repo
|
|
142
|
+
> (`npm i @huggingface/transformers` there). If `hunch embed` reports the model "present
|
|
143
|
+
> but failed to load," the scopes don't match. `hunch doctor` shows the active mode.
|
|
144
|
+
|
|
138
145
|
Embeddings are **local and free** (no API — consistent with the subscription-only synthesis
|
|
139
146
|
rule) and **opt-in** (the base install stays lean). The long-lived MCP server picks them up
|
|
140
147
|
automatically once present. Vectors live in the derived SQLite index and are reconciled by
|
|
@@ -160,6 +167,33 @@ concurrent edits to the graph merge **by record id** instead of throwing conflic
|
|
|
160
167
|
committed `.gitattributes`; the per-clone driver definition is set up by each teammate's
|
|
161
168
|
`hunch init`.
|
|
162
169
|
|
|
170
|
+
## Continuous learning (CI)
|
|
171
|
+
|
|
172
|
+
The decision half of the loop is automatic (the post-commit hook). Light up the **bug /
|
|
173
|
+
constraint half** by wrapping your test run with `hunch test`:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
hunch test # runs `npm test`; capture failures → Bugs, resolve fixed ones
|
|
177
|
+
hunch test -- pytest -q # any runner: pass the command after `--`
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
It parses TAP and the `node:test` spec reporter, captures each failing test as a **Bug**
|
|
181
|
+
(ranked suspects; a recurrence or substantiated high-severity failure auto-promotes a
|
|
182
|
+
do-not-break **Constraint**), and marks a previously-open bug **fixed** once its test passes
|
|
183
|
+
again. It preserves the runner's exit code, so it's a drop-in CI step:
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
# .github/workflows/ci.yml
|
|
187
|
+
- run: npm ci
|
|
188
|
+
- run: npx hunch test # exits non-zero on failure, just like the suite
|
|
189
|
+
- run: | # persist what was learned (optional)
|
|
190
|
+
git add .hunch && git commit -m "chore(hunch): capture test run" || true
|
|
191
|
+
git push || true
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Repair drift after refactors with **`hunch stale --resync`** (re-synthesizes stale decisions
|
|
195
|
+
from their commits via the LLM).
|
|
196
|
+
|
|
163
197
|
## Maintenance
|
|
164
198
|
|
|
165
199
|
- **`hunch doctor`** — is git healthy? are you on the subscription path or the offline
|
package/dist/cli/index.js
CHANGED
|
@@ -9,17 +9,19 @@
|
|
|
9
9
|
* why decisions/bugs/constraints explaining a file/symbol
|
|
10
10
|
* fragile ranked fragility report with evidence
|
|
11
11
|
* record-bug capture a Bug from a (failing) test
|
|
12
|
+
* test run the suite, auto-capture failures as Bugs, resolve fixed ones
|
|
12
13
|
* mcp start the MCP server (Claude Code connects here)
|
|
13
14
|
* doctor environment diagnostics
|
|
14
15
|
*/
|
|
15
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
18
|
import { Command } from "commander";
|
|
18
19
|
import { hunchPaths, findRoot } from "../core/paths.js";
|
|
19
20
|
import { HunchStore } from "../store/hunchStore.js";
|
|
20
21
|
import { selectEmbedder } from "../store/embedder.js";
|
|
21
22
|
import { indexRepo } from "../extractors/indexer.js";
|
|
22
|
-
import { syncCommit, recordFailure } from "../synthesis/synthesize.js";
|
|
23
|
+
import { syncCommit, recordFailure, captureTestRun } from "../synthesis/synthesize.js";
|
|
24
|
+
import { parseTestReport } from "../extractors/testreport.js";
|
|
23
25
|
import { selectProvider } from "../synthesis/provider.js";
|
|
24
26
|
import { isGitRepo, headSha, logSince, lastChangeDate, stagedFiles, commitFiles } from "../extractors/git.js";
|
|
25
27
|
import { installPostCommitHook, installPreCommitHook } from "../integrations/hooks.js";
|
|
@@ -177,11 +179,11 @@ program
|
|
|
177
179
|
let hits;
|
|
178
180
|
let how = "";
|
|
179
181
|
if (opts.semantic) {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
+
// Same gate hybridSearch uses internally (store.semanticReady), so the flag's
|
|
183
|
+
// messaging can't drift from what actually runs. If unusable, say so and use FTS
|
|
184
|
+
// rather than silently returning identical keyword results under the flag.
|
|
182
185
|
const emb = await selectEmbedder();
|
|
183
|
-
|
|
184
|
-
if (!emb || !cov || cov.embedded === 0) {
|
|
186
|
+
if (!store.semanticReady(emb)) {
|
|
185
187
|
console.log("· semantic search isn't enabled yet — run `hunch embed` (using keyword search for now).\n");
|
|
186
188
|
hits = store.search(q, 12);
|
|
187
189
|
}
|
|
@@ -228,13 +230,25 @@ program
|
|
|
228
230
|
return;
|
|
229
231
|
}
|
|
230
232
|
process.stdout.write(`Embedding ${todo} doc(s) with ${embedder.id} (first run downloads the model ~90MB, one time)…\n`);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
233
|
+
try {
|
|
234
|
+
const res = await store.embedAll(embedder, {
|
|
235
|
+
batch: Number(opts.batch),
|
|
236
|
+
onProgress: (done, total) => process.stdout.write(`\r ${done}/${total} embedded `),
|
|
237
|
+
});
|
|
238
|
+
process.stdout.write("\n");
|
|
239
|
+
console.log(`✓ embedded ${res.embedded} doc(s) (${res.skipped} already current). Model: ${embedder.id}.`);
|
|
240
|
+
console.log(` Try: hunch query --semantic "<question>" — the MCP server uses it automatically.`);
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
// The availability probe (createRequire.resolve) can succeed while the
|
|
244
|
+
// runtime ESM import/model load fails — e.g. the package is only
|
|
245
|
+
// resolvable via NODE_PATH, or a native backend is missing. Degrade with
|
|
246
|
+
// an actionable hint instead of a stack trace; queries stay on keyword search.
|
|
247
|
+
process.stdout.write("\n");
|
|
248
|
+
console.log("Semantic model is present but failed to load — staying on keyword (FTS) search.");
|
|
249
|
+
console.log(` reason: ${e.message.split("\n")[0]}`);
|
|
250
|
+
console.log(" Install @huggingface/transformers where hunch actually runs (a global `hunch` needs a global install; running from source needs it in the repo's node_modules).");
|
|
251
|
+
}
|
|
238
252
|
store.close();
|
|
239
253
|
});
|
|
240
254
|
// ---- why ------------------------------------------------------------------
|
|
@@ -306,24 +320,108 @@ program
|
|
|
306
320
|
console.log(` ↳ promoted constraint ${r.constraint.id} [${r.constraint.severity}]: ${r.constraint.statement}`);
|
|
307
321
|
store.close();
|
|
308
322
|
});
|
|
323
|
+
// ---- test (failure-learning loop) -----------------------------------------
|
|
324
|
+
program
|
|
325
|
+
.command("test")
|
|
326
|
+
.description("Run the test suite; capture failures as Bugs (suspects + recurrence → Constraints), mark passing tests' bugs fixed.")
|
|
327
|
+
.argument("[cmd...]", "test command to run (default: `npm test`)")
|
|
328
|
+
.option("--dry-run", "show what would be captured without writing")
|
|
329
|
+
.action(async (cmd, opts) => {
|
|
330
|
+
const { store, root } = storeFor();
|
|
331
|
+
store.json.ensureDirs();
|
|
332
|
+
// Run as a shell string (not argv) so the npm/test-runner shim resolves on
|
|
333
|
+
// Windows and avoids Node's DEP0190 args+shell warning — same lesson as the
|
|
334
|
+
// claude CLI fix. The command is operator-supplied, so a shell is expected.
|
|
335
|
+
const cmdStr = cmd.length ? cmd.join(" ") : "npm test";
|
|
336
|
+
console.log(`▶ running: ${cmdStr}\n`);
|
|
337
|
+
const run = spawnSync(cmdStr, {
|
|
338
|
+
cwd: root,
|
|
339
|
+
shell: true,
|
|
340
|
+
encoding: "utf8",
|
|
341
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
342
|
+
windowsHide: true,
|
|
343
|
+
});
|
|
344
|
+
const output = `${run.stdout ?? ""}\n${run.stderr ?? ""}`;
|
|
345
|
+
const report = parseTestReport(output);
|
|
346
|
+
if (opts.dryRun) {
|
|
347
|
+
const willFallback = !report.recognized && run.status !== 0;
|
|
348
|
+
const n = willFallback ? 1 : report.failures.length;
|
|
349
|
+
console.log(`DRY RUN — exit ${run.status}, ${report.passed.length} passed, ${n} failure(s)${willFallback ? " (fallback: output not TAP/spec)" : ""}:`);
|
|
350
|
+
for (const f of report.failures)
|
|
351
|
+
console.log(` ✗ ${f.test}`);
|
|
352
|
+
if (willFallback)
|
|
353
|
+
console.log(` ✗ ${cmdStr} (whole-suite)`);
|
|
354
|
+
store.close();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const cap = await captureTestRun(store, root, { report, status: run.status, cmd: cmdStr, output });
|
|
358
|
+
for (const { bug, constraint } of cap.results) {
|
|
359
|
+
if (constraint)
|
|
360
|
+
console.log(` ⚠ ${bug.id} "${bug.title}" → promoted constraint ${constraint.id} [${constraint.severity}]`);
|
|
361
|
+
else
|
|
362
|
+
console.log(` ✗ ${bug.id} "${bug.title}" [${bug.severity}]${bug.lineage.recurrence_of ? ` ↳ recurrence of ${bug.lineage.recurrence_of}` : ""}`);
|
|
363
|
+
}
|
|
364
|
+
for (const b of cap.fixed)
|
|
365
|
+
console.log(` ✓ ${b.id} "${b.title}" → fixed (test passing)`);
|
|
366
|
+
store.reindex();
|
|
367
|
+
store.close();
|
|
368
|
+
const recurrences = cap.results.filter((r) => r.bug.lineage.recurrence_of).length;
|
|
369
|
+
const promoted = cap.results.filter((r) => r.constraint).length;
|
|
370
|
+
console.log(`\n${run.status === 0 ? "✓ suite passed" : "✗ suite failed"} — ` +
|
|
371
|
+
`${cap.results.length} bug(s) captured (${recurrences} recurrence, ${promoted} constraint), ${cap.fixed.length} resolved.`);
|
|
372
|
+
if (run.status !== 0)
|
|
373
|
+
process.exitCode = 1; // preserve CI semantics
|
|
374
|
+
});
|
|
309
375
|
// ---- stale (drift detection) ----------------------------------------------
|
|
310
376
|
program
|
|
311
377
|
.command("stale")
|
|
312
378
|
.description("List decisions/constraints whose files changed after they were last verified (drift).")
|
|
313
|
-
.
|
|
379
|
+
.option("--resync", "re-synthesize stale decisions from their commit via the LLM (drift repair)")
|
|
380
|
+
.action(async (opts) => {
|
|
314
381
|
const { store, root } = storeFor();
|
|
315
382
|
const stale = store.staleness((f) => lastChangeDate(f, root));
|
|
316
383
|
if (!stale.length) {
|
|
317
384
|
console.log("✓ No drift detected — every verified decision/constraint is current.");
|
|
385
|
+
store.close();
|
|
386
|
+
return;
|
|
318
387
|
}
|
|
319
|
-
|
|
388
|
+
if (!opts.resync) {
|
|
320
389
|
console.log(`⚠ ${stale.length} record(s) may be stale (a file in scope changed after last verification):\n`);
|
|
321
390
|
for (const s of stale) {
|
|
322
391
|
console.log(` ${s.kind} ${s.id}\n verified ${s.last_verified.slice(0, 10)} · changed ${s.changed_at.slice(0, 10)} · ${s.files.join(", ")}`);
|
|
323
392
|
}
|
|
324
|
-
console.log(`\
|
|
393
|
+
console.log(`\nRepair: hunch stale --resync (re-synthesize from commits) · or hunch review --accept <id>`);
|
|
394
|
+
store.close();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// --resync: regenerate each stale DECISION from its commit. Constraints have no
|
|
398
|
+
// commit to replay, so they're reported as needing manual review instead.
|
|
399
|
+
let resynced = 0, skipped = 0;
|
|
400
|
+
for (const s of stale) {
|
|
401
|
+
if (!s.kind.startsWith("decision")) {
|
|
402
|
+
skipped++;
|
|
403
|
+
console.log(` · ${s.kind} ${s.id} — manual (no commit to replay)`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const d = store.json.get("decisions", s.id);
|
|
407
|
+
if (!d?.commit) {
|
|
408
|
+
skipped++;
|
|
409
|
+
console.log(` · ${s.id} — skipped (no source commit)`);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const r = await syncCommit(store, root, d.commit, { force: true });
|
|
413
|
+
if (r.status === "written") {
|
|
414
|
+
resynced++;
|
|
415
|
+
console.log(` ↻ ${s.id} ← ${d.commit.slice(0, 8)} (${r.provider})`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
skipped++;
|
|
419
|
+
console.log(` · ${s.id} — skipped: ${r.reason}`);
|
|
420
|
+
}
|
|
325
421
|
}
|
|
422
|
+
store.reindex();
|
|
326
423
|
store.close();
|
|
424
|
+
console.log(`\n✓ re-synthesized ${resynced} stale decision(s), ${skipped} left for manual review.`);
|
|
327
425
|
});
|
|
328
426
|
// ---- check (constraint enforcement) ---------------------------------------
|
|
329
427
|
program
|
|
@@ -331,40 +429,76 @@ program
|
|
|
331
429
|
.description("Flag changes that touch a do-not-break invariant's scope (guardrail).")
|
|
332
430
|
.option("--staged", "check git staged files (default)")
|
|
333
431
|
.option("--commit <sha>", "check a specific commit's files")
|
|
334
|
-
.option("--strict", "exit non-zero if a blocking constraint is in scope")
|
|
432
|
+
.option("--strict", "exit non-zero if a blocking constraint is in scope (direct OR near)")
|
|
433
|
+
.option("--blast", "also print the dependency blast radius of the changed files")
|
|
335
434
|
.action((opts) => {
|
|
336
435
|
if (opts.commit && opts.staged)
|
|
337
436
|
return fail("--staged and --commit are mutually exclusive");
|
|
338
437
|
const { store, root } = storeFor();
|
|
438
|
+
store.reindex(); // blast radius walks the edge graph — make the index current
|
|
339
439
|
const files = opts.commit ? commitFiles(opts.commit, root) : stagedFiles(root);
|
|
340
440
|
if (!files.length) {
|
|
341
441
|
console.log("No changed files to check.");
|
|
342
442
|
store.close();
|
|
343
443
|
return;
|
|
344
444
|
}
|
|
345
|
-
const
|
|
445
|
+
const mark = (s) => (s === "blocking" ? "⛔" : s === "warning" ? "⚠" : "·");
|
|
446
|
+
// 1) DIRECT — a changed file matches a constraint's scope.
|
|
447
|
+
const direct = new Map();
|
|
346
448
|
for (const f of files) {
|
|
347
449
|
for (const c of store.checkConstraints(f)) {
|
|
348
|
-
const e =
|
|
450
|
+
const e = direct.get(c.id) ?? { c, files: [] };
|
|
349
451
|
e.files.push(f);
|
|
350
|
-
|
|
452
|
+
direct.set(c.id, e);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// 2) NEAR — a changed file's blast radius reaches a file an invariant guards:
|
|
456
|
+
// you didn't touch the invariant, but you touched something it depends on.
|
|
457
|
+
const near = new Map();
|
|
458
|
+
for (const f of files) {
|
|
459
|
+
for (const b of store.blastRadiusFiles(f)) {
|
|
460
|
+
for (const c of store.checkConstraints(b.file)) {
|
|
461
|
+
if (direct.has(c.id))
|
|
462
|
+
continue; // already reported as a direct hit
|
|
463
|
+
const e = near.get(c.id) ?? { c, via: [] };
|
|
464
|
+
e.via.push(`${f} → ${b.file} (${b.via}, depth ${b.depth})`);
|
|
465
|
+
near.set(c.id, e);
|
|
466
|
+
}
|
|
351
467
|
}
|
|
352
468
|
}
|
|
353
|
-
if (
|
|
354
|
-
console.log(
|
|
469
|
+
if (opts.blast) {
|
|
470
|
+
console.log(`Blast radius of ${files.length} changed file(s):`);
|
|
471
|
+
for (const f of files) {
|
|
472
|
+
const b = store.blastRadiusFiles(f);
|
|
473
|
+
const list = b.length ? `: ${b.slice(0, 8).map((x) => x.file).join(", ")}${b.length > 8 ? " …" : ""}` : "";
|
|
474
|
+
console.log(` ${f} → ${b.length} dependent file(s)${list}`);
|
|
475
|
+
}
|
|
476
|
+
console.log("");
|
|
477
|
+
}
|
|
478
|
+
if (!direct.size && !near.size) {
|
|
479
|
+
console.log(`✓ ${files.length} changed file(s) touch no recorded invariants (directly or via blast radius).`);
|
|
355
480
|
store.close();
|
|
356
481
|
return;
|
|
357
482
|
}
|
|
358
483
|
let blocking = 0;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
blocking
|
|
363
|
-
|
|
364
|
-
|
|
484
|
+
if (direct.size) {
|
|
485
|
+
console.log(`Directly touches ${direct.size} invariant(s):\n`);
|
|
486
|
+
for (const { c, files: fs } of direct.values()) {
|
|
487
|
+
if (c.severity === "blocking")
|
|
488
|
+
blocking++;
|
|
489
|
+
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id} · in: ${fs.join(", ")}\n rationale: ${c.rationale || "—"}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (near.size) {
|
|
493
|
+
console.log(`${direct.size ? "\n" : ""}Near ${near.size} invariant(s) via blast radius (a guarded dependency changed — review):\n`);
|
|
494
|
+
for (const { c, via } of near.values()) {
|
|
495
|
+
if (c.severity === "blocking")
|
|
496
|
+
blocking++;
|
|
497
|
+
console.log(` ${mark(c.severity)} [${c.severity}] ${c.statement}\n ${c.id}\n ${via.slice(0, 4).join("\n ")}${via.length > 4 ? `\n …+${via.length - 4} more path(s)` : ""}`);
|
|
498
|
+
}
|
|
365
499
|
}
|
|
366
500
|
if (opts.strict && blocking) {
|
|
367
|
-
console.log(`\n✗ ${blocking} blocking invariant(s) in scope — review before committing.`);
|
|
501
|
+
console.log(`\n✗ ${blocking} blocking invariant(s) in scope (direct or near) — review before committing.`);
|
|
368
502
|
process.exitCode = 1;
|
|
369
503
|
}
|
|
370
504
|
else {
|
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.). */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a test run's output into pass/fail signals for the failure-learning loop
|
|
3
|
+
* (`hunch test`). Test-framework-agnostic: it recognizes both common shapes that
|
|
4
|
+
* `node:test` (this repo), mocha, ava, vitest, jest, and `prove` emit:
|
|
5
|
+
*
|
|
6
|
+
* - TAP: `ok 12 - name` / `not ok 3 - name`, with an optional indented YAML
|
|
7
|
+
* diagnostic block (error + stack) under a failure.
|
|
8
|
+
* - spec: node:test's default non-TTY reporter — `✔ name (1.2ms)` /
|
|
9
|
+
* `✖ name (1.2ms)`, failures followed by an indented error block.
|
|
10
|
+
* (Also accepts ✓/✗.)
|
|
11
|
+
*
|
|
12
|
+
* Design choices:
|
|
13
|
+
* - A failure's `message` is the test name plus its indented diagnostic block —
|
|
14
|
+
* exactly the context recordFailure() feeds the synthesizer for root-cause and
|
|
15
|
+
* suspect ranking.
|
|
16
|
+
* - If NEITHER shape is recognized, we return empty lists + recognized=false so
|
|
17
|
+
* the caller falls back to a coarse "the suite failed" bug from the raw tail —
|
|
18
|
+
* never silently reports success.
|
|
19
|
+
* - Results are deduped by name (a spec reporter can echo a failing test in its
|
|
20
|
+
* end-of-run recap).
|
|
21
|
+
*/
|
|
22
|
+
// TAP: `ok 12 - desc` / `not ok 3 - desc` (the "- " separator is optional).
|
|
23
|
+
const TAP = /^(not ok|ok)\s+\d+\s*-?\s*(.*)$/;
|
|
24
|
+
// spec: `✔ desc (1.2ms)` (pass) or `✖ desc (1.2ms)` (fail). ✓/✗ accepted too.
|
|
25
|
+
const SPEC = /^([✔✓✖✗])\s+(.*)$/;
|
|
26
|
+
const DURATION = /\s+\(\d+(?:\.\d+)?ms\)\s*$/; // trailing " (1.2ms)" spec suffix
|
|
27
|
+
const DIRECTIVE = /#\s*(SKIP|TODO)\b/i;
|
|
28
|
+
/** Parse TAP-or-spec text. Pure + synchronous so it's trivially unit-testable. */
|
|
29
|
+
export function parseTestReport(output) {
|
|
30
|
+
const lines = output.split(/\r?\n/);
|
|
31
|
+
const failMap = new Map();
|
|
32
|
+
const passSet = new Set();
|
|
33
|
+
let recognized = false;
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
const raw = lines[i];
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
const tap = TAP.exec(trimmed);
|
|
38
|
+
const spec = tap ? null : SPEC.exec(trimmed);
|
|
39
|
+
if (!tap && !spec)
|
|
40
|
+
continue;
|
|
41
|
+
recognized = true;
|
|
42
|
+
const isFail = tap ? tap[1] === "not ok" : (spec[1] === "✖" || spec[1] === "✗");
|
|
43
|
+
let name = (tap ? tap[2] : spec[2]) ?? "";
|
|
44
|
+
name = name.replace(DURATION, "").trim();
|
|
45
|
+
if (!name)
|
|
46
|
+
name = `test #${i + 1}`;
|
|
47
|
+
if (DIRECTIVE.test(name))
|
|
48
|
+
continue; // skipped/todo — neither pass nor fail
|
|
49
|
+
name = stripDirective(name);
|
|
50
|
+
if (!isFail) {
|
|
51
|
+
passSet.add(name);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (failMap.has(name))
|
|
55
|
+
continue; // dedupe recap echoes
|
|
56
|
+
// Collect the following more-indented diagnostic block as the message.
|
|
57
|
+
const baseIndent = leadingSpaces(raw);
|
|
58
|
+
const block = [];
|
|
59
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
60
|
+
const ln = lines[j];
|
|
61
|
+
if (ln.trim() === "") {
|
|
62
|
+
block.push("");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (leadingSpaces(ln) <= baseIndent)
|
|
66
|
+
break;
|
|
67
|
+
block.push(ln.slice(baseIndent + 1));
|
|
68
|
+
}
|
|
69
|
+
const diag = block.join("\n").trim();
|
|
70
|
+
failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
|
|
71
|
+
}
|
|
72
|
+
// A test can legitimately appear as both (flaky retry) — trust the failure.
|
|
73
|
+
for (const name of failMap.keys())
|
|
74
|
+
passSet.delete(name);
|
|
75
|
+
return { failures: [...failMap.values()], passed: [...passSet], recognized };
|
|
76
|
+
}
|
|
77
|
+
function leadingSpaces(s) {
|
|
78
|
+
let n = 0;
|
|
79
|
+
while (n < s.length && s[n] === " ")
|
|
80
|
+
n++;
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
function stripDirective(name) {
|
|
84
|
+
const hash = name.indexOf("#");
|
|
85
|
+
return (hash === -1 ? name : name.slice(0, hash)).trim();
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=testreport.js.map
|
package/dist/store/embedder.js
CHANGED
|
@@ -32,36 +32,36 @@ 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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
51
|
+
// Pin dtype to fp32 (the lib's default for this model): silences the
|
|
52
|
+
// "dtype not specified … using the default dtype (fp32)" line the lib
|
|
53
|
+
// prints to stderr on every load (it leaked into `hunch query` output),
|
|
54
|
+
// and guarantees the numbers stay consistent with vectors already
|
|
55
|
+
// persisted under this model id — changing dtype would shift them.
|
|
56
|
+
return (await mod.pipeline("feature-extraction", HF_MODEL, { dtype: "fp32" }));
|
|
57
|
+
})();
|
|
58
|
+
this.extractor = p;
|
|
59
|
+
// Never cache a REJECTED load: a transient failure (network blip during the
|
|
60
|
+
// first model download, a missing/incompatible native backend) must not poison
|
|
61
|
+
// the embedder for the rest of a long-lived (MCP) process. Reset so the next
|
|
62
|
+
// call retries from scratch.
|
|
63
|
+
p.catch(() => { if (this.extractor === p)
|
|
64
|
+
this.extractor = null; });
|
|
65
65
|
}
|
|
66
66
|
return this.extractor;
|
|
67
67
|
}
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
186
|
-
opts.onProgress?.(
|
|
199
|
+
attempted += slice.length;
|
|
200
|
+
opts.onProgress?.(attempted, todo.length);
|
|
187
201
|
}
|
|
188
|
-
return { embedded
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
226
|
-
}
|
|
244
|
+
scored.push({ ref: r.ref, kind: r.kind, score: dot });
|
|
245
|
+
}
|
|
227
246
|
scored.sort((a, b) => b.score - a.score);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
@@ -309,6 +337,26 @@ export class HunchStore {
|
|
|
309
337
|
}
|
|
310
338
|
return id;
|
|
311
339
|
}
|
|
340
|
+
/** Files whose symbols (in)directly DEPEND ON a symbol defined in `file` — the
|
|
341
|
+
* blast radius of editing `file`, collapsed to file granularity (nearest depth
|
|
342
|
+
* wins per file). Powers `hunch check` near-violation detection and `--blast`. */
|
|
343
|
+
blastRadiusFiles(file, maxDepth = 4) {
|
|
344
|
+
const syms = this.db.prepare(`SELECT id FROM symbols WHERE file = ?`).all(file);
|
|
345
|
+
const out = new Map();
|
|
346
|
+
for (const s of syms) {
|
|
347
|
+
for (const dep of this.getDependents(s.id, maxDepth)) {
|
|
348
|
+
if (!dep.id.startsWith("sym_"))
|
|
349
|
+
continue;
|
|
350
|
+
const r = this.db.prepare(`SELECT name, file FROM symbols WHERE id=?`).get(dep.id);
|
|
351
|
+
if (!r || r.file === file)
|
|
352
|
+
continue; // ignore self-file dependents
|
|
353
|
+
const prev = out.get(r.file);
|
|
354
|
+
if (!prev || dep.depth < prev.depth)
|
|
355
|
+
out.set(r.file, { file: r.file, via: r.name, depth: dep.depth });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return [...out.values()].sort((a, b) => a.depth - b.depth || a.file.localeCompare(b.file));
|
|
359
|
+
}
|
|
312
360
|
/** Constraints whose scope glob matches a path/glob (hunch_check_constraints). */
|
|
313
361
|
checkConstraints(scope) {
|
|
314
362
|
const all = this.json.loadAll("constraints");
|
package/dist/store/schema.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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,
|
|
@@ -165,6 +165,43 @@ export async function recordFailure(store, root, failure) {
|
|
|
165
165
|
raiseFragility(store, affectedFiles);
|
|
166
166
|
return { status: "written", bug, constraint, provider: provider.name };
|
|
167
167
|
}
|
|
168
|
+
/** Orchestrate one `hunch test` run into graph writes: capture each failing test
|
|
169
|
+
* as a Bug (recordFailure → suspects / recurrence / Constraint promotion), and
|
|
170
|
+
* resolve any open Bug whose test now passes. Kept free of console I/O so the
|
|
171
|
+
* whole capture→resolve→promote loop is unit-testable. Does NOT reindex — the
|
|
172
|
+
* caller does, once. `status` is the runner's exit code (null if unknown). */
|
|
173
|
+
export async function captureTestRun(store, root, input) {
|
|
174
|
+
const { report, status } = input;
|
|
175
|
+
// Unrecognized output that still failed → one coarse bug from the tail, rather
|
|
176
|
+
// than silently reporting success (the worst failure mode for a learning loop).
|
|
177
|
+
let failures = report.failures;
|
|
178
|
+
let fallback = false;
|
|
179
|
+
if (!report.recognized && status !== 0) {
|
|
180
|
+
const tail = input.output.trim().split(/\r?\n/).slice(-40).join("\n");
|
|
181
|
+
failures = [{ test: input.cmd, message: `Test run failed (exit ${status}); output not TAP/spec.\n${tail}` }];
|
|
182
|
+
fallback = true;
|
|
183
|
+
}
|
|
184
|
+
const results = [];
|
|
185
|
+
for (const f of failures) {
|
|
186
|
+
const r = await recordFailure(store, root, f);
|
|
187
|
+
results.push({ bug: r.bug, constraint: r.constraint });
|
|
188
|
+
}
|
|
189
|
+
let sha = null;
|
|
190
|
+
try {
|
|
191
|
+
sha = headSha(root);
|
|
192
|
+
}
|
|
193
|
+
catch { /* not a git repo / no HEAD — leave null */ }
|
|
194
|
+
const fixed = [];
|
|
195
|
+
for (const name of report.passed) {
|
|
196
|
+
const b = store.json.get("bugs", bugId(name));
|
|
197
|
+
if (b && b.status === "open") {
|
|
198
|
+
const resolved = { ...b, status: "fixed", lineage: { ...b.lineage, fixed_commit: sha } };
|
|
199
|
+
store.json.put("bugs", resolved);
|
|
200
|
+
fixed.push(resolved);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { results, fixed, fallback };
|
|
204
|
+
}
|
|
168
205
|
/** Whether a bug should auto-promote a regression Constraint (a do-not-break
|
|
169
206
|
* invariant). A recurrence always does. Otherwise it must be high/critical AND
|
|
170
207
|
* substantiated by a real root cause — a bare severity label with no analysis
|
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "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.4.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
|
+
}
|