@davesheffer/hunch 1.10.0 → 1.10.2

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.
@@ -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) {
@@ -659,6 +711,22 @@ export function isGitCleanPath(root, rel) {
659
711
  return false;
660
712
  }
661
713
  }
714
+ /** The committed (HEAD) content of a tracked file, or null when the path is
715
+ * untracked/absent at HEAD or git is unavailable. Used to decide whether a
716
+ * dirty grounding doc differs from HEAD ONLY inside its generated section
717
+ * (the stranded-grounding heal, fnd_b269d5c422). */
718
+ export function headFileContent(root, rel) {
719
+ try {
720
+ return execFileSync("git", ["-C", root, "show", `HEAD:${rel.replace(/\\/g, "/")}`], {
721
+ encoding: "utf8",
722
+ env: foreignRepoEnv(process.env),
723
+ maxBuffer: 16 * 1024 * 1024,
724
+ });
725
+ }
726
+ catch {
727
+ return null;
728
+ }
729
+ }
662
730
  /** Is the staged set a clean, MEMORY-ONLY change — only JSON record adds/updates, nothing else?
663
731
  * The overlay store is entirely JSON (decisions/, bugs/, …, manifest.json). A real memory sync
664
732
  * is purely additive; a DELETION, rename, or any non-.json staged path means hunchDir is NOT a
@@ -1123,6 +1191,7 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1123
1191
  if (!hooksDir)
1124
1192
  return "failed";
1125
1193
  const tryGit = (args, timeout = timeoutMs) => {
1194
+ const startedAt = Date.now();
1126
1195
  try {
1127
1196
  execFileSync("git", [
1128
1197
  "-C", hunchDir,
@@ -1136,7 +1205,10 @@ function mergeRemote(hunchDir, env, timeoutMs, contract, allowUnrelatedHistories
1136
1205
  });
1137
1206
  return true;
1138
1207
  }
1139
- catch {
1208
+ catch (error) {
1209
+ // Same stranding class as the commit path: a timeout-killed merge/fetch
1210
+ // leaves index.lock behind and wedges every later sync (issue #53).
1211
+ clearStrandedIndexLock(hunchDir, env, startedAt, error);
1140
1212
  return false;
1141
1213
  }
1142
1214
  };
@@ -1522,6 +1594,13 @@ function waitForCommitLockHandoff(lock, first, timeoutMs) {
1522
1594
  Atomics.wait(sleeper, 0, 0, Math.min(25, deadline - Date.now()));
1523
1595
  attempt = acquireCommitLock(lock);
1524
1596
  }
1597
+ // The deadline can expire DURING the final wait+acquire; without this check an
1598
+ // acquire that succeeded on that last iteration returned false while this
1599
+ // process's owner directory held the lock — never released (the caller bails
1600
+ // before its try/finally), wedging every flush in every process until this one
1601
+ // exited (issue #48).
1602
+ if (attempt.state === "acquired")
1603
+ return true;
1525
1604
  return false;
1526
1605
  }
1527
1606
  export function headSha(cwd) {
@@ -1595,7 +1674,7 @@ export function currentBranch(cwd) {
1595
1674
  /** Files changed in a single commit. `--root` makes the initial commit (which
1596
1675
  * has no parent) report its files as additions instead of returning nothing. */
