@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 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) {
@@ -3855,7 +3883,7 @@ program
3855
3883
  // from this file. No diff exists yet, so this is context — "don't re-add X" —
3856
3884
  // not a block; the commit-time `hunch check` does the actual gating.
3857
3885
  const retired = store.retiredForFile(target).filter((r) => r.symbols.length || r.deps.length);
3858
- const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || retired.length || docGround;
3886
+ const hasContent = ctx.constraints.length || ctx.decisions.length || ctx.bugs.length || ctx.blast_radius.length || ctx.findings.length || retired.length || docGround;
3859
3887
  if (!hasContent)
3860
3888
  return; // no noise on files Hunch hasn't learned yet
3861
3889
  let text = formatContext(ctx).trim();
@@ -4538,6 +4566,34 @@ program
4538
4566
  store.close();
4539
4567
  }
4540
4568
  });
4569
+ // ---- findings (the open-observations ledger) --------------------------------
4570
+ program
4571
+ .command("findings")
4572
+ .description("LIVE findings — observed gaps/debt with no fix landed yet (audits, measurements, incidents; anchored to a date + evidence, not a commit). Same store method as the hunch_findings MCP tool. Read-only, advisory.")
4573
+ .argument("[scope]", "a path, glob, or symbol (e.g. src/procs/** or dbo.GetOrders); omit for all")
4574
+ .option("--all", "include resolved/stale findings (the full history)")
4575
+ .action((scope, opts) => {
4576
+ const { store } = storeFor();
4577
+ try {
4578
+ const live = (f) => f.triage === "open" || f.triage === "accepted-risk" || f.triage === "scheduled";
4579
+ const list = (scope ? store.liveFindingsFor(scope) : store.recs("findings").filter(opts.all ? () => true : live))
4580
+ .filter(opts.all ? () => true : live);
4581
+ if (!list.length) {
4582
+ console.log(`No ${opts.all ? "" : "live "}findings${scope ? ` for "${scope}"` : ""}. Record one after an audit: /audit (or hunch_record_finding via MCP).`);
4583
+ return;
4584
+ }
4585
+ for (const f of list) {
4586
+ const links = [f.violates_constraint ? `violates ${f.violates_constraint}` : "", f.method ? `re-verify via ${f.method}` : "", f.resolved_commit ? `fixed in ${f.resolved_commit.slice(0, 9)}` : ""].filter(Boolean).join(" · ");
4587
+ console.log(`• [${f.triage}/${f.severity}] ${f.title} (${f.id}, observed ${f.observed_at.slice(0, 10)})`);
4588
+ console.log(` ${f.observation}`);
4589
+ console.log(` concerns: ${[...f.affected_files, ...f.affected_symbols].join(", ") || "(unscoped)"}${links ? `\n ${links}` : ""}`);
4590
+ }
4591
+ console.log(`\n${list.length} finding(s).`);
4592
+ }
4593
+ finally {
4594
+ store.close();
4595
+ }
4596
+ });
4541
4597
  // ---- path (shortest dependency chain) --------------------------------------
4542
4598
  program
4543
4599
  .command("path")
@@ -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";
@@ -118,6 +118,31 @@ export function computeDrift(store, root) {
118
118
  // component vanished). Deterministic hash comparison against the manifest;
119
119
  // fires only when a wiki was adopted. Advisory like every other kind here.
120
120
  findings.push(...computeWikiDrift(store, root));
121
+ // 7. FINDING-STALE — a LIVE finding (observation, no diff) whose anchor evaporated:
122
+ // an affected file that no longer exists, or a violates_constraint pointing at a
123
+ // retired/missing rule. Deterministic + advisory (never the exit-code class):
124
+ // the observation may be fixed, moved, or moot — re-verify (re-run its method)
125
+ // and re-record with triage resolved/stale, or refresh its paths.
126
+ for (const f of store.recs("findings")) {
127
+ if (f.triage === "resolved" || f.triage === "stale")
128
+ continue;
129
+ for (const file of f.affected_files) {
130
+ if (!file || file.includes("*"))
131
+ continue; // globs can't dead-ref
132
+ if (!existsSync(join(root, file))) {
133
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" references missing file "${file}" — re-verify${f.method ? ` (${f.method})` : ""} and re-record, or mark it stale` });
134
+ }
135
+ }
136
+ if (f.violates_constraint) {
137
+ const con = store.getRec("constraints", f.violates_constraint);
138
+ if (!con) {
139
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" claims to violate ${f.violates_constraint}, which does not exist — link the real rule or record it (hunch_record_correction)` });
140
+ }
141
+ else if (con.status === "retired") {
142
+ findings.push({ kind: "finding-stale", id: f.id, detail: `live finding "${f.title}" violates ${f.violates_constraint}, but that constraint is RETIRED — resolve the finding or re-link it` });
143
+ }
144
+ }
145
+ }
121
146
  return { findings };
122
147
  }
