sticky-note-cli 2.5.0 → 2.5.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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://www.npmjs.com/package/sticky-note"><img src="https://img.shields.io/npm/v/sticky-note.svg?style=flat-square&color=f59e0b" alt="npm version" /></a>
12
+ <a href="https://www.npmjs.com/package/sticky-note-cli"><img src="https://img.shields.io/npm/v/sticky-note-cli.svg?style=flat-square&color=f59e0b" alt="npm version" /></a>
13
13
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square" alt="MIT License" /></a>
14
14
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D16-brightgreen.svg?style=flat-square" alt="Node.js >= 16" /></a>
15
15
  <a href="#supported-tools"><img src="https://img.shields.io/badge/works%20with-Claude%20%C2%B7%20Copilot%20%C2%B7%20Codex-8b5cf6?style=flat-square" alt="Works with Claude, Copilot, Codex" /></a>
@@ -30,24 +30,23 @@
30
30
 
31
31
  ## The Problem
32
32
 
33
- Every developer using AI coding assistants starts every session from zero.
34
- The handoff between teammates happens in Slack and stand-ups — everywhere
35
- except inside the AI tool where the work actually happened.
33
+ I have seen many agent-to-agent to orchestration systems and frameworks, but I still like working with other people.
34
+ Other people that ALSO use agents to develop. I couldn't get my agent to understand what my friends agents were doing.
35
+ So we built Sticky Note.
36
36
 
37
37
  ## The Solution
38
38
 
39
39
  Sticky Note captures what happened in each AI session (files touched, status,
40
40
  narrative, failed approaches) and surfaces it to teammates automatically on
41
- their next session start — ranked by relevance. No dashboards. No extra tools.
42
- Just shared files in your repo.
41
+ their next session by relevance.
43
42
 
44
43
  ---
45
44
 
46
45
  ## What's New in V2.5
47
46
 
48
47
  - **Smart injection (two-tier)**: Stuck threads injected eagerly at session start; all other threads injected lazily when you first touch a file they authored — via built-in git blame attribution
49
- - **Thread resume (local)**: `npx sticky-note resume-thread --query "auth fix" --user alice` — natural language thread discovery with text similarity + file attribution ranking
50
- - **Built-in attribution engine**: `npx sticky-note get-line-attribution --file src/auth.ts` — maps file lines to commit SHAs via git blame, resolves to threads with line ranges. No external dependencies.
48
+ - **Thread resume (local)**: `npx sticky-note-cli resume-thread --query "auth fix" --user alice` — natural language thread discovery with text similarity + file attribution ranking
49
+ - **Built-in attribution engine**: `npx sticky-note-cli get-line-attribution --file src/auth.ts` — maps file lines to commit SHAs via git blame, resolves to threads with line ranges. No external dependencies.
51
50
  - **Git Notes storage**: Session attribution stored in `refs/notes/sticky-note` — survives rebase/amend
52
51
  - **Three-tier SHA resolution**: Git Notes → Audit JSONL → File+date heuristic — attribution never breaks
53
52
  - **PreToolUse hook**: New hook fires before each tool call for lazy injection with line-range detail
@@ -59,7 +58,7 @@ Just shared files in your repo.
59
58
  - **Relevance scoring**: Context injected based on file overlap, branch match, and recency
60
59
  - **Richer threads**: Narrative summaries, failed approaches, work type, activities
61
60
  - **Tombstone expiry**: Old threads are automatically cleaned up via `gc`
62
- - **Presence tracking**: See who's currently active with `npx sticky-note who`
61
+ - **Presence tracking**: See who's currently active with `npx sticky-note-cli who`
63
62
  - **Codex support**: Wrapper script for post-session capture
64
63
  - **Separate config**: Team settings in `sticky-note-config.json`
65
64
 
@@ -70,7 +69,7 @@ Just shared files in your repo.
70
69
  ### 1. Install
71
70
 
72
71
  ```bash
73
- npx sticky-note init
72
+ npx sticky-note-cli init
74
73
  ```
75
74
 
76
75
  This runs an interactive setup that:
@@ -111,7 +110,7 @@ that's always available as a static file.
111
110
 
112
111
  ### 1. Static Instruction Files (always available)
113
112
 
114
- `npx sticky-note init` deploys AI instruction files that teach each tool
113
+ `npx sticky-note-cli init` deploys AI instruction files that teach each tool
115
114
  how to interact with Sticky Note:
116
115
 
117
116
  | File | Consumed by | Installed to |
@@ -121,7 +120,7 @@ how to interact with Sticky Note:
121
120
 
122
121
  These files contain thread field references, status icons, resume
123
122
  instructions, display formats, and query examples. They're wrapped in
124
- `<!-- sticky-note:start/end -->` markers so `npx sticky-note update` can
123
+ `<!-- sticky-note:start/end -->` markers so `npx sticky-note-cli update` can
125
124
  refresh them without overwriting your own content.
126
125
 
127
126
  ### 2. Session Start Hook (`session-start.js`)
@@ -169,13 +168,13 @@ Runs **before each tool call**. Lazy injection tier:
169
168
 
170
169
  No external dependencies — uses built-in git blame.
171
170
 
172
- ### 4. Resume Flow (`npx sticky-note resume <id>`)
171
+ ### 4. Resume Flow (`npx sticky-note-cli resume <id>`)
173
172
 
174
173
  A manual trigger that enables **cross-tool handoff** (e.g., Claude Code →
175
174
  Copilot CLI):
176
175
 
177
176
  ```
178
- npx sticky-note resume <thread-id>
177
+ npx sticky-note-cli resume <thread-id>
179
178
  ```
180
179
 
181
180
  1. Writes the thread UUID to a `.sticky-resume` signal file.
@@ -259,7 +258,7 @@ the thread's `handoff_summary` field.
259
258
  open → stale (auto, after stale_days with no activity)
260
259
  open → closed (on session end)
261
260
  stuck → closed (on session end or manual)
262
- closed → open (via `npx sticky-note resume <id>`)
261
+ closed → open (via `npx sticky-note-cli resume <id>`)
263
262
  closed → expired (auto, tombstoned by gc after stale_days)
264
263
  ```
265
264
 
@@ -311,34 +310,34 @@ CLAUDE.md # AI instructions for Claude Code
311
310
  ## CLI Commands
312
311
 
313
312
  ```bash
314
- npx sticky-note init # Interactive setup
315
- npx sticky-note init --codex # Setup with Codex wrapper
316
- npx sticky-note update # Update hook scripts (preserves data)
317
- npx sticky-note status # Diagnostic report (includes attribution health)
318
- npx sticky-note threads # List threads with status icons
319
- npx sticky-note resume # List resumable threads
320
- npx sticky-note resume <id> # Resume a previous thread
321
- npx sticky-note resume --clear # Cancel active resume
322
- npx sticky-note resume-thread # Smart resume: --query, --user, --file (V2.5)
323
- npx sticky-note audit # Query merged audit trail (all users)
324
- npx sticky-note who # Show active and recent team members
325
- npx sticky-note switch <branch> # Safe branch switch (auto-stashes data)
326
- npx sticky-note gc # Tombstone expired threads
327
- npx sticky-note reset # Wipe all threads (--force, --keep-audit)
328
- npx sticky-note get-line-attribution # File→thread attribution with line ranges (V2.5)
329
- npx sticky-note checkpoint # Set work-topic checkpoint for attribution (V2.5)
330
- npx sticky-note --version # Show version
331
- npx sticky-note --help # Show help
313
+ npx sticky-note-cli init # Interactive setup
314
+ npx sticky-note-cli init --codex # Setup with Codex wrapper
315
+ npx sticky-note-cli update # Update hook scripts (preserves data)
316
+ npx sticky-note-cli status # Diagnostic report (includes attribution health)
317
+ npx sticky-note-cli threads # List threads with status icons
318
+ npx sticky-note-cli resume # List resumable threads
319
+ npx sticky-note-cli resume <id> # Resume a previous thread
320
+ npx sticky-note-cli resume --clear # Cancel active resume
321
+ npx sticky-note-cli resume-thread # Smart resume: --query, --user, --file (V2.5)
322
+ npx sticky-note-cli audit # Query merged audit trail (all users)
323
+ npx sticky-note-cli who # Show active and recent team members
324
+ npx sticky-note-cli switch <branch> # Safe branch switch (auto-stashes data)
325
+ npx sticky-note-cli gc # Tombstone expired threads
326
+ npx sticky-note-cli reset # Wipe all threads (--force, --keep-audit)
327
+ npx sticky-note-cli get-line-attribution # File→thread attribution with line ranges (V2.5)
328
+ npx sticky-note-cli checkpoint # Set work-topic checkpoint for attribution (V2.5)
329
+ npx sticky-note-cli --version # Show version
330
+ npx sticky-note-cli --help # Show help
332
331
  ```
333
332
 
334
333
  ### Audit Filters
335
334
 
336
335
  ```bash
337
- npx sticky-note audit --user alice
338
- npx sticky-note audit --file src/auth.ts
339
- npx sticky-note audit --since 2025-01-01
340
- npx sticky-note audit --session abc-123
341
- npx sticky-note audit --limit 100
336
+ npx sticky-note-cli audit --user alice
337
+ npx sticky-note-cli audit --file src/auth.ts
338
+ npx sticky-note-cli audit --since 2025-01-01
339
+ npx sticky-note-cli audit --session abc-123
340
+ npx sticky-note-cli audit --limit 100
342
341
  ```
343
342
 
344
343
  ---
@@ -388,7 +387,7 @@ All tools call the same JavaScript hooks and share the same data files.
388
387
 
389
388
  ## Concurrent Usage & Merge Strategy
390
389
 
391
- `npx sticky-note init` adds a `.gitattributes` rule:
390
+ `npx sticky-note-cli init` adds a `.gitattributes` rule:
392
391
 
393
392
  ```
394
393
  .sticky-note/sticky-note.json merge=union
@@ -412,9 +411,9 @@ Use one of these approaches:
412
411
 
413
412
  ```bash
414
413
  # Recommended: sticky-note CLI wrapper
415
- npx sticky-note switch <branch>
414
+ npx sticky-note-cli switch <branch>
416
415
 
417
- # Or: git alias (set up by npx sticky-note init)
416
+ # Or: git alias (set up by npx sticky-note-cli init)
418
417
  git sw <branch>
419
418
  ```
420
419
 
@@ -434,13 +433,13 @@ A: `merge=union` in `.gitattributes` handles most cases automatically.
434
433
 
435
434
  **Q: Can I close a thread manually?**
436
435
  A: Edit `sticky-note.json` and change the thread's `status` to `"closed"`,
437
- or run `npx sticky-note gc` to tombstone expired threads.
436
+ or run `npx sticky-note-cli gc` to tombstone expired threads.
438
437
 
439
438
  **Q: Does this work offline?**
440
439
  A: Yes. Everything is local until you `git push`.
441
440
 
442
441
  **Q: How do I set up Codex?**
443
- A: Run `npx sticky-note init --codex`, then alias the wrapper:
442
+ A: Run `npx sticky-note-cli init --codex`, then alias the wrapper:
444
443
  `alias sticky-codex=".claude/hooks/sticky-codex.sh"`
445
444
 
446
445
  ---
package/bin/cli.js CHANGED
@@ -21,7 +21,7 @@ const { execSync, execFileSync } = require("child_process");
21
21
  // Constants
22
22
  // ──────────────────────────────────────────────
23
23
 
24
- const VERSION = "2.5.0";
24
+ const VERSION = "2.5.2";
25
25
 
26
26
  const HOOK_FILES = [
27
27
  "sticky-utils.js",
@@ -537,21 +537,44 @@ async function cmdInit() {
537
537
 
538
538
  // Add .gitattributes merge strategy
539
539
  const gitattrsPath = path.join(process.cwd(), ".gitattributes");
540
- const mergeRule = ".sticky-note/sticky-note.json merge=union";
540
+ const oldMergeRule = ".sticky-note/sticky-note.json merge=union";
541
+ const mergeRule = ".sticky-note/sticky-note.json merge=sticky-note";
541
542
  let gitattrsContent = "";
542
543
  if (fs.existsSync(gitattrsPath)) {
543
544
  gitattrsContent = fs.readFileSync(gitattrsPath, "utf-8");
544
545
  }
545
- if (!gitattrsContent.includes(mergeRule)) {
546
+ // Upgrade from merge=union to custom merge driver
547
+ if (gitattrsContent.includes(oldMergeRule)) {
548
+ gitattrsContent = gitattrsContent.replace(oldMergeRule, mergeRule);
549
+ fs.writeFileSync(gitattrsPath, gitattrsContent, "utf-8");
550
+ print(" [OK] .gitattributes upgraded (merge=union -> merge=sticky-note)");
551
+ } else if (!gitattrsContent.includes(mergeRule)) {
546
552
  const addition = gitattrsContent.endsWith("\n") || gitattrsContent === ""
547
- ? `\n# Sticky Note - auto-merge threads from both sides\n${mergeRule}\n`
548
- : `\n\n# Sticky Note - auto-merge threads from both sides\n${mergeRule}\n`;
553
+ ? `\n# Sticky Note - JSON-aware thread merge\n${mergeRule}\n`
554
+ : `\n\n# Sticky Note - JSON-aware thread merge\n${mergeRule}\n`;
549
555
  fs.appendFileSync(gitattrsPath, addition);
550
- print(" [OK] .gitattributes updated (merge=union)");
556
+ print(" [OK] .gitattributes updated (merge=sticky-note)");
551
557
  } else {
552
558
  print(" ⏭️ .gitattributes already configured");
553
559
  }
554
560
 
561
+ // Install merge driver script and git config
562
+ const mergeDriverSrc = path.join(TEMPLATES_DIR, "merge-driver.js");
563
+ const mergeDriverDest = path.join(stickyDir, "merge-driver.js");
564
+ copyFile(mergeDriverSrc, mergeDriverDest);
565
+ print(" [OK] .sticky-note/merge-driver.js installed");
566
+ try {
567
+ execFileSync("git", ["config", "merge.sticky-note.name", "Sticky Note thread merge"], {
568
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
569
+ });
570
+ execFileSync("git", ["config", "merge.sticky-note.driver", "node .sticky-note/merge-driver.js %O %A %B"], {
571
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
572
+ });
573
+ print(" [OK] git merge driver configured (merge.sticky-note)");
574
+ } catch (_) {
575
+ print(" ⏭️ Could not configure git merge driver (non-fatal)");
576
+ }
577
+
555
578
  // Add git aliases for safe branch switching
556
579
  try {
557
580
  const swAlias = '!f() { npx sticky-note switch "$@"; }; f';
@@ -668,6 +691,38 @@ function cmdUpdate() {
668
691
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
669
692
  }
670
693
 
694
+ // Update merge driver
695
+ const mergeDriverSrc = path.join(TEMPLATES_DIR, "merge-driver.js");
696
+ const mergeDriverDest = path.join(stickyDir, "merge-driver.js");
697
+ copyFile(mergeDriverSrc, mergeDriverDest);
698
+ print(" [OK] .sticky-note/merge-driver.js updated");
699
+
700
+ // Upgrade .gitattributes from merge=union to custom driver
701
+ const gitattrsPath = path.join(process.cwd(), ".gitattributes");
702
+ if (fs.existsSync(gitattrsPath)) {
703
+ let gitattrsContent = fs.readFileSync(gitattrsPath, "utf-8");
704
+ const oldRule = ".sticky-note/sticky-note.json merge=union";
705
+ const newRule = ".sticky-note/sticky-note.json merge=sticky-note";
706
+ if (gitattrsContent.includes(oldRule)) {
707
+ gitattrsContent = gitattrsContent.replace(oldRule, newRule);
708
+ fs.writeFileSync(gitattrsPath, gitattrsContent, "utf-8");
709
+ print(" [OK] .gitattributes upgraded (merge=union -> merge=sticky-note)");
710
+ }
711
+ }
712
+
713
+ // Ensure git merge driver config is set
714
+ try {
715
+ execFileSync("git", ["config", "merge.sticky-note.name", "Sticky Note thread merge"], {
716
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
717
+ });
718
+ execFileSync("git", ["config", "merge.sticky-note.driver", "node .sticky-note/merge-driver.js %O %A %B"], {
719
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
720
+ });
721
+ print(" [OK] git merge driver configured (merge.sticky-note)");
722
+ } catch (_) {
723
+ // non-fatal
724
+ }
725
+
671
726
  print(`\n ✨ Scripts updated to v${VERSION}`);
672
727
 
673
728
  // Update instruction files (section-based: only replaces between markers)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.5.0",
3
+ "version": "2.5.2",
4
4
  "description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -50,7 +50,7 @@ try {
50
50
  gitNotes = null;
51
51
  }
52
52
  const {
53
- getMemoryPath, loadJson, saveJson, getUser, getBranch,
53
+ getMemoryPath, loadJson, saveJson, saveMemoryMerged, getUser, getBranch,
54
54
  getResumeThreadId, findThreadById, getSessionId,
55
55
  appendAuditLine, detectTool, getConfigPath,
56
56
  isThreadInjected, markThreadInjected, normalizeSep,
@@ -391,7 +391,7 @@ function main() {
391
391
 
392
392
  if (memoryDirty) {
393
393
  try {
394
- saveJson(getMemoryPath(), memory);
394
+ saveMemoryMerged(getMemoryPath(), memory);
395
395
  } catch (_) {
396
396
  // ignore
397
397
  }
@@ -19,12 +19,13 @@ function _safeExit() {
19
19
  process.exit(0);
20
20
  }
21
21
 
22
- let getMemoryPath, loadJson, saveJson, appendAuditLine, getUser, detectTool, getSessionId;
22
+ let getMemoryPath, loadJson, saveJson, saveMemoryMerged, appendAuditLine, getUser, detectTool, getSessionId;
23
23
  try {
24
24
  ({
25
25
  getMemoryPath,
26
26
  loadJson,
27
27
  saveJson,
28
+ saveMemoryMerged,
28
29
  appendAuditLine,
29
30
  getUser,
30
31
  detectTool,
@@ -118,7 +119,7 @@ function main() {
118
119
  tool: toolName,
119
120
  });
120
121
 
121
- saveJson(memoryPath, memory);
122
+ saveMemoryMerged(memoryPath, memory);
122
123
  const statusMsg = `[STICKY-NOTE] Marked thread as STUCK - ${errorMsg.substring(0, 80)}`;
123
124
  try {
124
125
  process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
@@ -29,6 +29,7 @@ const {
29
29
  getMemoryPath,
30
30
  loadJson,
31
31
  saveJson,
32
+ saveMemoryMerged,
32
33
  appendAuditLine,
33
34
  getUser,
34
35
  getBranch,
@@ -132,7 +133,7 @@ function main() {
132
133
  reason: reason ? reason.substring(0, 200) : "stop_checkpoint",
133
134
  });
134
135
 
135
- saveJson(memoryPath, memory);
136
+ saveMemoryMerged(memoryPath, memory);
136
137
  const reasonStr = reason ? reason.substring(0, 60) : "checkpoint";
137
138
  const statusMsg = `[STICKY-NOTE] Session stopped - handoff saved (${reasonStr})`;
138
139
  try {
@@ -36,6 +36,7 @@ const {
36
36
  getAllAuditPaths,
37
37
  loadJson,
38
38
  saveJson,
39
+ saveMemoryMerged,
39
40
  appendAuditLine,
40
41
  getUser,
41
42
  getBranch,
@@ -932,7 +933,7 @@ function main() {
932
933
  clearHeadFile();
933
934
  clearInjectedSet();
934
935
  }
935
- saveJson(memoryPath, memory);
936
+ saveMemoryMerged(memoryPath, memory);
936
937
 
937
938
  const fileCount = filesTouched.length;
938
939
  const commitCount = commitShas.length;
@@ -34,6 +34,7 @@ const {
34
34
  migrateAuditAndPresence,
35
35
  loadJson,
36
36
  saveJson,
37
+ saveMemoryMerged,
37
38
  appendAuditLine,
38
39
  getUser,
39
40
  getSessionId,
@@ -270,7 +271,7 @@ function main() {
270
271
  resumed_from: prevSession,
271
272
  });
272
273
  resumedThread.resume_chain = chain;
273
- saveJson(memoryPath, memory);
274
+ saveMemoryMerged(memoryPath, memory);
274
275
  }
275
276
  }
276
277
 
@@ -293,7 +294,7 @@ function main() {
293
294
  session_id: sessionId,
294
295
  });
295
296
 
296
- saveJson(memoryPath, memory);
297
+ saveMemoryMerged(memoryPath, memory);
297
298
 
298
299
  // ── Assemble output ───────────────────────────────────
299
300
  const parts = [];
@@ -205,6 +205,31 @@ function saveJson(filePath, data) {
205
205
  fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
206
206
  }
207
207
 
208
+ /**
209
+ * Merge-on-write for the memory file. Re-reads the file from disk before
210
+ * writing and preserves any threads (by ID) that exist on disk but not in
211
+ * the in-memory copy. This prevents concurrent sessions or un-pulled commits
212
+ * from silently dropping other users' threads.
213
+ */
214
+ function saveMemoryMerged(memoryPath, memory) {
215
+ try {
216
+ const onDisk = loadJson(memoryPath, { threads: [] });
217
+ const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads : [];
218
+ const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads : [];
219
+ const inMemoryIds = new Set(inMemoryThreads.map((t) => t.id));
220
+
221
+ for (const diskThread of onDiskThreads) {
222
+ if (diskThread.id && !inMemoryIds.has(diskThread.id)) {
223
+ memory.threads.push(diskThread);
224
+ }
225
+ }
226
+ } catch (_) {
227
+ // If re-read fails, proceed with what we have
228
+ }
229
+
230
+ saveJson(memoryPath, memory);
231
+ }
232
+
208
233
  function appendAuditLine(entry) {
209
234
  const filePath = getUserAuditPath();
210
235
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -618,6 +643,7 @@ module.exports = {
618
643
  clearResumeSignal,
619
644
  loadJson,
620
645
  saveJson,
646
+ saveMemoryMerged,
621
647
  appendAuditLine,
622
648
  getUser,
623
649
  detectTool,
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Git custom merge driver for sticky-note.json
4
+ *
5
+ * Merges threads by ID instead of line-based union merge.
6
+ * For threads present in both sides, the version with the latest
7
+ * last_activity_at wins. Threads unique to either side are preserved.
8
+ *
9
+ * Usage (called by git automatically):
10
+ * node .sticky-note/merge-driver.js %O %A %B
11
+ *
12
+ * %O = ancestor, %A = ours (result written here), %B = theirs
13
+ */
14
+
15
+ const fs = require("fs");
16
+
17
+ function loadJson(filePath) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
20
+ } catch (_) {
21
+ return { version: "2", project: "", threads: [] };
22
+ }
23
+ }
24
+
25
+ function getTimestamp(thread) {
26
+ const ts = thread.last_activity_at || thread.closed_at || thread.created_at || "";
27
+ if (!ts) return 0;
28
+ const t = new Date(ts).getTime();
29
+ return isNaN(t) ? 0 : t;
30
+ }
31
+
32
+ function mergeThreads(ancestor, ours, theirs) {
33
+ const ancestorMap = new Map((ancestor.threads || []).map((t) => [t.id, t]));
34
+ const oursMap = new Map((ours.threads || []).map((t) => [t.id, t]));
35
+ const theirsMap = new Map((theirs.threads || []).map((t) => [t.id, t]));
36
+
37
+ const allIds = new Set([...oursMap.keys(), ...theirsMap.keys()]);
38
+ const merged = [];
39
+
40
+ for (const id of allIds) {
41
+ const inOurs = oursMap.has(id);
42
+ const inTheirs = theirsMap.has(id);
43
+ const inAncestor = ancestorMap.has(id);
44
+
45
+ if (inOurs && inTheirs) {
46
+ // Both sides have it — pick the most recently active version
47
+ const o = oursMap.get(id);
48
+ const t = theirsMap.get(id);
49
+ merged.push(getTimestamp(o) >= getTimestamp(t) ? o : t);
50
+ } else if (inOurs && !inTheirs) {
51
+ // Only in ours — keep unless theirs deliberately removed it
52
+ if (inAncestor) {
53
+ // Was in ancestor, theirs removed it — respect the deletion
54
+ } else {
55
+ merged.push(oursMap.get(id));
56
+ }
57
+ } else if (!inOurs && inTheirs) {
58
+ // Only in theirs — keep unless ours deliberately removed it
59
+ if (inAncestor) {
60
+ // Was in ancestor, ours removed it — respect the deletion
61
+ } else {
62
+ merged.push(theirsMap.get(id));
63
+ }
64
+ }
65
+ }
66
+
67
+ return merged;
68
+ }
69
+
70
+ // ── Main ──────────────────────────────────────────────────
71
+
72
+ const [ancestorPath, oursPath, theirsPath] = process.argv.slice(2);
73
+
74
+ if (!ancestorPath || !oursPath || !theirsPath) {
75
+ process.stderr.write("Usage: merge-driver.js <ancestor> <ours> <theirs>\n");
76
+ process.exit(1);
77
+ }
78
+
79
+ const ancestor = loadJson(ancestorPath);
80
+ const ours = loadJson(oursPath);
81
+ const theirs = loadJson(theirsPath);
82
+
83
+ const mergedThreads = mergeThreads(ancestor, ours, theirs);
84
+
85
+ // Use ours as the base for non-thread fields (version, project, etc.)
86
+ const result = { ...ours, threads: mergedThreads };
87
+
88
+ fs.writeFileSync(oursPath, JSON.stringify(result, null, 2) + "\n", "utf-8");
89
+ process.exit(0);