@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.
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);
@@ -3238,26 +3259,29 @@ program
3238
3259
  console.log(" Strict mode fails closed because an omitted source file could hide a semantic violation.");
3239
3260
  }
3240
3261
  }
3241
- if (!files.length) {
3242
- console.log(markdown ? renderMarkdown(emptyReport) : "No changed files to check.");
3243
- if (teamFreshnessFailure)
3244
- fail(teamFreshnessFailure);
3245
- if (opts.strict && semanticIssues.length)
3246
- process.exitCode = 1;
3247
- store.close();
3248
- return;
3249
- }
3250
3262
  // DIRECT (scope match) + NEAR (blast radius) + REGRESSION (re-added retired
3251
3263
  // code) + REDUNDANT (adds a symbol already defined elsewhere — advisory) + the
3252
3264
  // hardened strict gate + causal `why` citations — all assembled by the shared
3253
3265
  // store.buildCheckReport (also used by the hunch_merge_verdict tool).
3254
- const diff = exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root);
3255
- const report = store.buildCheckReport(files, diff, {
3256
- strict: !!opts.strict,
3257
- lastChange: (f) => lastChangeDate(f, root),
3258
- publicOnly: !!opts.publicOnly,
3259
- });
3260
- if (opts.blast && !markdown) {
3266
+ //
3267
+ // A ZERO-FILE diff must NOT short-circuit the run: the per-file report has nothing
3268
+ // to say, but the GRAPH-WIDE gates below (Architectural Conformance and Constitution
3269
+ // policy) ask "does the code, right now, still satisfy recorded intent?" — a question
3270
+ // independent of what this diff touched. Returning early here made a delete-only PR a
3271
+ // vacuous green (deletions are excluded by the enumerators' --diff-filter=ACMR, so
3272
+ // such a PR enumerates zero files) — including a deletion of the very symbol a
3273
+ // blocking conformance predicate or an active policy guards.
3274
+ const diff = files.length
3275
+ ? (exactCommit ? commitDiff(exactCommit, root) : opts.base ? rangeDiff(opts.base, root) : opts.working ? workingDiff(root) : stagedDiff(root))
3276
+ : "";
3277
+ const report = files.length
3278
+ ? store.buildCheckReport(files, diff, {
3279
+ strict: !!opts.strict,
3280
+ lastChange: (f) => lastChangeDate(f, root),
3281
+ publicOnly: !!opts.publicOnly,
3282
+ })
3283
+ : emptyReport;
3284
+ if (opts.blast && !markdown && files.length) {
3261
3285
  console.log(`Blast radius of ${files.length} changed file(s):`);
3262
3286
  for (const f of files) {
3263
3287
  const b = store.blastRadiusFiles(f);
@@ -3266,7 +3290,9 @@ program
3266
3290
  }
3267
3291
  console.log("");
3268
3292
  }
3269
- console.log(markdown ? renderMarkdown(report) : renderText(report));
3293
+ console.log(files.length
3294
+ ? (markdown ? renderMarkdown(report) : renderText(report))
3295
+ : (markdown ? renderMarkdown(emptyReport) : "No changed files to check."));
3270
3296
  // ARCHITECTURAL CONFORMANCE: does the RESULTING code still satisfy every recorded
3271
3297
  // architectural invariant? This is graph-reachability, not a diff — so it catches semantic
3272
3298
  // violations a pattern-matcher / SAST can't express (a controller that now reaches the DB
@@ -3354,6 +3380,13 @@ const vetoCmd = program
3354
3380
  store.close();
3355
3381
  return fail(`--base ref "${opts.base}" does not resolve. In CI, fetch the base branch first (git fetch origin <branch>).`);
3356
3382
  }
3383
+ // Same guard for --commit: an unfetched/mistyped sha would enumerate zero
3384
+ // files and exit 0 — a vacuous pass where CI expects the Decision Guard
3385
+ // to have actually looked (issue #45).
3386
+ if (opts.commit && !revExists(opts.commit, root)) {
3387
+ store.close();
3388
+ return fail(`--commit sha "${opts.commit}" does not resolve. In CI, ensure the commit is fetched (git fetch --depth=... or fetch-depth: 0).`);
3389
+ }
3357
3390
  store.reindex();