1597
1676
  export function commitFiles(sha, cwd) {
1598
- const out = gitSafe(["diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
1677
+ const out = gitSafe(["-c", "core.quotePath=false", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha], cwd);
1599
1678
  return out ? out.split("\n").filter(Boolean) : [];
1600
1679
  }
1601
1680
  /** Raw `git log` over `.hunch/`, paired with parseMemoryLog — the memory-move
@@ -2011,7 +2090,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2011
2090
  return out;
2012
2091
  // churn — one windowed log; tally each wanted path's appearances (= commits).
2013
2092
  if (days > 0) {
2014
- const raw = gitSafe(["log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
2093
+ const raw = gitSafe(["-c", "core.quotePath=false", "log", `--since=${days}.days.ago`, "--name-only", "--format="], cwd);
2015
2094
  if (raw) {
2016
2095
  for (const line of raw.split("\n")) {
2017
2096
  const e = line && out.get(line);
@@ -2023,7 +2102,7 @@ export function fileGitMetrics(cwd, want, days = 90) {
2023
2102
  // last commit — one newest-first log; the FIRST time a path appears is its most
2024
2103
  // recent commit. NUL-prefixed lines mark commit boundaries; the rest are paths.
2025
2104
  // 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);
2105
+ const raw = gitSafe(["-c", "core.quotePath=false", "log", "--name-only", "--format=%x00%h"], cwd, 256 * 1024 * 1024);
2027
2106
  if (raw) {
2028
2107
  let remaining = out.size;
2029
2108
  let sha = "";
@@ -2044,17 +2123,23 @@ export function fileGitMetrics(cwd, want, days = 90) {
2044
2123
  }
2045
2124
  return out;
2046
2125
  }
2047
- /** Files staged for commit (for `hunch check` pre-commit enforcement). */
2126
+ /** Files staged for commit (for `hunch check` pre-commit enforcement).
2127
+ *
2128
+ * Every path enumerator here pins `core.quotePath=false` (issue #50): with
2129
+ * git's default quotePath, any path holding bytes > 0x7F comes back
2130
+ * octal-quoted (`"src/caf\303\251.ts"`), which matches neither the store's
2131
+ * POSIX paths nor constraint scope globs — a blocking constraint over such a
2132
+ * file graded as a vacuous PASS, and its churn/last-commit metrics read zero. */
2048
2133
  export function stagedFiles(cwd) {
2049
- const out = gitSafe(["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
2134
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd);
2050
2135
  return out ? out.split("\n").filter(Boolean) : [];
2051
2136
  }
2052
2137
  /** Files changed anywhere in the working tree compared with HEAD: both staged
2053
2138
  * and unstaged tracked files, plus untracked files. This powers the local,
2054
2139
  * pre-commit Change Gate; it never mutates the index or asks an agent/model. */
2055
2140
  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);
2141
+ const changed = gitSafe(["-c", "core.quotePath=false", "diff", "HEAD", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR"], cwd).split("\n").filter(Boolean);
2142
+ const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter(Boolean);
2058
2143
  return [...new Set([...changed, ...untracked])].sort();
2059
2144
  }
2060
2145
  /** Does a ref resolve to a commit in this repo? Lets `--base` fail LOUDLY on an
@@ -2066,7 +2151,7 @@ export function revExists(ref, cwd) {
2066
2151
  /** Files a PR/branch changes vs `base` (3-dot: changes on HEAD since the merge-base,
2067
2152
  * i.e. exactly the PR's own commits — the CI Constraint Guard's surface). */
2068
2153
  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);
2154
+ const out = gitSafe(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=ACMR", `${base}...${head}`], cwd);
2070
2155
  return out ? out.split("\n").filter(Boolean) : [];
2071
2156
  }
2072
2157
  /** Commit subjects on `head` since `base` (2-dot: commits added by the task),
@@ -2095,8 +2180,8 @@ export function stagedDiff(cwd, maxBytes = 60_000) {
2095
2180
  * intentionally contribute no synthetic content to regression analysis. */
2096
2181
  export function workingDiff(cwd, maxBytes = 60_000) {
2097
2182
  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));
2183
+ 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));
2184
+ const untracked = gitSafe(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], cwd).split("\n").filter((f) => f && !tracked.has(f));
2100
2185
  const readWorkingFile = createRepoFileReader(cwd);
