@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.
@@ -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
- const f = this.fileFor(kind, validated.id);
420
- const arr = this.readRawArray(kind, directory, f).filter((r) => r?.id !== validated.id);
421
- arr.push(validated);
422
- arr.sort((a, b) => String(a?.id).localeCompare(String(b?.id)));
423
- this.writeContainedFile(directory, f, encode(arr), this.maxBytes(kind));
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 deleting
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
- for (const name of existing) {
456
- this.removeContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
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
- const f = this.fileFor(kind, "index");
495
- const arr = this.readRawArray(kind, directory, f);
496
- if (!this.validateExistingFile(directory, f, this.maxBytes(kind)))
497
- return false;
498
- const next = arr.filter((r) => r?.id !== id);
499
- if (next.length === arr.length)
500
- return false;
501
- this.writeContainedFile(directory, f, encode(next), this.maxBytes(kind));
502
- this.invalidate(kind);
503
- return true;
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);
@@ -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
- child.kill();
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
- child.kill();
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.9.4",
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.",