@davesheffer/hunch 1.39.2 → 1.40.0

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.
Files changed (69) hide show
  1. package/dist/cli/index.js +154 -64
  2. package/dist/cli/serve.d.ts +14 -0
  3. package/dist/cli/serve.js +18 -4
  4. package/dist/constitution/g2BehaviorCandidates.js +6 -1
  5. package/dist/constitution/g2Candidates.js +11 -1
  6. package/dist/constitution/structural.js +10 -0
  7. package/dist/core/correction.js +12 -7
  8. package/dist/core/delivery.d.ts +34 -0
  9. package/dist/core/delivery.js +60 -1
  10. package/dist/core/docanchors.js +195 -11
  11. package/dist/core/format.js +6 -1
  12. package/dist/core/glob.d.ts +25 -0
  13. package/dist/core/glob.js +43 -0
  14. package/dist/core/hookcache.d.ts +9 -2
  15. package/dist/core/hookcache.js +10 -3
  16. package/dist/core/paths.d.ts +48 -5
  17. package/dist/core/paths.js +89 -6
  18. package/dist/core/procstart.d.ts +94 -0
  19. package/dist/core/procstart.js +244 -0
  20. package/dist/core/served.d.ts +6 -3
  21. package/dist/core/served.js +10 -6
  22. package/dist/core/taskDelivery.js +27 -1
  23. package/dist/core/taskReport.d.ts +2 -0
  24. package/dist/core/taskReport.js +46 -11
  25. package/dist/core/taskReportEvidence.js +17 -1
  26. package/dist/core/taskReportHook.d.ts +19 -0
  27. package/dist/core/taskReportHook.js +54 -6
  28. package/dist/core/verifyLauncher.d.ts +21 -0
  29. package/dist/core/verifyLauncher.js +37 -0
  30. package/dist/core/workspace.d.ts +9 -0
  31. package/dist/core/workspace.js +15 -0
  32. package/dist/extractors/git.d.ts +5 -0
  33. package/dist/extractors/git.js +87 -10
  34. package/dist/extractors/indexer.d.ts +10 -0
  35. package/dist/extractors/indexer.js +56 -10
  36. package/dist/extractors/k8sManifest.d.ts +13 -0
  37. package/dist/extractors/k8sManifest.js +207 -23
  38. package/dist/extractors/landscapeDiscovery.js +9 -1
  39. package/dist/extractors/nativeTreeSitter.d.ts +24 -0
  40. package/dist/extractors/nativeTreeSitter.js +54 -1
  41. package/dist/extractors/parse.js +6 -1
  42. package/dist/integrations/claudemd.js +3 -3
  43. package/dist/integrations/hookmatch.d.ts +25 -0
  44. package/dist/integrations/hookmatch.js +58 -0
  45. package/dist/integrations/hooks.d.ts +79 -9
  46. package/dist/integrations/hooks.js +559 -46
  47. package/dist/integrations/providers.js +2 -17
  48. package/dist/integrations/scaffold.d.ts +1 -1
  49. package/dist/integrations/scaffold.js +24 -21
  50. package/dist/integrations/workspaceLedger.d.ts +10 -0
  51. package/dist/integrations/workspaceLedger.js +9 -1
  52. package/dist/mcp/server.d.ts +10 -0
  53. package/dist/mcp/server.js +32 -16
  54. package/dist/mcp/taskReportTools.d.ts +5 -9
  55. package/dist/mcp/taskReportTools.js +9 -20
  56. package/dist/serve/writelock.d.ts +21 -1
  57. package/dist/serve/writelock.js +157 -16
  58. package/dist/store/changeLedger.d.ts +9 -3
  59. package/dist/store/changeLedger.js +36 -10
  60. package/dist/store/hunchStore.d.ts +42 -2
  61. package/dist/store/hunchStore.js +105 -17
  62. package/dist/store/jsonStore.d.ts +6 -4
  63. package/dist/store/jsonStore.js +225 -32
  64. package/dist/store/merge.d.ts +7 -4
  65. package/dist/store/merge.js +65 -5
  66. package/dist/store/privateMigrate.js +10 -0
  67. package/dist/wiki/adopt.js +13 -2
  68. package/package.json +1 -1
  69. package/server.json +2 -2