123
148
  /** Resolve a decision file reference without making private-memory paths depend on
@@ -138,7 +163,10 @@ function referenceExists(store, root, decisionId, ref) {
138
163
  // A private-scoped reference is an overlay-repo-relative path, not an escape
139
164
  // hatch into arbitrary local files.
140
165
  const rel = relative(privateRoot, candidate);
141
- 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))
142
170
  return false;
143
171
  return existsSync(candidate);
144
172
  }
@@ -21,6 +21,12 @@ export function formatContext(ctx) {
21
21
  for (const b of ctx.bugs)
22
22
  out.push(`- [${b.status}/${b.severity}] ${b.title} — root cause: ${b.root_cause}${prov(b.provenance)}`);
23
23
  }
24
+ if (ctx.findings.length) {
25
+ out.push(`\n## 🔍 Known findings (observed, unresolved — no fix landed yet)`);
26
+ for (const f of ctx.findings) {
27
+ out.push(`- [${f.triage}/${f.severity}] ${f.title} — ${f.observation}${prov(f.provenance)}\n (${f.id}; observed ${f.observed_at.slice(0, 10)}${f.violates_constraint ? `; violates ${f.violates_constraint}` : ""}${f.method ? `; re-verify via ${f.method}` : ""})`);
28
+ }
29
+ }
24
30
  if (ctx.blast_radius.length) {
25
31
  out.push(`\n## 💥 Blast radius (transitive dependents)`);
26
32
  out.push(ctx.blast_radius.map((d) => `- [d${d.depth}] ${d.via}`).join("\n"));
package/dist/core/ids.js CHANGED
@@ -43,4 +43,10 @@ export function runbookId(seed) {
43
43
  export function constraintId(statement) {
44
44
  return "con_" + shortHash(statement.trim().toLowerCase());
45
45
  }
46
+ /** Finding id seeded by its title (trim + lowercase, same idiom as constraints):
47
+ * re-recording the same observation UPDATES it (e.g. a triage change) instead of
48
+ * minting a duplicate. A genuinely new observation deserves a new title. */
49
+ export function findingId(title) {
50
+ return "fnd_" + shortHash(title.trim().toLowerCase());
51
+ }
46
52
  //# sourceMappingURL=ids.js.map
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
@@ -218,8 +218,31 @@ export const RunbookSchema = z.object({
218
218
  provenance: ProvenanceSchema,
219
219
  date: z.string(),
220
220
  });
221
+ /** An OBSERVATION — audited knowledge with no diff (the anchor is a date + evidence,
222
+ * not a commit). Fills the gap between Bug (broke and got fixed) and Decision (chose
223
+ * and changed code): "we looked, we found, we haven't acted yet". Examples: an audit
224
+ * that surfaced unscoped tenant queries, a measured perf number, a vendor limit, an
225
+ * incident with no code fix. ADVISORY retrieval context (pre-edit grounding + MCP);
226
+ * never enters any block path. Lifecycle is `triage`, not valid-time: a finding is
227
+ * resolved/stale-marked, never superseded. */
228
+ export const FindingSchema = z.object({
229
+ id: z.string().describe("fnd_*"),
230
+ title: z.string(),
231
+ observation: z.string().default("").describe("what was observed, in plain words"),
232
+ evidence: z.array(z.string()).default([]).describe("the query/command run + representative output — a finding without evidence is an opinion"),
233
+ method: z.string().nullable().default(null).describe("rb_* runbook that re-runs the audit (makes the finding re-verifiable)"),
234
+ severity: z.enum(["low", "medium", "high", "critical"]).default("medium"),
235
+ triage: z.enum(["open", "accepted-risk", "scheduled", "resolved", "stale"]).default("open"),
236
+ affected_files: z.array(z.string()).default([]).describe("concrete paths or globs the observation concerns"),
237
+ affected_symbols: z.array(z.string()).default([]).describe("symbols/objects concerned (e.g. dbo.GetOrders)"),
238
+ violates_constraint: z.string().nullable().default(null).describe("con_* this finding is a known violation of"),
239
+ spawned_decision: z.string().nullable().default(null).describe("dec_* recorded in response to this finding"),
240
+ observed_at: z.string().describe("ISO instant the observation was made — the anchor (findings have no commit)"),
241
+ resolved_commit: z.string().nullable().default(null).describe("the commit that fixed it (set when triage becomes resolved)"),
242
+ provenance: ProvenanceSchema,
243
+ });
221
244
  /** The entity collections, keyed by their on-disk directory name. */
