@davesheffer/hunch 1.10.0 → 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 CHANGED
@@ -222,12 +222,26 @@ program
222
222
  if (opts.autoCommit === false) {
223
223
  const localFile = join(paths.hunch, "local.json");
224
224
  let existing = {};
225
- try {
226
- existing = JSON.parse(readFileSync(localFile, "utf8"));
225
+ if (existsSync(localFile)) {
226
+ // Same contract as every other writer of this file (issue #40): an
227
+ // unparseable or non-object local.json may hold the private-overlay
228
+ // pointer — rewriting it from a swallowed parse failure silently
229
+ // re-routes private captures into the committed public store.
230
+ try {
231
+ const parsed = JSON.parse(readFileSync(localFile, "utf8"));
232
+ existing = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
233
+ }
234
+ catch {
235
+ existing = null;
236
+ }
237
+ }
238
+ if (existing === null) {
239
+ console.warn(` ⚠ refusing to rewrite ${localFile}: it is not a JSON object and may hold your private-overlay pointer. Fix or remove it, then re-run \`hunch init --no-auto-commit\`.`);
240
+ }
241
+ else {
242
+ writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
243
+ console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
227
244
  }
228
- catch { /* absent/invalid → fresh */ }
229
- writeFileAtomic(localFile, JSON.stringify({ ...existing, autoCommit: false }, null, 2) + "\n");
230
- console.log(" ✓ auto-commit OFF (captures stay uncommitted; commit .hunch/ yourself)");
231
245
  }
232
246
  if (isGitRepo(root)) {
233
247
  const syncToOverlay = !!(opts.privateSync || opts.sharedSync);
@@ -259,7 +273,10 @@ program
259
273
  console.log(` ⚠ skipped .mcp.json: ${e.message}`);
260
274
  }
261
275
  const cmds = writeSlashCommands(root);
262
- console.log(` ✓ wrote ${cmds.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile)`);
276
+ console.log(` ✓ wrote ${cmds.written.length} slash commands (/hunch-why, /hunch-fix, /hunch-fragile, /capture, /heal, /audit)`);
277
+ for (const s of cmds.skipped) {
278
+ console.log(` ⚠ kept your existing ${rel(root, s)} (no hunch:generated marker — delete the file and re-run init to adopt Hunch's version)`);
279
+ }
263
280
  const cmd = updateClaudeMd(root, store);
264
281
  console.log(` ✓ updated ${rel(root, cmd)} with ambient Hunch context`);
265
282
  // Firmness: stamp .hunch/config.json (default advisory) so `hunch hook` reads a
@@ -374,7 +391,11 @@ program
374
391
  // commit is drafted — not per-commit, and not under --deep (an ensemble may
375
392
  // fan out to several distinct workers, each with its own configuration).
376
393
  if (!opts.deep && commits.length > 0) {
377
- const ctxProvider = await selectProvider();
394
+ // { root }: the advisory must resolve the SAME provider the synthesis
395
+ // inside syncCommit resolves (persisted local.json preference included) —
396
+ // rootless resolution could grade a different provider and swallow the
397
+ // num_ctx warning for an actual Ollama run (issue #46).
398
+ const ctxProvider = await selectProvider({ root });
378
399
  const ctxWarning = await maybeWarnOllamaContext(ctxProvider.name, process.env);
379
400
  if (ctxWarning)
380
401
  console.log(ctxWarning);
@@ -3354,6 +3375,13 @@ const vetoCmd = program
3354
3375
  store.close();
3355
3376
  return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
3356
3377
  }
3378
+ // Same guard for --commit: an unfetched/mistyped sha would enumerate zero
3379
+ // files and exit 0 — a vacuous pass where CI expects the Decision Guard
3380
+ // to have actually looked (issue #45).
3381
+ if (opts.commit && !revExists(opts.commit, root)) {
3382
+ store.close();
3383
+ return fail(`--commit sha "${opts.commit}" does not resolve. In CI, ensure the commit is fetched (git fetch --depth=... or fetch-depth: 0).`);
3384
+ }
3357
3385
  store.reindex();
3358
3386
  const files = opts.commit ? commitFiles(opts.commit, root)
3359
3387
  : opts.base ? rangeFiles(opts.base, root)
@@ -3593,7 +3621,7 @@ program
3593
3621
  firm: "surfaces + warns on a violating edit",
3594
3622
  strict: "edit-time DENY + CI guard — the teeth are on",
3595
3623
  };
3596
- console.log(`\nHunch — enforcement status (${root.split("/").pop()})\n`);
3624
+ console.log(`\nHunch — enforcement status (${basename(root)})\n`);
3597
3625
  console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
3598
3626
  console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