3358
3391
  const files = opts.commit ? commitFiles(opts.commit, root)
3359
3392
  : opts.base ? rangeFiles(opts.base, root)
@@ -3593,7 +3626,7 @@ program
3593
3626
  firm: "surfaces + warns on a violating edit",
3594
3627
  strict: "edit-time DENY + CI guard — the teeth are on",
3595
3628
  };
3596
- console.log(`\nHunch — enforcement status (${root.split("/").pop()})\n`);
3629
+ console.log(`\nHunch — enforcement status (${basename(root)})\n`);
3597
3630
  console.log(` firmness: ${firmness} ← ${fnote[firmness] ?? ""}\n`);
3598
3631
  console.log(` ✓ ARMED ${blocking.length} confirmed blocking invariant(s) — held against every assistant`);
3599
3632
  if (blocking.length) {
@@ -3713,19 +3746,29 @@ program
3713
3746
  // When the prompt reads like a correction ("no / that's wrong / never X"),
3714
3747
  // nudge the agent to PERSIST it as an enforced constraint (Never Twice) —
3715
3748
  // not just obey it this once and forget it next session.
3716
- let text = looksLikeCorrection(evt.prompt) ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
3749
+ const isCorrection = looksLikeCorrection(evt.prompt);
3750
+ let text = isCorrection ? `${HOOK_REMINDER}\n\n${CORRECTION_NUDGE}` : HOOK_REMINDER;
3751
+ // Payloads that must NEVER be deduped away. The dedup key hashes CONTENT, and
3752
+ // this content is assembled from constants — so two back-to-back corrections
3753
+ // produce byte-identical text and the second (usually the escalating one) was
3754
+ // silently swallowed, never becoming an enforced rule. Same for the unverified
3755
+ // nag, which is documented as the one nag that must repeat but rode the same
3756
+ // deduped payload and so fired once per streak.
3757
+ let mustDeliver = isCorrection;
3717
3758
  // Pipeline turn bookkeeping (fresh block budget) + the one nag that must
3718
3759
  // repeat: edits from an earlier turn still unverified.
3719
3760
  if (evt.session_id && pipelineEnabled()) {
3720
3761
  const st = onPrompt(loadPipelineState(evt.session_id));
3721
3762
  savePipelineState(evt.session_id, st);
3722
- if (!st.verifyAfterEdit)
3763
+ if (!st.verifyAfterEdit) {
3723
3764
  text += `\n\n${UNVERIFIED_NAG}`;
3765
+ mustDeliver = true;
3766
+ }
3724
3767
  }
3725
- // Once per session is enough for the availability reminder — repeating it
3726
- // every prompt burns context for zero information. A correction nudge has
3727
- // different content, so it always comes through (dec_244397d920).
3728
- if (injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
3768
+ // Once per session is enough for the bare availability reminder — repeating it
3769
+ // every prompt burns context for zero information (dec_244397d920). Only that
3770
+ // ambient case is deduped.
3771
+ if (!mustDeliver && injectionMode(evt.session_id, "prompt-reminder", text) === "delta")
3729
3772
  return;
3730
3773
  emitContext(provider, "UserPromptSubmit", text);
3731
3774
  return;
@@ -4659,6 +4702,7 @@ program
4659
4702
  .option("--no-llm", "skip LLM prose; deterministic template pages only")
4660
4703
  .option("--prose-heal", "also LLM-rewrite each adopted copy's reconciled overview (subscription; the deterministic corrections always remain)")
4661
4704
  .option("--private", "render the FULL graph (private overlay included) and write the wiki into the OVERLAY repo — nothing lands in this repo")
4705
+ .option("--private-prose", "opt in to LLM prose for a PRIVATE-split overlay wiki — this SENDS overlay records (decision text, private constraints, private bug root causes) to the configured provider. Off by default.")
4662
4706
  .action(async (opts) => {
4663
4707
  const { store, root } = storeFor();
4664
4708
  try {
@@ -4724,7 +4768,21 @@ program
4724
4768
  return fail("--prose-heal needs the LLM — drop --no-llm.");
4725
4769
  let prose;
4726
4770
  let adoptionProse;
4727
- if (opts.llm !== false) {
4771
+ // A PRIVATE-SPLIT overlay's packs carry the full union (source: "all") — overlay
4772
+ // decision context/rationale/rejected alternatives, private constraint statements,
4773
+ // private bug root causes. Sending that to an external subscription CLI would
4774
+ // silently break the storage-private promise every other path here enforces
4775
+ // (public-only CI comments, public-only grounding, public-only wiki manifests), in
4776
+ // the very command documented as the way to build the private wiki. So prose is OFF
4777
+ // by default for that home and needs an explicit --private-prose. A SHARED overlay
4778
+ // is deliberately excluded: there the team already routes captures through the
4779
+ // configured provider by recorded policy.
4780
+ const privateSplit = home.kind === "private" && store.mode === "private";
4781
+ const proseBlockedForPrivacy = privateSplit && !opts.privateProse;
4782
+ if (opts.llm !== false && proseBlockedForPrivacy) {
4783
+ console.log("Private overlay: LLM prose is OFF (pages would send overlay records to the configured provider). Deterministic template pages; pass --private-prose to opt in.");
4784
+ }
4785
+ if (opts.llm !== false && !proseBlockedForPrivacy) {
4728
4786
  const provider = await selectProvider({ root });
4729
4787
  if (provider.draftProse) {
4730
4788
  console.log(`Prose via ${provider.name}; the drift-bearing skeleton stays deterministic.`);
@@ -1,3 +1,4 @@
1
+ import { externalImportNodeId } from "./externalImports.js";
1
2
  function resolveSymbols(graph, ref) {
2
3
  const syms = graph.symbols;
3
4
  if (ref.startsWith("sym_"))
@@ -48,17 +49,30 @@ function evalPredicate(graph, d, p) {
48
49
  return { ...base, satisfied: false, detail: `subject "${p.subject}" not found in the graph — intent's subject is gone` };
49
50
  const wantReach = p.assert === "calls" || p.assert === "imports";
50
51
  const objects = p.object ? resolveSymbols(graph, p.object) : [];
51
- if (!objects.length) {
52
- // a required target gone the link can't hold (violated); a forbidden one trivially holds.
52
+ // An external package is a VIRTUAL graph node (`ext_<hash>`, see core/externalImports.ts),
53
+ // never a Symbol record so resolveSymbols can NEVER find one. Without this, every
54
+ // `not-imports <symbol> <package>` predicate fell straight into the "target not found ⇒
55
+ // a forbidden relation trivially holds" branch below and reported ✅ forever, while the
56
+ // graph right beside it recorded the violating `imports` edge. Resolve the package from
57
+ // the edge set instead, which is where the indexer actually puts it.
58
+ const externalId = p.object ? externalImportNodeId(p.object) : null;
59
+ const objectIds = objects.length
60
+ ? objects.map((o) => o.id)
61
+ : externalId && graph.edges.some((e) => e.to === externalId)
62
+ ? [externalId]
63
+ : [];
64
+ if (!objectIds.length) {
65
+ // a required target gone ⇒ the link can't hold (violated); a forbidden one trivially
66
+ // holds — nothing can reach a node the graph does not contain.
53
67
  return { ...base, satisfied: !wantReach, detail: `target "${p.object ?? ""}" not found in the graph` };
54
68
  }
55
69
  // A required relation cannot guess which same-name symbol carries the intent.
56
70
  // Force qualification instead of accidentally proving a different binding.
57
- if (wantReach && (subjects.length !== 1 || objects.length !== 1)) {
71
+ if (wantReach && (subjects.length !== 1 || objectIds.length !== 1)) {
58
72
  return {
59
73
  ...base,
60
74
  satisfied: false,
61
- detail: `ambiguous required binding (${subjects.length} subject, ${objects.length} target matches) — qualify as file:symbol; intent VIOLATED`,
75
+ detail: `ambiguous required binding (${subjects.length} subject, ${objectIds.length} target matches) — qualify as file:symbol; intent VIOLATED`,
62
76
  };
63
77
  }
64
78
  // A forbidden relation is conservative in the other direction: ANY matching
@@ -66,7 +80,7 @@ function evalPredicate(graph, d, p) {
66
80
  // only the first target lets a duplicate symbol hide a violation.
67
81
  const linked = subjects.some((subject) => {
68
82
  const reached = reaches(graph, subject.id, p.transitive);
69
- return objects.some((object) => reached.has(object.id));
83
+ return objectIds.some((id) => reached.has(id));
70
84
  });
71
85
  const satisfied = wantReach ? linked : !linked;
72
86
  const via = p.transitive ? " (transitively)" : "";
@@ -25,9 +25,17 @@ export function matchForbids(f, addedDeps, scopedAdded) {
25
25
  // tripping an in-scope edit.
26
26
  const hitDeps = [];
27
27
  for (const dep of f.deps) {
28
- const added = [...addedDeps].find((d) => d === dep || d.startsWith(`${dep}/`));
29
- if (added && codeLines.some((l) => importsDep(l, added)))
30
- hitDeps.push(added);
28
+ // EVERY added specifier that satisfies the forbid, not just the first: one commit
29
+ // can add `lodash` in an out-of-scope file and `lodash/groupBy` in the scoped one.
30
+ // Taking only `.find`'s first match meant the out-of-scope spelling shadowed the
31
+ // real in-scope violation and the blocking rule failed OPEN — with the outcome
32
+ // depending on diff order, i.e. on an unrelated file's name.
33
+ for (const added of addedDeps) {
34
+ if (added !== dep && !added.startsWith(`${dep}/`))
35
+ continue;
36
+ if (codeLines.some((l) => importsDep(l, added)))
37
+ hitDeps.push(added);
38
+ }
31
39
  }
32
40
  if (hitDeps.length)
33
41
  return { tier: "dep", evidence: hitDeps.map((d) => `+import ${d}`) };
@@ -89,6 +89,16 @@ export function scanRepoDocs(decisions, root) {
89
89
  else if (superseded && current && current.id !== a.pin) {
90
90
  issues.push(`line ${a.line}: pinned to superseded ${a.pin}; current for "${a.topic}" is ${current.id}`);
91
91
  }
92
+ else if (pinned.status === "rejected") {
93
+ // The ledger tells readers to "Trust ✅". A doc pinned to an approach the team
94
+ // explicitly REJECTED is the opposite of grounded — and the pre-edit hook, which
95
+ // only injects live decisions, already disagrees with the ✅ this used to award.
96
+ issues.push(`line ${a.line}: pinned to ${a.pin}, a REJECTED decision (topic "${a.topic}")${current ? `; current is ${current.id}` : ""}`);
97
+ }
98
+ else if (pinned.status === "proposed") {
99
+ // Roadmap intent, not an in-force answer: it neither grounds the doc nor makes
100
+ // it stale, so the doc falls to the honest "unverified" tier.
101
+ }
92
102
  else if (!superseded) {
93
103
  groundedPins++;
94
104
  }
@@ -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
@@ -131,18 +131,26 @@ export function analyzeDiff(diff) {
131
131
  }
132
132
  // ---- inside a hunk: content lines ----
133
133
  if (raw.startsWith("+")) {
134
- if (!isCode(curFile))
135
- continue;
136
- addedLines++;
137
- if (!curAdded && !curDeleted)
138
- filesModified.add(curFile);
139
134
  const body = raw.slice(1);
135
+ // Raw added lines are captured for EVERY file, before the code-only gate:
136
+ // content-matched constraints and Veto tripwires are not code-only rules
137
+ // (a blocking invariant legitimately scopes .github/workflows/**, *.sql,
138
+ // Dockerfile). Skipping them here left `scopedAdded` empty, which
139
+ // buildCheckReport reads as "cannot prove a violation ⇒ complies" — so the
140
+ // pre-edit hook denied the edit while `hunch check --strict` passed the
141
+ // very commit that landed it. Symbol/import extraction and the churn
142
+ // counters below stay code-only, unchanged.
140
143
  let lines = addedLinesBy.get(curFile);
141
144
  if (!lines) {
142
145
  lines = [];
143
146
  addedLinesBy.set(curFile, lines);
144
147
  }
145
148
  lines.push(body);
149
+ if (!isCode(curFile))
150
+ continue;
151
+ addedLines++;
152
+ if (!curAdded && !curDeleted)
153
+ filesModified.add(curFile);
146
154
  const d = declOf(body);
147
155
  if (d)
148
156
  declsFor(curFile)?.added.set(d.name, d);