222
- export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks"];
245
+ export const ENTITY_KINDS = ["components", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
223
246
  export const SCHEMAS = {
224
247
  components: ComponentSchema,
225
248
  edges: EdgeSchema,
@@ -228,6 +251,7 @@ export const SCHEMAS = {
228
251
  bugs: BugSchema,
229
252
  constraints: ConstraintSchema,
230
253
  runbooks: RunbookSchema,
254
+ findings: FindingSchema,
231
255
  };
232
256
  /** Default provenance helper for deterministic (extracted) records. */
233
257
  export function extracted(confidence, evidence = []) {
@@ -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)) {
@@ -535,7 +576,7 @@ export function commitAndPushHunch(hunchDir, message, opts) {
535
576
  // remote .gitignore, local info/exclude, or ambient excludesFile must not
536
577
  // be able to silently stop the shared graph's heartbeat.
537
578
  for (let index = 0; index < paths.length; index += 128) {
538
- if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "add", "-f", "--", ...paths.slice(index, index + 128)])) {
579
+ if (!run(["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "-f", "--", ...paths.slice(index, index + 128)])) {
539
580
  run(["reset", "-q", "--", "."]);
540
581
  return null;
541
582
  }
@@ -573,9 +614,12 @@ export function commitAndPushHunch(hunchDir, message, opts) {
573
614
  // docs the caller verified git-clean BEFORE rewriting, so it can neither weaken the
574
615
  // bug_overlay_clobber detection above nor sweep user edits.
575
616
  for (const file of opts.alsoStage ?? []) {
617
+ // core.autocrlf=false on every memory add/checkout: the Git-for-Windows
618
+ // installer default (system gitconfig autocrlf=true) would re-encode the
619
+ // graph's JSON bytes in transit, breaking byte-exact content hashes.
576
620
  run(opts.push === false
577
- ? ["add", "--", file]
578
- : ["-c", `core.attributesFile=${gitNullDevice()}`, "add", "--", file]);
621
+ ? ["-c", "core.autocrlf=false", "add", "--", file]
622
+ : ["-c", `core.attributesFile=${gitNullDevice()}`, "-c", "core.autocrlf=false", "add", "--", file]);
579
623
  }
580
624
  // Only sync+push when a memory commit was actually created — never run pull/push against the
581
625
  // enclosing repo on an empty stage. Two-way sync: MERGE the remote BEFORE pushing so a push
@@ -595,17 +639,29 @@ export function commitAndPushHunch(hunchDir, message, opts) {
595
639
  if (!hooksDir)
596
640
  return null;
597
641
  const commitPaths = [...memoryPaths, ...(opts.alsoStage ?? [])];
598
- try {
599
- execFileSync("git", [
600
- "-C", hunchDir,
601
- "-c", `core.hooksPath=${hooksDir}`,
602
- ...(opts.push === false ? [] : ["-c", `core.attributesFile=${gitNullDevice()}`]),
603
- "-c", "commit.gpgsign=false",
604
- "commit", "--no-gpg-sign", "--only", "-m", message, "--", ...commitPaths,
605
- ], { stdio: "ignore", env, timeout: 15_000 });
606
- 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
+ }
607
664
  }
608
- catch { /* nothing staged / not a repo */ }
609
665
  if (!committed)
610
666
  return null;
611
667
  if (opts.push !== false) {
@@ -1072,6 +1128,7 @@ function adoptContractHead(hunchDir, fetchedHead, contract, env, fingerprint) {
1072
1128
  "-C", hunchDir,
1073
1129
  "-c", `core.hooksPath=${hooksDir}`,
1074
1130
  "-c", `core.attributesFile=${gitNullDevice()}`,
1131
+ "-c", "core.autocrlf=false",
1075
1132
  "reset", "--hard", fetchedHead,
1076
1133
  ], {
1077
1134
  stdio: "ignore", env, timeout: 5_000,
@@ -1118,11 +1175,13 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1118
1175
  if (!hooksDir)
1119
1176
  return "failed";
1120
1177
  const tryGit = (args, timeout = timeoutMs) => {
1178
+ const startedAt = Date.now();
1121
1179
  try {
1122
1180
  execFileSync("git", [
1123
1181
  "-C", hunchDir,
1124
1182
  "-c", `core.hooksPath=${hooksDir}`,
1125
1183
  "-c", `core.attributesFile=${gitNullDevice()}`,
1184
+ "-c", "core.autocrlf=false",
1126
1185
  "-c", "commit.gpgsign=false",
1127
1186
  ...args,
1128
1187
  ], {
@@ -1130,7 +1189,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1130
1189
  });
1131
1190
  return true;
1132
1191
  }
1133
- 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);
1134
1196
  return false;
1135
1197
  }
1136
1198
  };
@@ -1516,6 +1578,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
1516
1578
  Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1517
1579
  attempt = acquireCommitLock(lock);
1518
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;
1519
1588
  return false;
1520
1589
  }
1521
1590
  export function headSha(cwd) {
@@ -1589,7 +1658,7 @@ export function currentBranch(cwd) {
1589
1658
  /** Files changed in a single commit. `--root` makes the initial commit (which
1590
1659
  * has no parent) report its files as additions instead of returning nothing. */
1591
1660
  export function commitFiles(sha, cwd) {
1592
- 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);
1593
1662
  return out ? out.split("\n").filter(Boolean) : [];
1594
1663
  }
1595
1664
  /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
@@ -2005,7 +2074,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2005
2074
  return out;
2006
2075
  // churn — one windowed log; tally each wanted path's appearances (= commits).
2007
2076
  if (days > 0) {
2008
- 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);
2009
2078
  if (raw) {
2010
2079
  for (const line of raw.split("\n")) {
2011
2080
  const e = line && out.get(line);
@@ -2017,7 +2086,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2017
2086
  // last commit — one newest-first log; the FIRST time a path appears is its most
2018
2087
  // recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
2019
2088
  // 256MB buffer for the all-history name-only stream on large repos.
2020
- 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);
2021
2090
  if (raw) {
2022
2091
  let remaining = out.size;
2023
2092
  let sha = "";
@@ -2038,17 +2107,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
2038
2107
  }
2039
2108
  return out;
2040
2109
  }
2041
- /** 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. */
2042
2117
  export function stagedFiles(cwd) {
2043
- 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);
2044
2119
  return out ? out.split("\n").filter(Boolean) : [];
2045
2120
  }
2046
2121
  /** Files changed anywhere in the working tree compared with HEAD: both staged
2047
2122
  * and unstaged tracked files, plus untracked files. This powers the local,
2048
2123
  * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
2049
2124
  export function workingFiles(cwd) {
2050
- const changed = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2051
- 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);
2052
2127
  return [...new Set([...changed, ...untracked])].sort();
2053
2128
  }
2054
2129
  /** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
@@ -2060,7 +2135,7 @@ export function revExists(ref, cwd) {
2060
2135
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
2061
2136
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
2062
2137
  export function rangeFiles(base, cwd, head = "HEAD") {
2063
- 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);
2064
2139
  return out ? out.split("\n").filter(Boolean) : [];
2065
2140
  }
2066
2141
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
@@ -2089,8 +2164,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
2089
2164
  * intentionally contribute no synthetic content to regression analysis. */
2090
2165
  export function workingDiff(cwd, maxBytes = 60_000) {
2091
2166
  let out = gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=2", "--", ...DIFF_NOISE], cwd);
2092
- const tracked = new Set(gitSafe(["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean));
2093
- 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));
2094
2169
  const readWorkingFile = createRepoFileReader(cwd);
2095
2170
  for (const file of untracked) {
2096
2171
  try {
@@ -2140,7 +2215,7 @@ export function fixCommits(spec, cwd, max = 200) {
2140
2215
  }
2141
2216
  /** All tracked files matching the given extensions. */
2142
2217
  export function trackedFiles(cwd, exts) {
2143
- const out = gitSafe(["ls-files"], cwd);
2218
+ const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
2144
2219
  const all = out ? out.split("\n").filter(Boolean) : [];
2145
2220
  return all.filter((f) => exts.some((e) => f.endsWith(e)));
2146
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(", ")}`);