@davesheffer/hunch 1.9.4 → 1.10.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 +65 -9
- package/dist/core/drift.js +30 -2
- package/dist/core/format.js +6 -0
- package/dist/core/ids.js +6 -0
- package/dist/core/io.js +64 -9
- package/dist/core/types.js +25 -1
- package/dist/extractors/git.js +106 -31
- package/dist/extractors/nativeTreeSitter.js +6 -1
- package/dist/extractors/testreport.js +7 -1
- package/dist/integrations/claudemd.js +11 -5
- package/dist/integrations/gitignore.js +2 -0
- package/dist/integrations/providers.js +16 -6
- package/dist/integrations/scaffold.js +34 -5
- package/dist/integrations/team.js +1 -0
- package/dist/mcp/roots.js +19 -3
- package/dist/mcp/server.js +109 -9
- package/dist/store/compact.js +6 -0
- package/dist/store/hunchStore.js +38 -3
- package/dist/store/jsonStore.js +111 -19
- package/dist/store/privateMigrate.js +12 -0
- package/dist/store/schema.js +1 -1
- package/dist/synthesis/provider.js +29 -3
- package/package.json +1 -1
package/dist/store/jsonStore.js
CHANGED
|
@@ -14,6 +14,9 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
14
14
|
* constraints) are one file per record so they're cleanly reviewable in PRs. */
|
|
15
15
|
const SINGLE_FILE = { symbols: "index.json", edges: "index.json" };
|
|
16
16
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
17
|
+
// Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
|
|
18
|
+
// same idiom as core/io.ts's rename backoff.
|
|
19
|
+
const RMW_LOCK_WAITER = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
|
|
17
20
|
/** Curated entities are intentionally small, human-reviewable records. Symbols
|
|
18
21
|
* and edges are dense indexes, so they get a much larger but still finite cap. */
|
|
19
22
|
export const MAX_JSON_RECORD_BYTES = 8 * 1024 * 1024;
|
|
@@ -389,6 +392,14 @@ export class JsonStore {
|
|
|
389
392
|
const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
390
393
|
if (text === null)
|
|
391
394
|
continue;
|
|
395
|
+
// A 0-byte per-record file is a merge-driver TOMBSTONE, not a record:
|
|
396
|
+
// git cannot delete through a merge driver, so "both sides deleted" is
|
|
397
|
+
// materialized as an empty %A (issue #37). Human-approved refinement of
|
|
398
|
+
// con_947c578b2c's boundary (2026-08-04): an empty file holds no record
|
|
399
|
+
// to migrate or drop, and Hunch's own atomic writes (con_902759b3dc)
|
|
400
|
+
// never produce one — emptiness is unambiguous, so no warning.
|
|
401
|
+
if (text.trim() === "")
|
|
402
|
+
continue;
|
|
392
403
|
raw = JSON.parse(text);
|
|
393
404
|
}
|
|
394
405
|
catch (e) {
|
|
@@ -403,6 +414,49 @@ export class JsonStore {
|
|
|
403
414
|
}
|
|
404
415
|
return out;
|
|
405
416
|
}
|
|
417
|
+
/** Cross-process mutex for single-file index read-modify-write (issue #35).
|
|
418
|
+
* The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
|
|
419
|
+
* two unsynchronized RMWs over index.json each read the same base array and the
|
|
420
|
+
* second rename silently erases the first's record. `mkdirSync` is the atomic
|
|
421
|
+
* acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
|
|
422
|
+
* against a live contender we wait briefly and then proceed WITH a warning —
|
|
423
|
+
* never worse than the historical lockless behavior, and capture paths must not
|
|
424
|
+
* start throwing on lock contention. */
|
|
425
|
+
withSingleFileLock(kind, directory, fn) {
|
|
426
|
+
const lock = join(directory.lexical, ".rmw-lock");
|
|
427
|
+
const deadline = Date.now() + 2_000;
|
|
428
|
+
for (;;) {
|
|
429
|
+
try {
|
|
430
|
+
mkdirSync(lock);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
try {
|
|
435
|
+
if (Date.now() - lstatSync(lock).mtimeMs > 10_000) {
|
|
436
|
+
rmSync(lock, { recursive: true, force: true }); // no live spawn holds a lock this old
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
continue; /* vanished between attempts — retry the acquire */
|
|
442
|
+
}
|
|
443
|
+
if (Date.now() >= deadline) {
|
|
444
|
+
console.warn(`[hunch] proceeding without the ${kind} index lock (still held: ${lock})`);
|
|
445
|
+
return fn();
|
|
446
|
+
}
|
|
447
|
+
Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
return fn();
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
try {
|
|
455
|
+
rmSync(lock, { recursive: true, force: true });
|
|
456
|
+
}
|
|
457
|
+
catch { /* stale-takeover reclaims it */ }
|
|
458
|
+
}
|
|
459
|
+
}
|
|
406
460
|
/** Write a single record (validated) to its JSON file / into the index array. */
|
|
407
461
|
put(kind, record) {
|
|
408
462
|
const schema = SCHEMAS[kind];
|
|
@@ -416,11 +470,14 @@ export class JsonStore {
|
|
|
416
470
|
// record can't silently drop schema-invalid / future-schema siblings — the
|
|
417
471
|
// same reason delete() reads raw. Keep the index sorted by id (stable diff,
|
|
418
472
|
// and agrees with the merge driver so a re-index after a merge is a no-op).
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
473
|
+
// Locked: the read-modify-write below is what issue #35 races.
|
|
474
|
+
this.withSingleFileLock(kind, directory, () => {
|
|
475
|
+
const f = this.fileFor(kind, validated.id);
|
|
476
|
+
const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
|
|
477
|
+
arr.push(validated);
|
|
478
|
+
arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
|
|
479
|
+
this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
|
|
480
|
+
});
|
|
424
481
|
}
|
|
425
482
|
else {
|
|
426
483
|
this.writeContainedFile(directory, this.fileFor(kind, validated.id), encode(validated), this.maxBytes(kind));
|
|
@@ -446,19 +503,27 @@ export class JsonStore {
|
|
|
446
503
|
this.writeContainedFile(directory, this.fileFor(kind, "index"), encode(validated), this.maxBytes(kind));
|
|
447
504
|
return;
|
|
448
505
|
}
|
|
449
|
-
// One file per record: preflight EVERY existing JSON file before
|
|
506
|
+
// One file per record: preflight EVERY existing JSON file before touching
|
|
450
507
|
// any, so one malicious symlink cannot cause a partially-cleared store.
|
|
451
508
|
const existing = this.jsonFileNames(kind);
|
|
452
509
|
for (const name of existing) {
|
|
453
510
|
this.validateExistingFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
454
511
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
512
|
+
// WRITE-FIRST, delete-stale-LAST (issue #30). The old delete-all-then-rewrite
|
|
513
|
+
// sequence had a crash window in which the kind directory held nothing — and
|
|
514
|
+
// `hunch private --migrate` runs replaceAll on the OVERLAY, so that window
|
|
515
|
+
// covered private-only records existing nowhere else. Now a crash mid-write
|
|
516
|
+
// leaves old ∪ new (same id → same file, so no duplicates), and a crash
|
|
517
|
+
// mid-delete leaves only stale extras — no state loses records.
|
|
518
|
+
const keep = new Set(validated.map((r) => `${r.id}.json`));
|
|
458
519
|
for (const r of validated) {
|
|
459
520
|
const id = r.id;
|
|
460
521
|
this.writeContainedFile(directory, this.fileFor(kind, id), encode(r), this.maxBytes(kind));
|
|
461
522
|
}
|
|
523
|
+
for (const name of existing) {
|
|
524
|
+
if (!keep.has(name))
|
|
525
|
+
this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
526
|
+
}
|
|
462
527
|
}
|
|
463
528
|
/** Read a single-file index as a raw array (no validation). Missing/empty → [].
|
|
464
529
|
* A non-empty file that fails to parse THROWS — we must never silently treat a
|
|
@@ -482,6 +547,29 @@ export class JsonStore {
|
|
|
482
547
|
get(kind, id) {
|
|
483
548
|
return this.loadAll(kind).find((r) => r.id === id);
|
|
484
549
|
}
|
|
550
|
+
/** On-disk record count, independent of validation: per-record kinds count
|
|
551
|
+
* every non-tombstone .json file (a 0-byte merge tombstone is an intentional
|
|
552
|
+
* absence), single-file kinds count raw array entries (a corrupt index file
|
|
553
|
+
* throws readRawArray's own actionable refusal). Lets a caller about to
|
|
554
|
+
* DELETE the kind — `hunch private --migrate` — prove the validating loader
|
|
555
|
+
* dropped nothing first, instead of silently destroying the records loadAll
|
|
556
|
+
* skipped (issue #29, the same never-silently-drop contract as
|
|
557
|
+
* con_947c578b2c). */
|
|
558
|
+
rawRecordCount(kind) {
|
|
559
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
560
|
+
if (!directory)
|
|
561
|
+
return 0;
|
|
562
|
+
const single = SINGLE_FILE[kind];
|
|
563
|
+
if (single)
|
|
564
|
+
return this.readRawArray(kind, directory, join(directory.lexical, single)).length;
|
|
565
|
+
let count = 0;
|
|
566
|
+
for (const name of this.jsonFileNames(kind)) {
|
|
567
|
+
const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
|
|
568
|
+
if (text !== null && text.trim() !== "")
|
|
569
|
+
count++;
|
|
570
|
+
}
|
|
571
|
+
return count;
|
|
572
|
+
}
|
|
485
573
|
/** Remove a record (used by the curate/reject flow). Returns true if removed.
|
|
486
574
|
* For single-file kinds we operate on the RAW JSON array (not the validating
|
|
487
575
|
* loader) so deleting one record can't silently drop schema-invalid siblings. */
|
|
@@ -491,16 +579,20 @@ export class JsonStore {
|
|
|
491
579
|
const directory = this.safeKindDirectory(kind, false);
|
|
492
580
|
if (!directory)
|
|
493
581
|
return false;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
582
|
+
// Locked like put(): an unsynchronized delete racing a concurrent put
|
|
583
|
+
// over the same index would resurrect or drop records (issue #35).
|
|
584
|
+
return this.withSingleFileLock(kind, directory, () => {
|
|
585
|
+
const f = this.fileFor(kind, "index");
|
|
586
|
+
const arr = this.readRawArray(kind, directory, f);
|
|
587
|
+
if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
|
|
588
|
+
return false;
|
|
589
|
+
const next = arr.filter((r) => r?.id !== id);
|
|
590
|
+
if (next.length === arr.length)
|
|
591
|
+
return false;
|
|
592
|
+
this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
|
|
593
|
+
this.invalidate(kind);
|
|
594
|
+
return true;
|
|
595
|
+
});
|
|
504
596
|
}
|
|
505
597
|
this.assertSafeRecordId(id);
|
|
506
598
|
const directory = this.safeKindDirectory(kind, false);
|
|
@@ -21,6 +21,18 @@ export function movePublicMemoryToPrivate(pub, priv) {
|
|
|
21
21
|
let total = 0;
|
|
22
22
|
for (const kind of ENTITY_KINDS) {
|
|
23
23
|
const pubRecs = pub.loadAll(kind);
|
|
24
|
+
// The validating loader SKIPS corrupt/invalid/future-schema records with a
|
|
25
|
+
// warning — but the CLI empties the public store right after this returns,
|
|
26
|
+
// which would silently DELETE exactly those skipped records (and then
|
|
27
|
+
// untrack + gitignore their only git history). Refuse instead: prove every
|
|
28
|
+
// on-disk record actually loaded before anything becomes deletable
|
|
29
|
+
// (issue #29). A kind whose only records are invalid also stops here, so
|
|
30
|
+
// the "0 loaded → skip merge" path below can never precede a wipe.
|
|
31
|
+
const rawCount = pub.rawRecordCount(kind);
|
|
32
|
+
if (rawCount !== pubRecs.length) {
|
|
33
|
+
throw new Error(`refusing to migrate ${kind}: ${rawCount - pubRecs.length} on-disk record(s) failed to load (see the warnings above) `
|
|
34
|
+
+ `and would be deleted without ever reaching the overlay. Fix or remove them, then re-run \`hunch private --migrate\`.`);
|
|
35
|
+
}
|
|
24
36
|
if (pubRecs.length === 0)
|
|
25
37
|
continue;
|
|
26
38
|
const privRecs = priv.loadAll(kind);
|
package/dist/store/schema.js
CHANGED
|
@@ -89,7 +89,7 @@ CREATE TABLE IF NOT EXISTS embeddings (
|
|
|
89
89
|
export const FTS_SEARCH_SCHEMA_SQL = /* sql */ `
|
|
90
90
|
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
|
|
91
91
|
ref UNINDEXED, -- entity id
|
|
92
|
-
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints
|
|
92
|
+
kind UNINDEXED, -- components | edges | symbols | decisions | bugs | constraints | runbooks | findings
|
|
93
93
|
title,
|
|
94
94
|
body,
|
|
95
95
|
tokenize = 'porter unicode61'
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* Every provider returns the same shape so the rest of the system never knows
|
|
25
25
|
* (or cares) which one ran.
|
|
26
26
|
*/
|
|
27
|
-
import { spawn } from "node:child_process";
|
|
27
|
+
import { execFile, spawn } from "node:child_process";
|
|
28
28
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
29
29
|
import { isIP } from "node:net";
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
@@ -66,6 +66,20 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
66
66
|
let err = "";
|
|
67
67
|
let outLen = 0;
|
|
68
68
|
let settled = false;
|
|
69
|
+
// Windows spawns through a cmd.exe wrapper (shell:true), and child.kill()
|
|
70
|
+
// terminates only that wrapper — the actual agent CLI survives as an orphan,
|
|
71
|
+
// still burning the user's subscription after every timeout, accumulating
|
|
72
|
+
// with each post-commit hook fire (issue #44). taskkill /T fells the tree.
|
|
73
|
+
// Best-effort by design: the wrapper kill below still runs either way.
|
|
74
|
+
const killTree = () => {
|
|
75
|
+
if (IS_WIN && child.pid) {
|
|
76
|
+
try {
|
|
77
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true }, () => { });
|
|
78
|
+
}
|
|
79
|
+
catch { /* taskkill unavailable — fall through to the wrapper kill */ }
|
|
80
|
+
}
|
|
81
|
+
child.kill();
|
|
82
|
+
};
|
|
69
83
|
const done = (fn) => {
|
|
70
84
|
if (settled)
|
|
71
85
|
return;
|
|
@@ -76,7 +90,7 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
76
90
|
};
|
|
77
91
|
const timer = opts.timeout
|
|
78
92
|
? setTimeout(() => {
|
|
79
|
-
|
|
93
|
+
killTree();
|
|
80
94
|
done(() => reject(new Error(`"${cmd}" timed out after ${opts.timeout}ms`)));
|
|
81
95
|
}, opts.timeout)
|
|
82
96
|
: null;
|
|
@@ -84,7 +98,7 @@ export function pexecIn(cmd, args, opts = {}) {
|
|
|
84
98
|
child.stdout.on("data", (d) => {
|
|
85
99
|
outLen += d.length;
|
|
86
100
|
if (outLen > max) {
|
|
87
|
-
|
|
101
|
+
killTree();
|
|
88
102
|
done(() => reject(new Error(`"${cmd}" exceeded maxBuffer (${max} bytes)`)));
|
|
89
103
|
return;
|
|
90
104
|
}
|
|
@@ -323,6 +337,18 @@ class ClaudeCliProvider extends PromptSynthProvider {
|
|
|
323
337
|
const childEnv = { ...process.env };
|
|
324
338
|
delete childEnv.ANTHROPIC_API_KEY;
|
|
325
339
|
delete childEnv.ANTHROPIC_AUTH_TOKEN;
|
|
340
|
+
// Gateway ROUTING is metered per-token exactly like a raw API key: with
|
|
341
|
+
// CLAUDE_CODE_USE_BEDROCK/VERTEX set (common in enterprise shell profiles
|
|
342
|
+
// for interactive use), headless `claude -p` bills AWS/GCP on every
|
|
343
|
+
// significant commit — silently, from a post-commit hook (issue #39,
|
|
344
|
+
// con_2ce3f2a547). Strip the routing switches and their endpoint overrides
|
|
345
|
+
// so the CLI falls through to subscription auth here too.
|
|
346
|
+
delete childEnv.CLAUDE_CODE_USE_BEDROCK;
|
|
347
|
+
delete childEnv.CLAUDE_CODE_USE_VERTEX;
|
|
348
|
+
delete childEnv.ANTHROPIC_BEDROCK_BASE_URL;
|
|
349
|
+
delete childEnv.ANTHROPIC_VERTEX_BASE_URL;
|
|
350
|
+
delete childEnv.ANTHROPIC_VERTEX_PROJECT_ID;
|
|
351
|
+
delete childEnv.CLOUD_ML_REGION;
|
|
326
352
|
// Single-shot text synthesis: no tools, no agentic loop. The prompt carries
|
|
327
353
|
// all needed context inline, so run from a neutral cwd to avoid loading this
|
|
328
354
|
// repo's own hunch MCP server / CLAUDE.md on every commit (cheaper, and no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
6
6
|
"description": "Engineering memory and a deterministic Change Gate for AI-assisted codebases: decisions, rejected approaches, constraints, and bug lineage become portable context and opt-in enforcement for every MCP assistant.",
|