@@ -3,13 +3,15 @@
3
3
  * in PRs, diffable, mergeable). This layer never touches SQLite — it is the
4
4
  * authoritative read/write surface; SQLite is rebuilt from it.
5
5
  */
6
- import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, rmSync, } from "node:fs";
6
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, opendirSync, readSync, realpathSync, rmSync, rmdirSync, } from "node:fs";
7
+ import { randomBytes } from "node:crypto";
7
8
  import { hostname } from "node:os";
8
9
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
10
  import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
10
11
  import { BASELINE_VERSION, migrateRaw, SCHEMA_VERSION } from "../core/migrate.js";
11
12
  import { writeFileAtomic } from "../core/io.js";
12
13
  import { readStoreArtifact } from "../core/storeArtifact.js";
14
+ import { heldLockNonces, judgeSameHostOwner, selfStartToken, startTokenOf } from "../core/procstart.js";
13
15
  /** High-cardinality collections (symbols, edges) are stored as a single
14
16
  * index.json array — there can be thousands, and one file per edge would create
15
17
  * enormous git noise. Curated, low-volume entities (components, decisions, bugs,
@@ -38,14 +40,64 @@ export const MAX_JSON_INDEX_BYTES = 256 * 1024 * 1024;
38
40
  export const MAX_JSON_MANIFEST_BYTES = 64 * 1024;
39
41
  export const MAX_JSON_DIRECTORY_ENTRIES_PER_KIND = 100_000;
40
42
  function readRmwOwner(lock) {
41
- const text = readStoreArtifact(lock, ["owner.tmp.json"], 4096);
43
+ let text;
44
+ try {
45
+ text = readStoreArtifact(lock, ["owner.tmp.json"], 4096);
46
+ }
47
+ catch (error) {
48
+ // A NORMAL release (`rmSync` of the lock dir) can land between
49
+ // readStoreArtifact's lstat and its read, and the reader then reports
50
+ // "unsafe or unreadable" for a file that simply went away — which used to
51
+ // make a healthy waiter throw instead of retrying. Only an ENOENT re-check
52
+ // excuses it: while the file still exists, a hardlink/oversize/symlink
53
+ // refusal is real and must still throw, unchanged.
54
+ //
55
+ // The ENOENT of the OWNER FILE is not enough on its own: a `.rmw-lock` that
56
+ // is itself a SYMLINK to a directory without an owner file also has no owner
57
+ // file, and excusing that would turn main's permanent "unsafe store artifact
58
+ // path" refusal into an age-based removal of the link. So the excuse also
59
+ // requires the lock itself to be either gone (the release we are modelling)
60
+ // or a REAL directory that is not a link.
61
+ //
62
+ // Residual accepted: a release + re-acquire between the reader's failure and
63
+ // this re-lstat makes a REAL refusal (hardlink/oversize) throw for a file
64
+ // that already belongs to the next holder. That only turns a retry into an
65
+ // error — it never licenses a steal, so the safe direction is preserved.
66
+ let ownerMissing = false;
67
+ try {
68
+ lstatSync(join(lock, "owner.tmp.json"));
69
+ }
70
+ catch (statError) {
71
+ ownerMissing = statError.code === "ENOENT";
72
+ }
73
+ if (ownerMissing) {
74
+ let lockOrdinary;
75
+ try {
76
+ const lockStat = lstatSync(lock);
77
+ lockOrdinary = lockStat.isDirectory() && !lockStat.isSymbolicLink();
78
+ }
79
+ catch (statError) {
80
+ if (statError.code !== "ENOENT")
81
+ throw error;
82
+ lockOrdinary = true; // the whole lock went away: exactly the release above
83
+ }
84
+ if (lockOrdinary)
85
+ return undefined;
86
+ }
87
+ throw error;
88
+ }
42
89
  if (text === null)
43
90
  return undefined;
44
91
  try {
45
92
  const parsed = JSON.parse(text);
46
93
  if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid < 1 || typeof parsed.host !== "string")
47
94
  return undefined;
48
- return { pid: parsed.pid, host: parsed.host };
95
+ return {
96
+ pid: parsed.pid,
97
+ host: parsed.host,
98
+ ...(typeof parsed.nonce === "string" ? { nonce: parsed.nonce } : {}),
99
+ ...(typeof parsed.start === "string" ? { start: parsed.start } : {}),
100
+ };
49
101
  }
50
102
  catch {
51
103
  return undefined;
@@ -60,6 +112,114 @@ function rmwPidAlive(pid) {
60
112
  return error.code === "EPERM";
61
113
  }
62
114
  }
115
+ /** Longest a `.rmw-lock.reclaim` claim may sit before another contender clears
116
+ * it: the claimed section is a handful of syscalls. */
117
+ const RMW_CLAIM_STALE_MS = 10_000;
118
+ /** Is this `.rmw-lock` takeable? Same rule as before — a live same-host PID is
119
+ * authoritative, everything else falls back to age — with the PID-RECYCLING
120
+ * hole closed (issue #293): a restart can hand the contender the dead owner's
121
+ * exact pid and host, and the orphaned lock then blocks every write forever.
122
+ * The owner's recorded process-instance token settles that by string equality
123
+ * (core/procstart.ts); no clock is compared, because a lock's mtime and a start
124
+ * time come from different clocks and any skew would "prove" a live owner
125
+ * recycled. `readRmwOwner` stays FIRST: it throws on unsafe/hardlinked or
126
+ * oversized ownership metadata, and unsafe ownership never licenses removal. */
127
+ function rmwLockIsStale(lock) {
128
+ let stat;
129
+ try {
130
+ stat = lstatSync(lock);
131
+ }
132
+ catch (statError) {
133
+ if (statError.code === "ENOENT")
134
+ return false; // already gone; nothing to take over
135
+ throw statError;
136
+ }
137
+ const owner = readRmwOwner(lock);
138
+ if (owner && owner.host === hostname()) {
139
+ // One shared table with src/serve/writelock.ts (core/procstart.ts), so the
140
+ // two locks can never drift apart; "age" means nothing was proven and the
141
+ // fallback below governs, exactly as for a foreign host.
142
+ const verdict = judgeSameHostOwner(owner, {
143
+ alive: rmwPidAlive,
144
+ self: selfStartToken(),
145
+ tokenOf: startTokenOf,
146
+ platform: process.platform,
147
+ selfPid: process.pid,
148
+ heldNonces: heldLockNonces,
149
+ });
150
+ if (verdict !== "age")
151
+ return verdict === "stale";
152
+ }
153
+ return Date.now() - stat.mtimeMs > 10_000;
154
+ }
155
+ /**
156
+ * Remove a stale `.rmw-lock` under an exclusive sibling claim, mirroring
157
+ * `src/serve/writelock.ts` (issue #287). "judge stale → rm → mkdir" is not
158
+ * atomic: two contenders can both judge the same corpse stale, the first
159
+ * removes it and creates its own, and the second then removes that FRESH lock —
160
+ * two holders, which is exactly the record-loss race this mutex exists to
161
+ * prevent. The claim is a directory (invisible to git, like the commit lock in
162
+ * src/extractors/git.ts) created with exclusive `mkdirSync`.
163
+ *
164
+ * INVARIANT: a stale lock's path can only be vacated by a claim holder (its
165
+ * owner is dead and will never release), and only one contender holds the claim
166
+ * at a time — so nothing can replace the lock between the re-judge and the `rm`.
167
+ * A contender that judged stale a moment earlier re-judges under the claim, sees
168
+ * the winner's fresh live lock, and removes nothing. Accepted residuals: (i) an
169
+ * AGE-judged lock (foreign host, unprobeable owner, or unreadable owner
170
+ * metadata) whose owner is actually alive and releases inside the re-judge→rm
171
+ * window — the same class as the existing 10 s age steal;
172
+ * (ii) two contenders racing to clear a STRANDED claim.
173
+ * A claim's staleness is judged by ITS MTIME against OUR clock — the one clock
174
+ * comparison left in the takeover path — and there is no claim-nonce file to
175
+ * fall back on, so with filesystem/host clock skew beyond RMW_CLAIM_STALE_MS
176
+ * every LIVE claim looks stranded and exclusion among three or more simultaneous
177
+ * contenders degrades to the pre-fix race; without such skew it needs a stranded
178
+ * claim section plus simultaneous removers — and "stranded" is NOT only a crash:
179
+ * a STALL longer than RMW_CLAIM_STALE_MS between the re-judge and the `rm`
180
+ * (SIGSTOP, a VM pause or live migration, a laptop sleep) does it too, and one
181
+ * other contender is then enough — it clears the "stranded" claim, takes over and
182
+ * publishes a LIVE lock, and the resumed claimer's `rm` removes that live lock;
183
+ * the unconditional `rmdirSync(claim)` in the `finally` can likewise remove a
184
+ * SUCCESSOR's claim. Same probability class as the age steal already accepted;
185
+ * (iii) a stranded claim blocks any takeover for up to
186
+ * RMW_CLAIM_STALE_MS, needing the same crash OR over-RMW_CLAIM_STALE_MS stall,
187
+ * and a filesystem clock AHEAD of this host extends that block by the skew;
188
+ * (iv) MIXED VERSIONS: a pre-fix `hunch` ignores `.reclaim`,
189
+ * so the race persists until every writer on the store is upgraded. Returns true
190
+ * when the path may now be free.
191
+ */
192
+ function takeOverRmwLock(lock) {
193
+ const claim = `${lock}.reclaim`;
194
+ try {
195
+ mkdirSync(claim);
196
+ }
197
+ catch (error) {
198
+ if (error.code === "EEXIST") {
199
+ // Mid-takeover elsewhere, or a claimer that crashed inside the section.
200
+ // Clear only a provably stranded claim; either way do not take over this
201
+ // round — the caller falls through to its deadline check and wait.
202
+ try {
203
+ if (Date.now() - lstatSync(claim).mtimeMs > RMW_CLAIM_STALE_MS)
204
+ rmdirSync(claim);
205
+ }
206
+ catch { /* vanished, or another contender cleared it first */ }
207
+ }
208
+ return false;
209
+ }
210
+ try {
211
+ if (!rmwLockIsStale(lock))
212
+ return false; // re-judged from scratch under the claim
213
+ rmSync(lock, { recursive: true, force: true });
214
+ return true;
215
+ }
216
+ finally {
217
+ try {
218
+ rmdirSync(claim);
219
+ }
220
+ catch { /* best effort; a stranded claim is cleared by age */ }
221
+ }
222
+ }
63
223
  function missing(error) {
64
224
  return error.code === "ENOENT";
65
225
  }