2101
2186
  for (const file of untracked) {
2102
2187
  try {
@@ -2146,7 +2231,7 @@ export function fixCommits(spec, cwd, max = 200) {
2146
2231
  }
2147
2232
  /** All tracked files matching the given extensions. */
2148
2233
  export function trackedFiles(cwd, exts) {
2149
- const out = gitSafe(["ls-files"], cwd);
2234
+ const out = gitSafe(["-c", "core.quotePath=false", "ls-files"], cwd);
2150
2235
  const all = out ? out.split("\n").filter(Boolean) : [];
2151
2236
  return all.filter((f) => exts.some((e) => f.endsWith(e)));
2152
2237
  }
@@ -6,19 +6,25 @@
6
6
  * four files.
7
7
  */
8
8
  import { loadNativeTreeSitter } from "./nativeTreeSitter.js";
9
- const TS_QUERY = `
10
- (function_declaration name: (identifier) @fn.name) @fn.def
11
- (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
- (method_definition name: (property_identifier) @method.name) @method.def
13
- (class_declaration name: (type_identifier) @class.name) @class.def
14
- (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
- (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
- (variable_declarator
17
- name: (identifier) @arrow.name
18
- value: [(arrow_function) (function_expression)]) @arrow.def
19
- (import_statement source: (string) @import.src)
20
- (call_expression function: (identifier) @call.id)
21
- (call_expression function: (member_expression property: (property_identifier) @call.member))
9
+ const TS_QUERY = `
10
+ (function_declaration name: (identifier) @fn.name) @fn.def
11
+ (generator_function_declaration name: (identifier) @fn.name) @fn.def
12
+ (method_definition name: (property_identifier) @method.name) @method.def
13
+ (class_declaration name: (type_identifier) @class.name) @class.def
14
+ (interface_declaration name: (type_identifier) @iface.name) @iface.def
15
+ (type_alias_declaration name: (type_identifier) @type.name) @type.def
16
+ (variable_declarator
17
+ name: (identifier) @arrow.name
18
+ value: [(arrow_function) (function_expression)]) @arrow.def
19
+ (import_statement source: (string) @import.src)
20
+ (call_expression function: (identifier) @call.id)
21
+ (call_expression function: (member_expression property: (property_identifier) @call.member))
22
+ ;; Construction IS a call. Without these, \`new Foo()\` produced no edge at all, so
23
+ ;; every class in a TS/JS repo had fan_in 0: blast radius before a constructor
24
+ ;; change came back empty, and a \`not-calls\` conformance predicate over a class
25
+ ;; could never see its own counterexample.
26
+ (new_expression constructor: (identifier) @call.id)
27
+ (new_expression constructor: (member_expression property: (property_identifier) @call.member))
22
28
  `;
23
29
  const TS_BUILTIN_METHODS = new Set([
24
30
  "map", "filter", "forEach", "reduce", "find", "findIndex", "some", "every", "includes",
@@ -65,21 +71,28 @@ const TSX = {
65
71
  grammarKey: "tsx",
66
72
  loadGrammar: () => loadNativeTreeSitter().tsx,
67
73
  };
68
- const PY_QUERY = `
69
- (class_definition
70
- name: (identifier) @class.name
71
- body: (block
72
- [
73
- (function_definition name: (identifier) @method.name) @method.def
74
- (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
75
- ])) @class.def
76
- (function_definition name: (identifier) @fn.name) @fn.def
77
- (import_statement name: (dotted_name) @import.src)
78
- (import_statement name: (aliased_import name: (dotted_name) @import.src))
79
- (import_from_statement module_name: (dotted_name) @import.src)
80
- (import_from_statement module_name: (relative_import) @import.src)
81
- (call function: (identifier) @call.id)
82
- (call function: (attribute attribute: (identifier) @call.member))
74
+ const PY_QUERY = `
75
+ (class_definition
76
+ name: (identifier) @class.name
77
+ body: (block
78
+ [
79
+ (function_definition name: (identifier) @method.name) @method.def
80
+ (decorated_definition definition: (function_definition name: (identifier) @method.name) @method.def)
81
+ ])) @class.def
82
+ ;; Every class, including one with no directly-nested def: dataclasses, Exception
83
+ ;; subclasses, Enums, TypedDicts and pydantic models are method-less by design and
84
+ ;; were invisible to the entire graph (no symbol, no component, no edges), so
85
+ ;; \`hunch why\` and blast radius came back empty for exactly the classes a refactor
86
+ ;; breaks. parse.ts keys pendingDefs by node id and keeps the first classification,
87
+ ;; so a class that ALSO matches the method-bearing pattern above is not duplicated.
88
+ (class_definition name: (identifier) @class.name) @class.def
89
+ (function_definition name: (identifier) @fn.name) @fn.def
90
+ (import_statement name: (dotted_name) @import.src)
91
+ (import_statement name: (aliased_import name: (dotted_name) @import.src))
92
+ (import_from_statement module_name: (dotted_name) @import.src)
93
+ (import_from_statement module_name: (relative_import) @import.src)
94
+ (call function: (identifier) @call.id)
95
+ (call function: (attribute attribute: (identifier) @call.member))
83
96
  `;
84
97
  const PY_BUILTIN_METHODS = new Set([
85
98
  "get", "set", "keys", "values", "items", "pop", "popitem", "update", "setdefault", "copy", "clear",
@@ -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,12 +3,24 @@
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 -->";
11
12
  const END = "<!-- HUNCH:END -->";
13
+ /** Remove the managed HUNCH section (markers inclusive), leaving only the
14
+ * user-authored surroundings. Lets a caller decide whether two versions of a
15
+ * doc differ ONLY in generated content (the stranded-grounding heal,
16
+ * fnd_b269d5c422): equal outside the block ⇒ regenerating cannot lose prose. */
17
+ export function stripManagedSection(text) {
18
+ const iStart = text.indexOf(START);
19
+ const iEnd = text.indexOf(END);
20
+ if (iStart < 0 || iEnd <= iStart)
21
+ return text;
22
+ return text.slice(0, iStart) + text.slice(iEnd + END.length);
23
+ }
12
24
  export function renderHunchSection(store, root) {
13
25
  const constraints = store.json
14
26
  .loadAll("constraints")
@@ -103,12 +115,14 @@ export function upsertSection(file, section, fallbackTitle) {
103
115
  content = `${fallbackTitle}\n\n${section}\n`;
104
116
  }
105
117
  mkdirSync(dirname(file), { recursive: true }); // e.g. .github/ for copilot-instructions
106
- writeFileSync(file, content);
118
+ // Atomic: this file carries the USER'S prose around the managed block — a torn
119
+ // write must not be able to truncate it (issue #43).
120
+ writeFileAtomic(file, content);
107
121
  return file;
108
122
  }
109
123
  /** Insert/replace the HUNCH section in CLAUDE.md, preserving everything else. */
110
124
  export function updateClaudeMd(root, store) {
111
- return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${root.split("/").pop()}`);
125
+ return upsertSection(join(root, "CLAUDE.md"), renderHunchSection(store, root), `# ${basename(root)}`);
112
126
  }
113
127
  function sev(s) {
114
128
  return { blocking: 3, warning: 2, advisory: 1 }[s] ?? 0;
@@ -17,11 +17,12 @@
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
- import { renderHunchSection, upsertSection, updateClaudeMd } from "./claudemd.js";
24
- import { isGitCleanPath } from "../extractors/git.js";
24
+ import { renderHunchSection, stripManagedSection, upsertSection, updateClaudeMd } from "./claudemd.js";
25
+ import { headFileContent, isGitCleanPath } from "../extractors/git.js";
25
26
  /** Strip // line and block comments + trailing commas (JSONC → JSON). String-aware
26
27
  * (double-quoted, with escapes) so a // inside a value isn't mangled. VS Code's
27
28
  * .vscode/mcp.json is JSONC, so we must tolerate comments. */
@@ -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
@@ -434,22 +444,50 @@ export function refreshExistingGrounding(root, store) {
434
444
  }
435
445
  return changed;
436
446
  }
437
- /** Capture-commit refresh: rewrite ONLY grounding docs that are git-clean, and return the
438
- * absolute paths of the ones that changed so the caller folds them into the memory commit
439
- * (commitAndPushHunch alsoStage). This keeps committed record counts permanently true
440
- * every capture used to bump the count and re-stale the committed docs, failing the
441
- * release gate's clean-tree check on the next CI index (the refresh-counts treadmill).
442
- * A user-dirty or untracked doc is left completely untouched (never refreshed, never
443
- * staged); it heals on the next manual `hunch sync` or `hunch index`. */
447
+ /** Wholly-Hunch-owned grounding docs (namespaced rule files the generators emit
448
+ * in full). Any dirt in these is generated dirt by contract there is no user
449
+ * prose to protect, so a stale copy is always safe to regenerate and stage. */
450
+ const WHOLLY_OWNED_GROUNDING = new Set([
451
+ join(".cursor", "rules", "hunch.mdc"),
452
+ join(".windsurf", "rules", "hunch.md"),
453
+ ]);
454
+ /** Is a DIRTY grounding doc's divergence from HEAD confined to generated content?
455
+ * Marker-managed docs (CLAUDE.md, AGENTS.md, copilot-instructions): compare
456
+ * worktree vs HEAD with the managed section stripped from both — equal outside
457
+ * the block means regenerating cannot lose user prose. Wholly-owned rule files
458
+ * need no comparison. Untracked files return false: a doc the user hasn't
459
+ * committed is theirs to stage. */
460
+ function generatedDirtOnly(root, rel, current) {
461
+ if (WHOLLY_OWNED_GROUNDING.has(rel))
462
+ return headFileContent(root, rel) !== null;
463
+ const head = headFileContent(root, rel);
464
+ if (head === null)
465
+ return false;
466
+ return stripManagedSection(head) === stripManagedSection(current);
467
+ }
468
+ /** Capture-commit refresh: rewrite grounding docs that are git-clean OR whose only
469
+ * divergence from HEAD is generated content, and return the absolute paths to fold
470
+ * into the memory commit (commitAndPushHunch alsoStage). This keeps committed record
471
+ * counts permanently true — every capture used to bump the count and re-stale the
472
+ * committed docs, failing the release gate's clean-tree check on the next CI index
473
+ * (the refresh-counts treadmill). The generated-dirt branch closes the second half
474
+ * (fnd_b269d5c422): once a doc went stale-dirty, the clean-only rule skipped it on
475
+ * every later flush FOREVER, and each release needed a manual chore commit. A doc
476
+ * whose USER PROSE differs from HEAD is still left completely untouched. */
444
477
  export function refreshCommittableGrounding(root, store) {
445
478
  const changed = [];
446
479
  for (const [rel, write] of groundingTargets(root, store)) {
447
480
  const file = join(root, rel);
448
- if (!existsSync(file) || !isGitCleanPath(root, rel))
481
+ if (!existsSync(file))
449
482
  continue;
450
483
  const before = readFileSync(file, "utf8");
484
+ const clean = isGitCleanPath(root, rel);
485
+ if (!clean && !generatedDirtOnly(root, rel, before))
486
+ continue;
451
487
  write();
452
- if (readFileSync(file, "utf8") !== before)
488
+ // A doc that was stale-DIRTY must be staged even when the regeneration is a
489
+ // byte no-op — the commit is what re-syncs HEAD with the worktree.
490
+ if (readFileSync(file, "utf8") !== before || !clean)
453
491
  changed.push(file);
454
492
  }
455
493
  return changed;
@@ -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