3599
3627
  if (blocking.length) {
@@ -13,7 +13,7 @@
13
13
  * `hunch wiki --heal`, never a gate.
14
14
  */
15
15
  import { existsSync, readFileSync } from "node:fs";
16
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
16
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
17
17
  import { toPosixTarget } from "./paths.js";
18
18
  import { currentForTopic, isLive } from "./topics.js";
19
19
  import { parseDocAnchors } from "./docanchors.js";
@@ -163,7 +163,10 @@ function referenceExists(store, root, decisionId, ref) {
163
163
  // A private-scoped reference is an overlay-repo-relative path, not an escape
164
164
  // hatch into arbitrary local files.
165
165
  const rel = relative(privateRoot, candidate);
166
- if (rel === "" || rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\\\" : "/"}`) || isAbsolute(rel))
166
+ // NOTE: sep, not an escaped literal `"\\\\"` in a template is the TWO-char
167
+ // string `\\`, which `relative()` never produces, silently disabling the
168
+ // containment check on Windows (issue #31).
169
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
167
170
  return false;
168
171
  return existsSync(candidate);
169
172
  }
package/dist/core/io.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Durable file writes for the Hunch. */
2
- import { linkSync, writeFileSync, renameSync, rmSync } from "node:fs";
2
+ import { closeSync, fsyncSync, linkSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
3
+ import { dirname } from "node:path";
3
4
  let counter = 0;
4
5
  const renameRetryDelaysMs = [10, 20, 40, 80];
5
6
  const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
@@ -7,6 +8,13 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
7
8
  * Write `data` to `file` via a temp file + rename, so an interrupted write can't
8
9
  * leave the target truncated (the symbols/edges index is the worst to half-write).
9
10
  *
11
+ * Durability (issue #34): the temp file is fsync'd BEFORE the rename, and the
12
+ * parent directory best-effort after it. A process kill was always safe (page
13
+ * cache preserves ordering), but on power loss / OS crash the rename's metadata
14
+ * could reach disk before the temp file's data blocks — leaving the target
15
+ * present but truncated or garbage, the exact state the atomic-write invariant
16
+ * (con_902759b3dc) exists to prevent.
17
+ *
10
18
  * Windows caveat: renameSync can't REPLACE a file another process holds open (even
11
19
  * for read) — it throws EPERM/EBUSY/EACCES, exactly when the MCP server is reading
12
20
  * while a CLI writes. Retry that atomic replacement with bounded backoff. If the
@@ -17,19 +25,42 @@ const renameRetryWaiter = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_
17
25
  export function writeFileAtomic(file, data) {
18
26
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
19
27
  try {
20
- writeFileSync(tmp, data);
28
+ const fd = openSync(tmp, "w");
29
+ try {
30
+ writeSync(fd, data);
31
+ fsyncSync(fd); // data blocks reach disk before the rename's metadata can
32
+ }
33
+ finally {
34
+ closeSync(fd);
35
+ }
21
36
  }
22
37
  catch (e) {
23
- safeRm(tmp);
38
+ cleanupTmp(tmp);
24
39
  throw e;
25
40
  }
26
41
  try {
27
42
  renameWithContentionRetry(tmp, file);
28
43
  }
29
44
  catch (e) {
30
- safeRm(tmp);
45
+ cleanupTmp(tmp);
31
46
  throw e;
32
47
  }
48
+ fsyncDirBestEffort(dirname(file));
49
+ }
50
+ /** Persist the rename itself (the directory entry). POSIX semantics; Windows
51
+ * cannot open directories for fsync, so this is a silent no-op there — NTFS
52
+ * journals the metadata on its own schedule. */
53
+ function fsyncDirBestEffort(dir) {
54
+ try {
55
+ const fd = openSync(dir, "r");
56
+ try {
57
+ fsyncSync(fd);
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ catch { /* platform without directory fsync — best effort by contract */ }
33
64
  }
34
65
  function renameWithContentionRetry(from, to) {
35
66
  for (let attempt = 0;; attempt++) {
@@ -55,7 +86,7 @@ function isRenameContention(error) {
55
86
  export function writeFileAtomicIfAbsent(file, data) {
56
87
  const tmp = `${file}.tmp${process.pid}.${counter++}`;
57
88
  try {
58
- writeFileSync(tmp, data);
89
+ writeFileAtomicTmp(tmp, data);
59
90
  linkSync(tmp, file);
60
91
  return true;
61
92
  }
@@ -65,15 +96,39 @@ export function writeFileAtomicIfAbsent(file, data) {
65
96
  throw error;
66
97
  }
67
98
  finally {
68
- safeRm(tmp);
99
+ // Not silently best-effort (issue #38): after linkSync succeeds the target
100
+ // shares the temp inode, so a swallowed unlink failure (a Windows AV scanner
101
+ // or indexer briefly holding tmp) leaves the published file with nlink=2 —
102
+ // which validateExistingFile rejects as hard-linked — plus a stray .tmp.
103
+ cleanupTmp(tmp);
69
104
  }
70
105
  }
71
- function safeRm(p) {
106
+ /** Write + fsync a fresh temp file (shared by both atomic writers). */
107
+ function writeFileAtomicTmp(tmp, data) {
108
+ const fd = openSync(tmp, "w");
109
+ try {
110
+ writeSync(fd, data);
111
+ fsyncSync(fd);
112
+ }
113
+ finally {
114
+ closeSync(fd);
115
+ }
116
+ }
117
+ /** Remove a temp file, riding out a transient external hold (AV/indexer) with one
118
+ * short retry; a persistent failure is REPORTED, never swallowed — a leaked tmp
119
+ * beside a hard-link-published target keeps that target at nlink=2. */
120
+ function cleanupTmp(p) {
72
121
  try {
73
122
  rmSync(p, { force: true });
123
+ return;
74
124
  }
75
- catch {
76
- /* best effort */
125
+ catch { /* transient hold — retry once below */ }
126
+ Atomics.wait(renameRetryWaiter, 0, 0, 50);
127
+ try {
128
+ rmSync(p, { force: true });
129
+ }
130
+ catch (e) {
131
+ console.warn(`[hunch] temp file left behind (its published target keeps nlink=2 until it is removed): ${p} (${e.message})`);
77
132
  }
78
133
  }
79
134
  //# sourceMappingURL=io.js.map
@@ -166,6 +166,39 @@ function canonicalPath(path) {
166
166
  export function gitNullDevice() {
167
167
  return process.platform === "win32" ? "NUL" : devNull;
168
168
  }
169
+ /** How long a live gitindex.lock can plausibly be held: every Hunch git spawn
170
+ * carries a timeout well under this, so an OLDER lock provably has no living
171
+ * owner in any Hunch flow. */
172
+ const STALE_INDEX_LOCK_MS = 30_000;
173
+ /** Heal a stranded `.git/index.lock` (issue #53). Two ways one appears:
174
+ * (a) THIS call's git was timeout-killed — TerminateProcess on Windows skips
175
+ * git's cleanup, so a lock created at/after this attempt started is ours;
176
+ * (b) a PREVIOUS run crashed/was killed — git then fails FAST forever after,
177
+ * and the best-effort flush paths swallow it, so captures keep "succeeding"
178
+ * while nothing commits. A pre-existing lock older than any live git's
179
+ * possible hold time has no living owner and is safe to remove.
180
+ * Returns true when a lock was removed (a retry is then sensible). */
181
+ function clearStrandedIndexLock(repoDir, env, sinceMs, error) {
182
+ const killed = error?.code === "ETIMEDOUT" || !!error?.signal;
183
+ try {
184
+ const rel = execFileSync("git", ["-C", repoDir, "rev-parse", "--git-path", "index.lock"], {
185
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env, timeout: 5_000,
186
+ }).trim();
187
+ const lockPath = isAbsolute(rel) ? rel : join(repoDir, rel);
188
+ const mtimeMs = statSync(lockPath).mtimeMs;
189
+ const stranded = killed
190
+ ? mtimeMs >= sinceMs // created by the git we just killed
191
+ : mtimeMs <= Date.now() - STALE_INDEX_LOCK_MS; // left behind long before this attempt
192
+ if (!stranded)
193
+ return false;
194
+ rmSync(lockPath, { force: true });
195
+ console.error(`hunch: removed a stranded index.lock at "${repoDir}" (${killed ? "this git operation timed out" : "left by an earlier interrupted git"}); retrying.`);
196
+ return true;
197
+ }
198
+ catch {
199
+ return false; // no lock present, or git itself unavailable — nothing to heal
200
+ }
201
+ }
169
202
  /** Compare physical directory identity before path text. Git for Windows can
170
203
  * return an 8.3/short or differently-cased spelling for the same top-level
171
204
  * directory that Node reached through its long path. A nonzero file ID keeps
@@ -515,13 +548,21 @@ export function commitAndPushHunch(hunchDir, message, opts) {
515
548
  GIT_ATTR_NOSYSTEM: "1",
516
549
  });
517
550
  const run = (args) => {
518
- try {
519
- execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
520
- return true;
521
- }
522
- catch {
523
- return false; // best-effort: nothing staged / not a repo / offline
551
+ for (let attempt = 0; attempt < 2; attempt++) {
552
+ const startedAt = Date.now();
553
+ try {
554
+ execFileSync("git", ["-C", hunchDir, ...args], { stdio: "ignore", env });
555
+ return true;
556
+ }
557
+ catch (error) {
558
+ // best-effort: nothing staged / not a repo / offline — EXCEPT a
559
+ // stranded index.lock, which would otherwise fail every future
560
+ // flush silently (issue #53); heal it and retry once.
561
+ if (!clearStrandedIndexLock(hunchDir, env, startedAt, error))
562
+ return false;
563
+ }
524
564
  }
565
+ return false;
525
566
  };
526
567
  if (opts.push !== false) {
527
568
  if (!overlayAttributeSourcesAreSafe(hunchDir, env)) {
@@ -598,18 +639,29 @@ export function commitAndPushHunch(hunchDir, message, opts) {
598
639
  if (!hooksDir)
599
640
  return null;
600
641
  const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
601
- try {
602
- execFileSync("git", [
603
- "-C", hunchDir,
604
- "-c", `core.hooksPath=${hooksDir}`,
605
- ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
606
- "-c", "core.autocrlf=false",
607
- "-c", "commit.gpgsign=false",
608
- "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
609
- ], { stdio: "ignore", env, timeout: 15_000 });
610
- committed = true;
642
+ // One retry after healing a stranded index.lock (issue #53): a lock left by
643
+ // a timeout-killed or crashed git otherwise fails EVERY later flush fast and
644
+ // silently — captures keep reporting success while nothing commits.
645
+ for (let attempt = 0; attempt < 2 && !committed; attempt++) {
646
+ const commitStartedAt = Date.now();
647
+ try {
648
+ execFileSync("git", [
649
+ "-C", hunchDir,
650
+ "-c", `core.hooksPath=${hooksDir}`,
651
+ ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
652
+ "-c", "core.autocrlf=false",
653
+ "-c", "commit.gpgsign=false",
654
+ "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
655
+ ], { stdio: "ignore", env, timeout: 15_000 });
656
+ committed = true;
657
+ }
658
+ catch (error) {
659
+ // Nothing staged / not a repo stays quiet, as before; only a healed
660
+ // stranded lock earns the single retry.
661
+ if (!clearStrandedIndexLock(hunchDir, env, commitStartedAt, error))
662
+ break;
663
+ }
611
664
  }
612
- catch { /* nothing staged / not a repo */ }
613
665
  if (!committed)
614
666
  return null;
615
667
  if (opts.push !== false) {
@@ -1123,6 +1175,7 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1123
1175
  if (!hooksDir)
1124
1176
  return "failed";
1125
1177
  const tryGit = (args, timeout = timeoutMs) => {
1178
+ const startedAt = Date.now();
1126
1179
  try {
1127
1180
  execFileSync("git", [
1128
1181
  "-C", hunchDir,
@@ -1136,7 +1189,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1136
1189
  });
1137
1190
  return true;
1138
1191
  }
1139
- catch {
1192
+ catch (error) {
1193
+ // Same stranding class as the commit path: a timeout-killed merge/fetch
1194
+ // leaves index.lock behind and wedges every later sync (issue #53).
1195
+ clearStrandedIndexLock(hunchDir, env, startedAt, error);
1140
1196
  return false;
1141
1197
  }
1142
1198
  };
@@ -1522,6 +1578,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
1522
1578
  Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1523
1579
  attempt = acquireCommitLock(lock);
1524
1580
  }
1581
+ // The deadline can expire DURING the final wait+acquire; without this check an
1582
+ // acquire that succeeded on that last iteration returned false while this
1583
+ // process's owner directory held the lock — never released (the caller bails
1584
+ // before its try/finally), wedging every flush in every process until this one
1585
+ // exited (issue #48).
1586
+ if (attempt.state === "acquired")
1587
+ return true;
1525
1588
  return false;
1526
1589
  }
1527
1590
  export function headSha(cwd) {
@@ -1595,7 +1658,7 @@ export function currentBranch(cwd) {
1595
1658
  /** Files changed in a single commit. `--root` makes the initial commit (which
1596
1659
  * has no parent) report its files as additions instead of returning nothing. */
1597
1660
  export function commitFiles(sha, cwd) {
1598
- const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
1661
+ const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
1599
1662
  return out ? out.split("\n").filter(Boolean) : [];
1600
1663
  }
1601
1664
  /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
@@ -2011,7 +2074,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2011
2074
  return out;
2012
2075
  // churn — one windowed log; tally each wanted path's appearances (= commits).
2013
2076
  if (days > 0) {
2014
- const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
2077
+ const raw = gitSafe(["-c", "core.quotePath=false", "log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
2015
2078
  if (raw) {
2016
2079
  for (const line of raw.split("\n")) {
2017
2080
  const e = line && out.get(line);
@@ -2023,7 +2086,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2023
2086
  // last commit — one newest-first log; the FIRST time a path appears is its most
2024
2087
  // recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
2025
2088
  // 256MB buffer for the all-history name-only stream on large repos.
2026
- const raw = gitSafe(["log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
2089
+ const raw = gitSafe(["-c", "core.quotePath=false", "log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
2027
2090
  if (raw) {
2028
2091
  let remaining = out.size;
2029
2092
  let sha = "";
@@ -2044,17 +2107,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
2044
2107
  }
2045
2108
  return out;
2046
2109
  }
2047
- /** Files staged for commit (for `hunch check` pre-commit enforcement). */
2110
+ /** Files staged for commit (for `hunch check` pre-commit enforcement).
2111
+ *
2112
+ * Every path enumerator here pins `core.quotePath=false` (issue #50): with
2113
+ * git's default quotePath, any path holding bytes > 0x7F comes back
2114
+ * octal-quoted (`"src/caf\303\251.ts"`), which matches neither the store's
2115
+ * POSIX paths nor constraint scope globs — a blocking constraint over such a
2116
+ * file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
2048
2117
  export function stagedFiles(cwd) {
2049
- const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
2118
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
2050
2119
  return out ? out.split("\n").filter(Boolean) : [];
2051
2120
  }
2052
2121
  /** Files changed anywhere in the working tree compared with HEAD: both staged
2053
2122
  * and unstaged tracked files, plus untracked files. This powers the local,
2054
2123
  * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
2055
2124
  export function workingFiles(cwd) {
2056
- const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2057
- const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
2125
+ const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2126
+ const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
2058
2127
  return [...new Set([...changed, ...untracked])].sort();
2059
2128
  }
2060
2129
  /** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
@@ -2066,7 +2135,7 @@ export function revExists(ref, cwd) {
2066
2135
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
2067
2136
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
2068
2137
  export function rangeFiles(base, cwd, head = "HEAD") {
2069
- const out = gitSafe(["diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
2138
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
2070
2139
  return out ? out.split("\n").filter(Boolean) : [];
2071
2140
  }
2072
2141
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
@@ -2095,8 +2164,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
2095
2164
  * intentionally contribute no synthetic content to regression analysis. */
2096
2165
  export function workingDiff(cwd, maxBytes = 60_000) {
2097
2166
  let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2098
- const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2099
- const untracked = gitSafe(["ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
2167
+ const tracked = new Set(gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2168
+ const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
2100
2169
  const readWorkingFile = createRepoFileReader(cwd);
2101
2170
  for (const file of untracked) {
2102
2171
  try {
@@ -2146,7 +2215,7 @@ export function fixCommits(spec, cwd, max = 200) {
2146
2215
  }
2147
2216
  /** All tracked files matching the given extensions. */
2148
2217
  export function trackedFiles(cwd, exts) {
2149
- const out = gitSafe(["ls-files"], cwd);
2218
+ const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
2150
2219
  const all = out ? out.split("\n").filter(Boolean) : [];
2151
2220
  return all.filter((f) => exts.some((e) => f.endsWith(e)));
2152
2221
  }
@@ -64,7 +64,12 @@ function copyNativeBinding(packageName, copyRoot, nodeGypBuild) {
64
64
  export function loadNativeTreeSitter() {
65
65
  if (runtime)
66
66
  return runtime;
67
- const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /tree-sitter(?:-typescript|-python)?\.node$/.test(path)
67
+ // Both binding spellings: prebuilds ship as tree-sitter[-typescript|-python].node,
68
+ // while from-source builds are named after the binding.gyp target with
69
+ // underscores (tree_sitter_runtime_binding.node, tree_sitter_python_binding.node,
70
+ // …). Missing the underscore names let an already-loaded source-built addon slip
71
+ // past this guard and defeat the file-lock isolation entirely (issue #52).
72
+ const preloaded = Object.keys(runtimeRequire.cache).filter((path) => /(?:tree-sitter(?:-typescript|-python)?|tree_sitter(?:_[a-z]+)*_binding)\.node$/.test(path)
68
73
  && !new RegExp(`(?:^|[\\\\/])${COPY_PREFIX}\\d+-`).test(path));
69
74
  if (preloaded.length) {
70
75
  throw new Error(`tree-sitter native addon was loaded before Hunch could isolate it: ${preloaded.join(", ")}`);
@@ -56,7 +56,8 @@ export function parseTestReport(output) {
56
56
  // Collect the following more-indented diagnostic block as the message.
57
57
  const baseIndent = leadingSpaces(raw);
58
58
  const block = [];
59
- for (let j = i + 1; j < lines.length; j++) {
59
+ let j = i + 1;
60
+ for (; j < lines.length; j++) {
60
61
  const ln = lines[j];
61
62
  if (ln.trim() === "") {
62
63
  block.push("");
@@ -68,6 +69,11 @@ export function parseTestReport(output) {
68
69
  }
69
70
  const diag = block.join("\n").trim();
70
71
  failMap.set(name, { test: name, message: diag ? `${name}\n${diag}` : name });
72
+ // Skip the consumed diagnostic block (issue #51): re-visiting it let
73
+ // TAP-looking text QUOTED INSIDE an error message (assertion diffs in this
74
+ // very repo quote "ok N - …" lines) parse as real results — a phantom pass
75
+ // can mark a previously-open bug fixed without any test having re-run.
76
+ i = j - 1;
71
77
  }
72
78
  // A test can legitimately appear as both (flaky retry) — trust the failure.
73
79
  for (const name of failMap.keys())
@@ -3,8 +3,9 @@
3
3
  * context loaded every session for free"). We own ONLY the region between the
4
4
  * HUNCH markers — any user-authored content outside it is preserved verbatim.
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
7
- import { join, dirname } from "node:path";
6
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { writeFileAtomic } from "../core/io.js";
8
+ import { basename, join, dirname } from "node:path";
8
9
  import { wikiSummary } from "../wiki/wiki.js";
9
10
  import { PolicyRepository } from "../constitution/repository.js";
10
11
  const START = "<!-- HUNCH:START — auto-generated, do not edit by hand -->";
@@ -103,12 +104,14 @@ export function upsertSection(file, section, fallbackTitle) {
103
104
  content = `${fallbackTitle}\n\n${section}\n`;
104
105
  }
105
106
  mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
106
- writeFileSync(file, content);
107
+ // Atomic: this file carries the USER'S prose around the managed block — a torn
108
+ // write must not be able to truncate it (issue #43).
109
+ writeFileAtomic(file, content);
107
110
  return file;
108
111
  }
109
112
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
110
113
  export function updateClaudeMd(root, store) {
111
- return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root.split("/").pop()}`);
114
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
112
115
  }
113
116
  function sev(s) {
114
117
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
@@ -17,7 +17,8 @@
17
17
  * Every writer MERGES into existing files (preserving other servers / user prose)
18
18
  * and is idempotent, so re-running `hunch init` is safe.
19
19
  */
20
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
20
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
21
+ import { writeFileAtomic } from "../core/io.js";
21
22
  import { homedir } from "node:os";
22
23
  import { join, dirname } from "node:path";
23
24
  import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
@@ -135,7 +136,9 @@ function tomlStr(s) {
135
136
  }
136
137
  function writeJson(file, obj) {
137
138
  mkdirSync(dirname(file), { recursive: true });
138
- writeFileSync(file, JSON.stringify(obj, null, 2) + "\n");
139
+ // Atomic: these files hold the USER'S merged servers/hooks — a torn write would
140
+ // leave them unparseable, which every writer here then refuses to touch (#43).
141
+ writeFileAtomic(file, JSON.stringify(obj, null, 2) + "\n");
139
142
  return file;
140
143
  }
141
144
  /** Provider hook commands live in tracked config files, so use the structured
@@ -148,7 +151,14 @@ function hookCommand(inv, provider) {
148
151
  function isHunchProviderHook(entry) {
149
152
  const e = entry && typeof entry === "object" ? entry : null;
150
153
  const command = typeof e?.command === "string" ? e.command : "";
151
- return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts))/.test(command) && /\bhook\b/.test(command);
154
+ // Anchored to the exact shape hookCommand() writes — JSON-quoted parts ending
155
+ // in "hook" "--provider" "<name>" — plus a Hunch launcher (the pinned npm
156
+ // package spec, or a quoted …/index.js|ts path for source installs). The old
157
+ // unanchored /index\.(js|ts)/ + /\bhook\b/ pair classified FOREIGN entries
158
+ // like `node ./hook/index.js` as ours and silently deleted them, violating
159
+ // the leave-every-foreign-hook-in-place contract (con_8460b6770f, issue #41).
160
+ return /(?:@davesheffer\/hunch|[\\/]index\.(?:js|ts)")/.test(command)
161
+ && /\s"hook"(?:\s+"--provider"\s+"[a-z]+")?\s*$/.test(command);
152
162
  }
153
163
  /** Merge our command entries into a standard `{ hooks: { Event: [] } }` file.
154
164
  * We replace only old Hunch commands and leave every foreign hook in place. */
@@ -255,7 +265,7 @@ export function writeCodexConfig(root, inv) {
255
265
  }
256
266
  base = base.trimEnd();
257
267
  mkdirSync(dirname(file), { recursive: true });
258
- writeFileSync(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
268
+ writeFileAtomic(file, base ? `${base}\n\n${block}\n` : `${block}\n`);
259
269
  return file;
260
270
  }
261
271
  /** AGENTS.md — the cross-tool ambient-instruction standard (Codex and a growing
@@ -273,7 +283,7 @@ export function writeCursorRule(root, store) {
273
283
  const file = join(root, ".cursor", "rules", "hunch.mdc");
274
284
  const body = `---\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\nalwaysApply: true\n---\n\n${renderHunchSection(store, root)}\n`;
275
285
  mkdirSync(dirname(file), { recursive: true });
276
- writeFileSync(file, body);
286
+ writeFileAtomic(file, body);
277
287
  return file;
278
288
  }
279
289
  /** Windsurf (Cascade): .windsurf/mcp_config.json — same `mcpServers` shape as
@@ -308,7 +318,7 @@ export function writeWindsurfRule(root, store) {
308
318
  const file = join(root, ".windsurf", "rules", "hunch.md");
309
319
  const body = `---\ntrigger: always_on\ndescription: Hunch engineering memory — consult the hunch_* MCP tools before editing\n---\n\n${renderHunchSection(store, root)}\n`;
310
320
  mkdirSync(dirname(file), { recursive: true });
311
- writeFileSync(file, body);
321
+ writeFileAtomic(file, body);
312
322
  return file;
313
323
  }
314
324
  /** Cursor's hook API is beta, but its project-level config accepts this standard
@@ -3,7 +3,8 @@
3
3
  * - .mcp.json → registers the `hunch` MCP server with Claude Code
4
4
  * - .claude/commands/* → user-triggered slash commands for the §5 workflows
5
5
  */
6
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
6
+ import { readFileSync, existsSync, mkdirSync } from "node:fs";
7
+ import { writeFileAtomic } from "../core/io.js";
7
8
  import { join, dirname } from "node:path";
8
9
  /** Merge a `hunch` server entry into .mcp.json, preserving other servers.
9
10
  * A non-empty file we cannot parse THROWS instead of being silently replaced
@@ -28,7 +29,9 @@ export function writeMcpJson(root, inv) {
28
29
  }
29
30
  json.mcpServers = json.mcpServers ?? {};
30
31
  json.mcpServers.hunch = { command: inv.command, args: [...inv.args, "mcp"] };
31
- writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
32
+ // Atomic: .mcp.json holds the user's other servers — a torn write would leave
33
+ // it unparseable, which this writer then refuses to touch (issue #43).
34
+ writeFileAtomic(file, JSON.stringify(json, null, 2) + "\n");
32
35
  return file;
33
36
  }
34
37
  const WHY_CMD = `---
@@ -168,13 +171,17 @@ export function installClaudeHooks(root, hookCmd) {
168
171
  if (existed && before === next)
169
172
  return { path: file, action: "unchanged" };
170
173
  mkdirSync(dirname(file), { recursive: true });
171
- writeFileSync(file, next);
174
+ writeFileAtomic(file, next);
172
175
  return { path: file, action: existed ? "updated" : "created" };
173
176
  }
177
+ /** Ownership marker for generated slash commands: its presence means Hunch may
178
+ * refresh the file; deleting the line hands the file to the user for good. */
179
+ const CMD_MARKER = "<!-- hunch:generated — refreshed by hunch init; delete this line to take ownership -->";
174
180
  export function writeSlashCommands(root) {
175
181
  const dir = join(root, ".claude", "commands");
176
182
  mkdirSync(dir, { recursive: true });
177
183
  const written = [];
184
+ const skipped = [];
178
185
  const files = [
179
186
  ["hunch-why.md", WHY_CMD],
180
187
  ["hunch-fix.md", FIX_CMD],
@@ -185,9 +192,18 @@ export function writeSlashCommands(root) {
185
192
  ];
186
193
  for (const [name, body] of files) {
187
194
  const p = join(dir, name);
188
- writeFileSync(p, body);
195
+ // Generic names (capture/heal/audit) are plausibly the USER'S OWN commands;
196
+ // hunch-prefixed names are namespaced ours. Overwrite an existing file only
197
+ // when it carries the ownership marker or the hunch- namespace — never
198
+ // silently replace user content (issue #42). Pre-marker Hunch installs skip
199
+ // once and report; re-adopt by deleting the file and re-running init.
200
+ if (existsSync(p) && !name.startsWith("hunch-") && !readFileSync(p, "utf8").includes("hunch:generated")) {
201
+ skipped.push(p);
202
+ continue;
203
+ }
204
+ writeFileAtomic(p, `${body}\n${CMD_MARKER}\n`);
189
205
  written.push(p);
190
206
  }
191
- return written;
207
+ return { written, skipped };
192
208
  }
193
209
  //# sourceMappingURL=scaffold.js.map
package/dist/mcp/roots.js CHANGED
@@ -5,10 +5,26 @@
5
5
  * another workspace or linked worktree. MCP roots are the client-neutral protocol
6
6
  * mechanism for following that change.
7
7
  */
8
- import { statSync } from "node:fs";
8
+ import { realpathSync, statSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { findRoot, HUNCH_DIR, isDir } from "../core/paths.js";
12
+ /** One canonical spelling per directory. `findRoot` only resolve()s, but a
13
+ * client's roots/list URI can spell the same repo differently — VS Code sends
14
+ * a lowercase drive letter (`c:\…`) while the spawn cwd has `C:\…`, and Git
15
+ * for Windows can surface 8.3/short names. Raw string comparison then treats
16
+ * ONE repo as different roots: a full re-prepare (new store + reindex) on
17
+ * every connect, or a false "multiple roots equally plausible" refusal
18
+ * (issue #54). realpathSync.native returns the on-disk spelling for all of
19
+ * these; fall back to the input when the path is transiently unreadable. */
20
+ export function canonicalRootPath(root) {
21
+ try {
22
+ return realpathSync.native(root);
23
+ }
24
+ catch {
25
+ return root;
26
+ }
27
+ }
12
28
  function toPath(uri) {
13
29
  if (!uri.startsWith("file:"))
14
30
  return "";
@@ -44,12 +60,12 @@ export function resolveActiveRoot(rootUris, fallbackCwd) {
44
60
  const start = rootStart(toPath(uri));
45
61
  if (!start)
46
62
  continue;
47
- const root = findRoot(start);
63
+ const root = canonicalRootPath(findRoot(start));
48
64
  if (!candidates.includes(root))
49
65
  candidates.push(root);
50
66
  }
51
67
  if (!candidates.length)
52
- return findRoot(fallbackCwd);
68
+ return canonicalRootPath(findRoot(fallbackCwd));
53
69
  if (candidates.length === 1)
54
70
  return candidates[0];
55
71
  const withStore = candidates.filter((candidate) => isDir(join(candidate, HUNCH_DIR)));
@@ -11,7 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { RootsListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
12
12
  import { z } from "zod";
13
13
  import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
14
- import { resolveActiveRoot } from "./roots.js";
14
+ import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
15
15
  import { HunchStore } from "../store/hunchStore.js";
16
16
  import { selectEmbedder } from "../store/embedder.js";
17
17
  import { decisionId, findingId } from "../core/ids.js";
@@ -278,8 +278,11 @@ export function buildServerWithRootControl(initialRoot) {
278
278
  let pendingScheduled = false;
279
279
  let closed = false;
280
280
  const activateRoot = (next) => {
281
- const canonical = findRoot(next);
282
- if (canonical === root)
281
+ // canonicalRootPath: a case/8.3 spelling difference must not read as a
282
+ // DIFFERENT repo — that closed the live store and re-prepared everything
283
+ // on every same-repo client connect (issue #54).
284
+ const canonical = canonicalRootPath(findRoot(next));
285
+ if (canonical === canonicalRootPath(root))
283
286
  return;
284
287
  const prepared = prepareRoot(canonical, explicitOverlay, true);
285
288
  const previous = store;
@@ -386,12 +389,18 @@ export function buildServerWithRootControl(initialRoot) {
386
389
  if (teamAdvertised && !matchesStartupTeamRoute()) {
387
390
  return err("The team-memory route changed during refresh. Refusing to serve a stale or redirected graph; reconnect Hunch first.");
388
391
  }
389
- try {
390
- if (store.sourceStamp() !== indexedSourceStamp)
391
- refreshIndex();
392
- }
393
- catch { /* corrupt/churning local source — serve the last durable indexed view */ }
394
392
  }
393
+ // Stamp check in EVERY mode, not only shared: a CLI capture or post-commit
394
+ // hook in another terminal writes JSON that mtime-invalidated loadAll sees
395
+ // immediately, while the SQLite FTS/graph index this long-lived process
396
+ // serves would stay frozen at startup — split-brain answers within one
397
+ // session (JSON-backed tools fresh, query/structure/dependents stale)
398
+ // until restart (issue #49).
399
+ try {
400
+ if (store.sourceStamp() !== indexedSourceStamp)
401
+ refreshIndex();
402
+ }
403
+ catch { /* corrupt/churning local source — serve the last durable indexed view */ }
395
404
  const result = await callback(...args);
396
405
  if (teamAdvertised && !matchesStartupTeamRoute()) {
397
406
  return err("The team-memory route changed while the tool was running. Its startup destination was not published; reconnect Hunch before retrying.");
@@ -58,6 +58,12 @@ export function planCompaction(input, opts) {
58
58
  continue; // d is being removed → its references don't count
59
59
  if (d.supersedes)
60
60
  refDec.add(d.supersedes);
61
+ // superseded_by is a reference too: supersedeIn() sets old.superseded_by
62
+ // without requiring the successor's `supersedes`, so removing a later-
63
+ // rejected successor would leave the surviving record with a dangling
64
+ // pointer AND permanently non-live for its topic (issue #36).
65
+ if (d.superseded_by)
66
+ refDec.add(d.superseded_by);
61
67
  if (d.caused_by_bug)
62
68
  refBug.add(d.caused_by_bug);
63
69
  }
@@ -760,11 +760,14 @@ export class HunchStore {
760
760
  const symbols = this.recs("symbols");
761
761
  const components = this.recs("components");
762
762
  const asOf = opts.asOf;
763
- const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || s.file.endsWith(target));
763
+ // pathRelated, not bare endsWith: "scenario.ts".endsWith("io.ts") is true,
764
+ // so an unanchored suffix pulled unrelated files' records into why()/the
765
+ // pre-edit grounding block (issue #32). Segment-anchored matching only.
766
+ const matchedSymbols = symbols.filter((s) => s.file === target || s.name === target || s.id === target || pathRelated(s.file, target));
764
767
  const symIds = new Set(matchedSymbols.map((s) => s.id));
765
768
  const fileSet = new Set(matchedSymbols.map((s) => s.file));
766
769
  const isPath = target.includes("/") || target.includes(".");
767
- const fileMatch = (files) => files.some((f) => f === target || (isPath && (f.endsWith(target) || target.endsWith(f))) || fileSet.has(f));
770
+ const fileMatch = (files) => files.some((f) => f === target || (isPath && pathRelated(f, target)) || fileSet.has(f));
768
771
  return {
769
772
  target,
770
773
  decisions: decisions.filter((d) => (fileMatch(d.related_files) || d.related_components.some((c) => components.find((x) => x.id === c && fileMatch(x.paths))))
@@ -1525,7 +1528,9 @@ const RRF_W_GRAPH = numEnv("HUNCH_RRF_W_GRAPH", 0.5);
1525
1528
  const GRAPH_GAMMA = numEnv("HUNCH_GRAPH_GAMMA", 0.25);
1526
1529
  function numEnv(name, dflt) {
1527
1530
  const v = Number(process.env[name]);
1528
- return Number.isFinite(v) && v > 0 ? v : dflt;
1531
+ // >= 0, not > 0: zero is the documented kill-switch (HUNCH_RRF_W_*=0 disables
1532
+ // a stream); rejecting it silently re-enabled the default weight (issue #33).
1533
+ return Number.isFinite(v) && v >= 0 ? v : dflt;
1529
1534
  }
1530
1535
  /** Pack a vector's exact bytes for SQLite. Explicit offset+length so a SUBARRAY
1531
1536
  * view (byteOffset != 0) writes only its slice, not the whole backing buffer.
@@ -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);
@@ -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.10.0",
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.",