@@ -423,7 +583,17 @@ export class JsonStore {
423
583
  }
424
584
  return out;
425
585
  }
426
- for (const name of this.jsonFileNames(kind)) {
586
+ // The canonical home of a record is `<id>.json` (see fileFor). A leftover
587
+ // COPY — `dec_x_BASE_1234.json` from an aborted mergetool, `dec_x.orig.json`,
588
+ // a cloud-sync `dec_x (1).json` — would otherwise yield a second record with
589
+ // the same id and blow up the SQLite rebuild on a duplicate primary key
590
+ // (issue #291). A misnamed file whose canonical name is ABSENT is the
591
+ // record's only home, so it is kept (con_947c578b2c: never silently drop).
592
+ // Names are sorted, so which stray wins in that case is deterministic.
593
+ const names = this.jsonFileNames(kind);
594
+ const present = new Set(names);
595
+ const seenMisnamed = new Set();
596
+ for (const name of names) {
427
597
  let raw;
428
598
  try {
429
599
  const text = this.readContainedFile(directory, join(directory.lexical, name), this.maxBytes(kind));
@@ -444,10 +614,20 @@ export class JsonStore {
444
614
  continue;
445
615
  }
446
616
  const r = schema.safeParse(this.migrate(kind, raw, version));
447
- if (r.success)
448
- out.push(r.data);
449
- else
617
+ if (!r.success) {
450
618
  console.warn(`[hunch] skipping invalid ${kind}/${name}: ${r.error.issues[0]?.message}`);
619
+ continue;
620
+ }
621
+ const id = r.data.id;
622
+ if (typeof id === "string" && `${id}.json` !== name) {
623
+ if (present.has(`${id}.json`) || seenMisnamed.has(id)) {
624
+ console.warn(`[hunch] skipping stray copy ${kind}/${name}: its record id ${id} belongs in ${id}.json`);
625
+ continue;
626
+ }
627
+ console.warn(`[hunch] ${kind}/${name} holds record ${id}; expected file name ${id}.json`);
628
+ seenMisnamed.add(id);
629
+ }
630
+ out.push(r.data);
451
631
  }
452
632
  return out;
453
633
  }
@@ -455,29 +635,37 @@ export class JsonStore {
455
635
  * The long-lived MCP server and CLI hooks write the same `.hunch/` concurrently;
456
636
  * two unsynchronized RMWs over index.json each read the same base array and the
457
637
  * second rename silently erases the first's record. `mkdirSync` is the atomic
458
- * acquire (EEXIST = held). A stale lock (killed process) is taken over by age;
459
- * against a live contender we wait briefly and then refuse the write. Proceeding
460
- * without the lock would reintroduce the record-loss race this mutex exists to
461
- * prevent. */
638
+ * acquire (EEXIST = held). A stale lock (killed process, or one whose pid was
639
+ * RECYCLED) is taken over behind an exclusive `.reclaim` claim — see
640
+ * `takeOverRmwLock`, because judging and removing are two steps and two
641
+ * contenders could otherwise both "win". Against a live contender we wait
642
+ * briefly and then refuse the write. Proceeding without the lock would
643
+ * reintroduce the record-loss race this mutex exists to prevent. */
462
644
  withSingleFileLock(kind, directory, fn) {
463
645
  const lock = join(directory.lexical, ".rmw-lock");
464
646
  const deadline = Date.now() + 2_000;
647
+ const nonce = randomBytes(8).toString("hex");
465
648
  for (;;) {
466
- if (Date.now() >= deadline)
467
- throw new Error(`[hunch] timed out acquiring the ${kind} index lock (still held: ${lock})`);
468
649
  try {
469
650
  mkdirSync(lock);
651
+ // Synchronously adjacent to the successful mkdir: from here on this nonce
652
+ // is one WE hold, so our own later calls never read it as a predecessor's.
653
+ heldLockNonces.add(nonce);
470
654
  // Record ownership inside the already-exclusive directory. A live local
471
655
  // writer may exceed the stale-age heuristic while serializing a large
472
- // index; its PID must prevent a second writer from taking over.
656
+ // index; its PID must prevent a second writer from taking over. The
657
+ // process-instance token (when this platform has one) is what lets a
658
+ // successor tell that PID apart from a recycled one.
473
659
  try {
474
- writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname() }));
660
+ const start = selfStartToken();
661
+ writeFileAtomic(join(lock, "owner.tmp.json"), JSON.stringify({ pid: process.pid, host: hostname(), nonce, ...(start !== null ? { start } : {}) }));
475
662
  }
476
663
  catch (error) {
477
664
  try {
478
665
  rmSync(lock, { recursive: true, force: true });
479
666
  }
480
667
  catch { /* report the ownership failure below */ }
668
+ heldLockNonces.delete(nonce);
481
669
  throw new Error(`[hunch] could not record ownership for the ${kind} index lock: ${error.message}`, { cause: error });
482
670
  }
483
671
  break;
@@ -485,23 +673,13 @@ export class JsonStore {
485
673
  catch (error) {
486
674
  if (error.code !== "EEXIST")
487
675
  throw error;
488
- let stat;
489
- try {
490
- stat = lstatSync(lock);
491
- }
492
- catch (statError) {
493
- if (statError.code === "ENOENT")
494
- continue; // vanished between mkdir and inspect
495
- throw statError;
496
- }
497
- const owner = readRmwOwner(lock);
498
- const stale = owner && owner.host === hostname()
499
- ? !rmwPidAlive(owner.pid)
500
- : Date.now() - stat.mtimeMs > 10_000;
501
- if (stale) {
502
- rmSync(lock, { recursive: true, force: true });
676
+ if (rmwLockIsStale(lock) && takeOverRmwLock(lock))
503
677
  continue;
504
- }
678
+ // The deadline is checked only AFTER an acquire has been attempted: with
679
+ // the check at the top of the loop, a successful takeover could still be
680
+ // followed immediately by a spurious timeout.
681
+ if (Date.now() >= deadline)
682
+ throw new Error(`[hunch] timed out acquiring the ${kind} index lock (still held: ${lock})`);
505
683
  Atomics.wait(RMW_LOCK_WAITER, 0, 0, 25);
506
684
  }
507
685
  }
@@ -509,10 +687,25 @@ export class JsonStore {
509
687
  return fn();
510
688
  }
511
689
  finally {
690
+ // Only remove a lock that is still ours. If we were stolen from (age, or a
691
+ // recycled-pid verdict) the directory now protects another writer, and
692
+ // removing it would put two writers inside the mutex. A record we cannot
693
+ // read, or one without a nonce, tells us nothing — remove it exactly as
694
+ // main always did, so a failed ownership read can never strand the lock.
512
695
  try {
513
- rmSync(lock, { recursive: true, force: true });
696
+ let foreign = false;
697
+ try {
698
+ const current = readRmwOwner(lock);
699
+ foreign = current?.nonce !== undefined && current.nonce !== nonce;
700
+ }
701
+ catch { /* unreadable ownership proves nothing: fall back to main's unconditional remove */ }
702
+ if (!foreign)
703
+ rmSync(lock, { recursive: true, force: true });
514
704
  }
515
705
  catch { /* stale-takeover reclaims it */ }
706
+ finally {
707
+ heldLockNonces.delete(nonce);
708
+ }
516
709
  }
517
710
  }
518
711
  /** Write a single record (validated) to its JSON file / into the index array. */
@@ -14,8 +14,10 @@
14
14
  *
15
15
  * Resolution for a record changed on BOTH sides: human-confirmed beats auto, then
16
16
  * higher provenance.confidence, then the more recently verified, then a deterministic
17
- * content tiebreak (so both developers' merges converge on the same result). Records
18
- * are pure data here — no filesystem access; the CLI reads/writes the files.
17
+ * content tiebreak (so both developers' merges converge on the same result). A lifecycle
18
+ * move only the losing side made (a supersession, a fix, a reopen) is carried onto the
19
+ * winner, so the ranking never undoes it. Records are pure data here — no filesystem
20
+ * access; the CLI reads/writes the files.
19
21
  */
20
22
  type Rec = Record<string, unknown>;
21
23
  export interface MergeResult {
@@ -32,8 +34,9 @@ export declare function mergeHunchJson(baseText: string, oursText: string, their
32
34
  * only if the other side left the record unchanged (a modification beats a delete);
33
35
  * a both-sides change is resolved by `pickWinner`. */
34
36
  export declare function mergeRecordsById(base: Rec[], ours: Rec[], theirs: Rec[]): Rec[];
35
- /** Both sides changed the same record: pick the one to keep. */
36
- export declare function pickWinner(ours: Rec, theirs: Rec): Rec;
37
+ /** Both sides changed the same record: pick the one to keep, then make sure the
38
+ * pick did not undo a lifecycle move only the OTHER side made (see carryLifecycle). */
39
+ export declare function pickWinner(ours: Rec, theirs: Rec, base?: Rec): Rec;
37
40
  /** Stable JSON with recursively SORTED keys, so equality/compare ignore key order. */
38
41
  export declare function canon(v: unknown): string;
39
42
  export {};
@@ -14,8 +14,10 @@
14
14
  *
15
15
  * Resolution for a record changed on BOTH sides: human-confirmed beats auto, then
16
16
  * higher provenance.confidence, then the more recently verified, then a deterministic
17
- * content tiebreak (so both developers' merges converge on the same result). Records
18
- * are pure data here — no filesystem access; the CLI reads/writes the files.
17
+ * content tiebreak (so both developers' merges converge on the same result). A lifecycle
18
+ * move only the losing side made (a supersession, a fix, a reopen) is carried onto the
19
+ * winner, so the ranking never undoes it. Records are pure data here — no filesystem
20
+ * access; the CLI reads/writes the files.
19
21
  */
20
22
  import { LEDGER_SCHEMA_VERSION, LedgerSchema, mergeLedgers } from "./changeLedger.js";
21
23
  /** Merge three versions of one `.hunch` JSON file (an index array OR a single
@@ -98,7 +100,7 @@ export function mergeRecordsById(base, ours, theirs) {
98
100
  else if (bv && canon(tv) === canon(bv))
99
101
  out.push(ov); // only ours changed
100
102
  else
101
- out.push(pickWinner(ov, tv)); // both changed (or both added differently)
103
+ out.push(pickWinner(ov, tv, bv)); // both changed (or both added differently)
102
104
  }
103
105
  else if (ov && !tv) {
104
106
  // theirs lacks it: a delete (bv present & ours unchanged) loses to a keep/modify
@@ -115,8 +117,14 @@ export function mergeRecordsById(base, ours, theirs) {
115
117
  }
116
118
  return out;
117
119
  }
118
- /** Both sides changed the same record: pick the one to keep. */
119
- export function pickWinner(ours, theirs) {
120
+ /** Both sides changed the same record: pick the one to keep, then make sure the
121
+ * pick did not undo a lifecycle move only the OTHER side made (see carryLifecycle). */
122
+ export function pickWinner(ours, theirs, base) {
123
+ const winner = pickWhole(ours, theirs);
124
+ return carryLifecycle(winner, winner === ours ? theirs : ours, base);
125
+ }
126
+ /** Whole-record ranking: provenance first, lifecycle evidence, then a content tiebreak. */
127
+ function pickWhole(ours, theirs) {
120
128
  const oc = humanConfirmed(ours);
121
129
  const tc = humanConfirmed(theirs);
122
130
  if (oc !== tc)
@@ -172,6 +180,58 @@ function closureEvidence(r) {
172
180
  }
173
181
  return n;
174
182
  }
183
+ /** The provenance ranking picks a whole record, and a lifecycle move never touches
184
+ * provenance: `hunch supersede` writes only status/superseded_by/valid_to, while
185
+ * `hunch review --accept` on the other branch raises provenance. The reviewed copy then
186
+ * won outright and the superseded decision came back live beside its successor, with no
187
+ * conflict reported (#290). Ranking closure evidence first would only move the loss: the
188
+ * review would be dropped, and a genuine reopen would lose to any provenance-only edit.
189
+ *
190
+ * So the lifecycle is merged three-way on its own. When the winner left it as the base
191
+ * had it and the loser moved it, the move is carried onto the winner — a closure or a
192
+ * reopen alike. It moves as ONE unit (status with its evidence), never blended: if both
193
+ * sides moved it differently, the winner's stands. With no base (an add on both sides,
194
+ * the overlay migrate) nothing says which side moved — a live copy may be a reopen or
195
+ * simply older — so the whole-record ranking stands, as it did before. A pure function of (winner, loser, base), and the winner
196
+ * is side-independent, so A-merges-B and B-merges-A still agree. */
197
+ function carryLifecycle(winner, loser, base) {
198
+ if (!base)
199
+ return winner;
200
+ const was = canon(lifecycle(base));
201
+ if (canon(lifecycle(winner)) !== was || canon(lifecycle(loser)) === was)
202
+ return winner;
203
+ const out = { ...winner };
204
+ for (const key of LIFECYCLE_KEYS) {
205
+ if (key in loser)
206
+ out[key] = loser[key];
207
+ else
208
+ delete out[key];
209
+ }
210
+ if (isRec(loser.lineage)) {
211
+ const lineage = isRec(winner.lineage) ? { ...winner.lineage } : {};
212
+ for (const key of LIFECYCLE_LINEAGE_KEYS) {
213
+ if (key in loser.lineage)
214
+ lineage[key] = loser.lineage[key];
215
+ else
216
+ delete lineage[key];
217
+ }
218
+ out.lineage = lineage;
219
+ }
220
+ return out;
221
+ }
222
+ const LIFECYCLE_KEYS = ["status", "superseded_by", "valid_to"];
223
+ const LIFECYCLE_LINEAGE_KEYS = ["fixed_commit", "spawned_decision", "spawned_constraint"];
224
+ /** The lifecycle unit of a record, absent and null alike normalised to null so a side
225
+ * that merely materialised a default does not read as having moved it. */
226
+ function lifecycle(r) {
227
+ const lineage = isRec(r.lineage) ? r.lineage : {};
228
+ const out = {};
229
+ for (const key of LIFECYCLE_KEYS)
230
+ out[key] = r[key] ?? null;
231
+ for (const key of LIFECYCLE_LINEAGE_KEYS)
232
+ out[`lineage.${key}`] = lineage[key] ?? null;
233
+ return out;
234
+ }
175
235
  function parseSide(text) {
176
236
  const trimmed = (text ?? "").trim();
177
237
  if (!trimmed)
@@ -36,6 +36,16 @@ export function movePublicMemoryToPrivate(pub, priv) {
36
36
  if (pubRecs.length === 0)
37
37
  continue;
38
38
  const privRecs = priv.loadAll(kind);
39
+ // Same proof for the OVERLAY side: replaceAll below deletes every overlay
40
+ // file whose id is not in the merged set, and a record the loader skipped
41
+ // is never in it — so an unloadable overlay record (corrupt, or written by
42
+ // a newer Hunch) would be deleted as "stale" and the deletion pushed to the
43
+ // shared remote (issue #289).
44
+ const privRawCount = priv.rawRecordCount(kind);
45
+ if (privRawCount !== privRecs.length) {
46
+ throw new Error(`refusing to migrate ${kind}: ${privRawCount - privRecs.length} overlay record(s) failed to load (see the warnings above) `
47
+ + `and would be deleted from the overlay by the merge. Fix or remove them (or update Hunch if they come from a newer version), then re-run \`hunch private --migrate\`.`);
48
+ }
39
49
  // base=[] so both sides' records are treated as additions; collisions resolved
40
50
  // by pickWinner. Public is "theirs", private "ours" — order is irrelevant since
41
51
  // pickWinner is symmetric for equal-provenance records.
@@ -97,12 +97,23 @@ export function renderAdoptedDoc(doc, content, decisions, reconciled = null) {
97
97
  out.push("## 🩹 Reconciled overview", "", reconciled.trim(), "", "_LLM-drafted from the current decisions (subscription); the graph corrections below are the deterministic, authoritative layer._", "");
98
98
  }
99
99
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
- for (const raw of lines) {
100
+ // Parse the WHOLE document once, like computeDrift and the pre-edit hook: a
101
+ // fence spans lines, so a per-line parse sees every line as outside any fence
102
+ // and would heal (and inject a callout into) a documentation example.
103
+ const anchorsByLine = new Map();
104
+ for (const a of parseDocAnchors(content)) {
105
+ const at = anchorsByLine.get(a.line);
106
+ if (at)
107
+ at.push(a);
108
+ else
109
+ anchorsByLine.set(a.line, [a]);
110
+ }
111
+ for (const [i, raw] of lines.entries()) {
101
112
  // Heal EVERY stale pin on the line, and only inside its own marker — a bare
102
113
  // decision id in the surrounding prose must never be rewritten.
103
114
  let line = raw;
104
115
  const corrections = [];
105
- for (const a of parseDocAnchors(raw)) {
116
+ for (const a of anchorsByLine.get(i + 1) ?? []) {
106
117
  if (!a.pin)
107
118
  continue;
108
119
  const pinned = byId.get(a.pin);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.39.2",
3
+ "version": "1.40.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.39.2",
10
+ "version": "1.40.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.39.2",
16
+ "version": "1.40.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {