sticky-note-cli 2.8.2 → 2.9.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.
package/bin/cli.js CHANGED
@@ -326,6 +326,62 @@ function countJsonlLines(filePath) {
326
326
  }
327
327
  }
328
328
 
329
+ // ──────────────────────────────────────────────
330
+ // Copilot CLI MCP config helpers
331
+ // ──────────────────────────────────────────────
332
+
333
+ function getCopilotCliConfigDir() {
334
+ if (process.env.COPILOT_HOME) return process.env.COPILOT_HOME;
335
+ const home = process.env.HOME || process.env.USERPROFILE;
336
+ if (!home) return null;
337
+ return path.join(home, ".copilot");
338
+ }
339
+
340
+ function getCopilotCliMcpConfigPath() {
341
+ const dir = getCopilotCliConfigDir();
342
+ return dir ? path.join(dir, "mcp-config.json") : null;
343
+ }
344
+
345
+ /**
346
+ * Register an MCP server in Copilot CLI's ~/.copilot/mcp-config.json.
347
+ * Only writes if the ~/.copilot/ directory exists (Copilot CLI is installed).
348
+ */
349
+ function ensureMcpInCopilotCliConfig(serverName, serverConfig) {
350
+ try {
351
+ const configDir = getCopilotCliConfigDir();
352
+ if (!configDir || !fs.existsSync(configDir)) return false;
353
+
354
+ const configPath = path.join(configDir, "mcp-config.json");
355
+ let config = {};
356
+ if (fs.existsSync(configPath)) {
357
+ try {
358
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
359
+ } catch (_) {
360
+ config = {};
361
+ }
362
+ }
363
+
364
+ config.mcpServers = config.mcpServers || {};
365
+ const existing = config.mcpServers[serverName];
366
+ // Copilot CLI requires "tools" field — fix entries missing it
367
+ if (existing) {
368
+ if (!existing.tools) {
369
+ existing.tools = ["*"];
370
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
371
+ return true;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ if (!serverConfig.tools) serverConfig.tools = ["*"];
377
+ config.mcpServers[serverName] = serverConfig;
378
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
379
+ return true;
380
+ } catch (_) {
381
+ return false;
382
+ }
383
+ }
384
+
329
385
  // ──────────────────────────────────────────────
330
386
  // Auto-detection
331
387
  // ──────────────────────────────────────────────
@@ -353,6 +409,28 @@ function detectMcpServers() {
353
409
  } catch (err) { debugLog("detectMcpServers settings.json: " + (err.message || err)); }
354
410
  }
355
411
 
412
+ // Also detect from Copilot CLI's ~/.copilot/mcp-config.json
413
+ const copilotMcpPath = getCopilotCliMcpConfigPath();
414
+ if (copilotMcpPath && fs.existsSync(copilotMcpPath)) {
415
+ try {
416
+ const copilotMcp = JSON.parse(fs.readFileSync(copilotMcpPath, "utf-8"));
417
+ const mcpServers = copilotMcp.mcpServers || {};
418
+ for (const [name, config] of Object.entries(mcpServers)) {
419
+ if (servers.has(name)) continue; // repo-level takes precedence
420
+ const entry = {
421
+ name,
422
+ type: config.type || config.transport || "unknown",
423
+ source: "~/.copilot/mcp-config.json",
424
+ };
425
+ if (config.command) entry.command = config.command;
426
+ if (config.args) entry.args = config.args;
427
+ if (config.env) entry.env = config.env;
428
+ if (config.url) entry.url = config.url;
429
+ servers.set(name, entry);
430
+ }
431
+ } catch (err) { debugLog("detectMcpServers copilot mcp-config: " + (err.message || err)); }
432
+ }
433
+
356
434
  const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
357
435
  if (fs.existsSync(localSettingsPath)) {
358
436
  try {
@@ -456,13 +534,18 @@ async function syncMcpFromStickyNote(rl) {
456
534
  }
457
535
 
458
536
  for (const s of missing) {
459
- const config = { type: s.type || "stdio" };
460
- if (s.command) config.command = s.command;
461
- if (s.args) config.args = s.args;
462
- if (s.env) config.env = s.env;
463
- if (s.url) config.url = s.url;
464
- mcpJson.mcpServers[s.name] = config;
537
+ const serverEntry = { type: s.type || "stdio" };
538
+ if (s.command) serverEntry.command = s.command;
539
+ if (s.args) serverEntry.args = s.args;
540
+ if (s.env) serverEntry.env = s.env;
541
+ if (s.url) serverEntry.url = s.url;
542
+ mcpJson.mcpServers[s.name] = serverEntry;
465
543
  print(` [OK] Added ${s.name} to .mcp.json`);
544
+
545
+ // Also register in Copilot CLI config
546
+ if (ensureMcpInCopilotCliConfig(s.name, serverEntry)) {
547
+ print(` [OK] Added ${s.name} to ~/.copilot/mcp-config.json`);
548
+ }
466
549
  }
467
550
 
468
551
  fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
@@ -856,6 +939,24 @@ async function cmdInit() {
856
939
  debugLog("git add --force .claude/ failed: " + (err.message || err));
857
940
  }
858
941
 
942
+ // Register sticky-note MCP server in Copilot CLI's ~/.copilot/mcp-config.json
943
+ const stickyMcpEntry = {
944
+ type: "stdio",
945
+ command: "npx",
946
+ args: ["-y", "-p", "sticky-note-cli", "sticky-note", "mcp-server"],
947
+ tools: ["*"],
948
+ };
949
+ if (ensureMcpInCopilotCliConfig("sticky-note", stickyMcpEntry)) {
950
+ print(" [OK] Registered sticky-note MCP server in ~/.copilot/mcp-config.json (Copilot CLI)");
951
+ } else {
952
+ const copilotDir = getCopilotCliConfigDir();
953
+ if (!copilotDir || !fs.existsSync(copilotDir)) {
954
+ print(" ⏭️ Copilot CLI not detected (~/.copilot/ not found) — skipped mcp-config.json");
955
+ } else {
956
+ print(" ⏭️ sticky-note already registered in ~/.copilot/mcp-config.json");
957
+ }
958
+ }
959
+
859
960
  // Done!
860
961
  print("\n ✨ Sticky Note V2 initialized!\n");
861
962
  print(" Next steps:");
@@ -2738,26 +2839,14 @@ async function cmdBootstrap() {
2738
2839
  };
2739
2840
 
2740
2841
  if (config.env) {
2741
- const resolvedEnv = {};
2742
- let hasUnresolved = false;
2842
+ // Keep ${VAR} placeholders in .mcp.json — never write resolved secrets
2843
+ // to committed files. Both Claude Code and Copilot CLI resolve env vars
2844
+ // from the process environment at MCP server spawn time.
2845
+ const safeEnv = {};
2743
2846
  for (const [envKey, envVal] of Object.entries(config.env)) {
2744
- if (typeof envVal === "string" && envVal.startsWith("${") && envVal.endsWith("}")) {
2745
- const varName = envVal.slice(2, -1);
2746
- if (resolved[varName]) {
2747
- resolvedEnv[envKey] = resolved[varName];
2748
- } else {
2749
- hasUnresolved = true;
2750
- }
2751
- } else {
2752
- resolvedEnv[envKey] = envVal;
2753
- }
2754
- }
2755
- if (hasUnresolved) {
2756
- print(` ⚠️ ${name}: skipped (unresolved secrets)`);
2757
- skipped++;
2758
- continue;
2847
+ safeEnv[envKey] = envVal; // preserves "${GITHUB_TOKEN}" as-is
2759
2848
  }
2760
- serverEntry.env = resolvedEnv;
2849
+ serverEntry.env = safeEnv;
2761
2850
  }
2762
2851
 
2763
2852
  mcpJson.mcpServers[name] = serverEntry;
@@ -2766,6 +2855,31 @@ async function cmdBootstrap() {
2766
2855
 
2767
2856
  fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
2768
2857
 
2858
+ // 6b. Also provision to Copilot CLI's ~/.copilot/mcp-config.json
2859
+ // This file is local-only (not committed), so resolved secrets are safe here.
2860
+ let copilotProvisioned = 0;
2861
+ for (const [name, config] of serverEntries) {
2862
+ if (mcpJson.mcpServers[name]) {
2863
+ const copilotEntry = { ...mcpJson.mcpServers[name] };
2864
+ // Resolve ${VAR} placeholders for local-only config
2865
+ if (copilotEntry.env) {
2866
+ const resolvedEnv = {};
2867
+ for (const [envKey, envVal] of Object.entries(copilotEntry.env)) {
2868
+ if (typeof envVal === "string" && envVal.startsWith("${") && envVal.endsWith("}")) {
2869
+ const varName = envVal.slice(2, -1);
2870
+ resolvedEnv[envKey] = resolved[varName] || envVal;
2871
+ } else {
2872
+ resolvedEnv[envKey] = envVal;
2873
+ }
2874
+ }
2875
+ copilotEntry.env = resolvedEnv;
2876
+ }
2877
+ if (ensureMcpInCopilotCliConfig(name, copilotEntry)) {
2878
+ copilotProvisioned++;
2879
+ }
2880
+ }
2881
+ }
2882
+
2769
2883
  // 7. Generate .env.example from manifest env_vars
2770
2884
  if (Object.keys(envVarsMeta).length > 0) {
2771
2885
  const lines = [
@@ -2793,6 +2907,7 @@ async function cmdBootstrap() {
2793
2907
  print("");
2794
2908
  print(" ── Summary ──");
2795
2909
  print(` Provisioned: ${provisioned} server(s)`);
2910
+ if (copilotProvisioned > 0) print(` Copilot CLI: ${copilotProvisioned} server(s) added to ~/.copilot/mcp-config.json`);
2796
2911
  if (skipped > 0) print(` Skipped: ${skipped} server(s) (missing secrets)`);
2797
2912
  print(` Config: ${mcpJsonPath}`);
2798
2913
  print("");
@@ -2851,6 +2966,13 @@ function cmdEnvStatus() {
2851
2966
  const mcpServers = manifest.mcp_servers || {};
2852
2967
  const serverNames = Object.keys(mcpServers);
2853
2968
 
2969
+ // Also check Copilot CLI config
2970
+ const copilotMcpPath = getCopilotCliMcpConfigPath();
2971
+ let copilotServers = {};
2972
+ if (copilotMcpPath && fs.existsSync(copilotMcpPath)) {
2973
+ copilotServers = readJsonSafe(copilotMcpPath, { mcpServers: {} }).mcpServers || {};
2974
+ }
2975
+
2854
2976
  if (serverNames.length === 0) {
2855
2977
  print(" MCP Servers: (none defined)");
2856
2978
  } else {
@@ -2858,9 +2980,14 @@ function cmdEnvStatus() {
2858
2980
  for (const name of serverNames) {
2859
2981
  const config = mcpServers[name];
2860
2982
  const isProvisioned = registeredServers.includes(name);
2861
-
2862
- if (isProvisioned) {
2863
- print(` ✅ ${name} — provisioned`);
2983
+ const inCopilotCli = !!copilotServers[name];
2984
+
2985
+ if (isProvisioned && inCopilotCli) {
2986
+ print(` ✅ ${name} — provisioned (Claude Code + Copilot CLI)`);
2987
+ } else if (isProvisioned) {
2988
+ print(` ✅ ${name} — provisioned (.mcp.json only)`);
2989
+ } else if (inCopilotCli) {
2990
+ print(` ✅ ${name} — provisioned (Copilot CLI only)`);
2864
2991
  } else {
2865
2992
  // Check if it needs secrets
2866
2993
  const envEntries = config.env || {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
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": {
@@ -1,37 +1,37 @@
1
- {
2
- "version": "1",
3
-
4
- "mcp_servers": {
5
- "context7": {
6
- "type": "stdio",
7
- "command": "npx",
8
- "args": ["-y", "@upstash/context7-mcp@latest"],
9
- "description": "Fetch up-to-date docs for any library",
10
- "required": true
11
- },
12
- "github": {
13
- "type": "stdio",
14
- "command": "npx",
15
- "args": ["-y", "@modelcontextprotocol/server-github"],
16
- "env": {
17
- "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
18
- },
19
- "description": "GitHub API access",
20
- "required": true
21
- }
22
- },
23
-
24
- "permissions": [
25
- "Bash(npx sticky-note:*)",
26
- "Bash(npm test:*)",
27
- "Bash(node:*)"
28
- ],
29
-
30
- "env_vars": {
31
- "GITHUB_TOKEN": {
32
- "description": "GitHub PAT with repo scope",
33
- "docs_url": "https://github.com/settings/tokens",
34
- "required": true
35
- }
36
- }
37
- }
1
+ {
2
+ "version": "1",
3
+
4
+ "mcp_servers": {
5
+ "context7": {
6
+ "type": "stdio",
7
+ "command": "npx",
8
+ "args": ["-y", "@upstash/context7-mcp@latest"],
9
+ "description": "Fetch up-to-date docs for any library",
10
+ "required": true
11
+ },
12
+ "github": {
13
+ "type": "stdio",
14
+ "command": "npx",
15
+ "args": ["-y", "@modelcontextprotocol/server-github"],
16
+ "env": {
17
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
18
+ },
19
+ "description": "GitHub API access",
20
+ "required": true
21
+ }
22
+ },
23
+
24
+ "permissions": [
25
+ "Bash(npx sticky-note:*)",
26
+ "Bash(npm test:*)",
27
+ "Bash(node:*)"
28
+ ],
29
+
30
+ "env_vars": {
31
+ "GITHUB_TOKEN": {
32
+ "description": "GitHub PAT with repo scope",
33
+ "docs_url": "https://github.com/settings/tokens",
34
+ "required": true
35
+ }
36
+ }
37
+ }
@@ -62,6 +62,7 @@ const {
62
62
  clearActiveResumeThreadId,
63
63
  getActiveResumeThreadId,
64
64
  normalizeSep,
65
+ detectLostThreads,
65
66
  } = utils;
66
67
 
67
68
  // ── Stale-thread ageing ───────────────────────────────────
@@ -511,15 +512,43 @@ function ensureMcpServerRegistered() {
511
512
  }
512
513
 
513
514
  mcp.mcpServers = mcp.mcpServers || {};
514
- if (mcp.mcpServers["sticky-note"]) return; // already registered
515
-
516
- mcp.mcpServers["sticky-note"] = {
517
- type: "stdio",
518
- command: "npx",
519
- args: ["-y", "-p", "sticky-note-cli", "sticky-note", "mcp-server"],
520
- };
515
+ if (!mcp.mcpServers["sticky-note"]) {
516
+ mcp.mcpServers["sticky-note"] = {
517
+ type: "stdio",
518
+ command: "npx",
519
+ args: ["-y", "-p", "sticky-note-cli", "sticky-note", "mcp-server"],
520
+ };
521
+ fs.writeFileSync(mcpPath, JSON.stringify(mcp, null, 2) + "\n");
522
+ }
521
523
 
522
- fs.writeFileSync(mcpPath, JSON.stringify(mcp, null, 2) + "\n");
524
+ // Also register in Copilot CLI's ~/.copilot/mcp-config.json
525
+ try {
526
+ const utils = require("./sticky-utils");
527
+ utils.ensureMcpInCopilotCliConfig("sticky-note", {
528
+ type: "stdio",
529
+ command: "npx",
530
+ args: ["-y", "-p", "sticky-note-cli", "sticky-note", "mcp-server"],
531
+ tools: ["*"],
532
+ });
533
+ } catch (_) {
534
+ // sticky-utils not available — inline fallback
535
+ const home = process.env.COPILOT_HOME || path.join(process.env.HOME || process.env.USERPROFILE || "", ".copilot");
536
+ if (home && fs.existsSync(home)) {
537
+ const copilotMcpPath = path.join(home, "mcp-config.json");
538
+ let copilotMcp = {};
539
+ try { copilotMcp = JSON.parse(fs.readFileSync(copilotMcpPath, "utf-8")); } catch (_) {}
540
+ copilotMcp.mcpServers = copilotMcp.mcpServers || {};
541
+ if (!copilotMcp.mcpServers["sticky-note"]) {
542
+ copilotMcp.mcpServers["sticky-note"] = {
543
+ type: "stdio",
544
+ command: "npx",
545
+ args: ["-y", "-p", "sticky-note-cli", "sticky-note", "mcp-server"],
546
+ tools: ["*"],
547
+ };
548
+ fs.writeFileSync(copilotMcpPath, JSON.stringify(copilotMcp, null, 2) + "\n");
549
+ }
550
+ }
551
+ }
523
552
  } catch (_) {
524
553
  // Non-fatal — MCP server registration is best-effort
525
554
  }
@@ -598,6 +627,15 @@ function ensureEnvironmentProvisioned() {
598
627
  if (debug) process.stderr.write(`[sticky-note] added MCP server "${name}"\n`);
599
628
  }
600
629
  if (mcpChanged) saveJson(mcpPath, mcpConfig);
630
+
631
+ // Also provision secret-free MCP servers to Copilot CLI's config
632
+ try {
633
+ const utils = require("./sticky-utils");
634
+ for (const [name, serverDef] of Object.entries(mcpServers)) {
635
+ if (hasEnvPlaceholders(serverDef)) continue;
636
+ utils.ensureMcpInCopilotCliConfig(name, serverDef);
637
+ }
638
+ } catch (_) { /* sticky-utils not available — skip Copilot CLI provisioning */ }
601
639
  }
602
640
 
603
641
  // ── Skill provisioning ──
@@ -771,6 +809,20 @@ function main() {
771
809
  project: "",
772
810
  threads: [],
773
811
  });
812
+
813
+ // Rollback detection: warn if threads went missing (e.g., after git reset)
814
+ try {
815
+ const lost = detectLostThreads(memoryPath);
816
+ if (lost.length > 0) {
817
+ const ids = lost.slice(0, 5).map((t) => t.id).join(", ");
818
+ const extra = lost.length > 5 ? ` (+${lost.length - 5} more)` : "";
819
+ process.stderr.write(
820
+ `[sticky-note] ⚠️ ${lost.length} thread(s) missing since last backup: ${ids}${extra}\n` +
821
+ `[sticky-note] This may indicate a git rollback. Run 'npx sticky-note status' to inspect.\n`
822
+ );
823
+ }
824
+ } catch (_) { /* rollback detection must not break session */ }
825
+
774
826
  const config = loadJson(getConfigPath(), { stale_days: 14 });
775
827
  const staleDays = config.stale_days != null ? config.stale_days : 14;
776
828
  const autoCloseHours =
@@ -284,32 +284,169 @@ function loadJson(filePath, defaultVal) {
284
284
 
285
285
  function saveJson(filePath, data) {
286
286
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
287
- fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
287
+ // Atomic write: write to temp file then rename to prevent partial/truncated JSON
288
+ const tmpPath = filePath + ".tmp." + process.pid;
289
+ try {
290
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
291
+ fs.renameSync(tmpPath, filePath);
292
+ } catch (err) {
293
+ // Rename can fail on some Windows configs; fall back to direct write
294
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
295
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
296
+ }
297
+ }
298
+
299
+ // ── File locking ─────────────────────────────────────────
300
+
301
+ const LOCK_STALE_MS = 10000; // 10 seconds
302
+ const LOCK_RETRY_MS = 50;
303
+ const LOCK_MAX_RETRIES = 60; // 3 seconds max wait
304
+
305
+ function acquireLock(memoryPath) {
306
+ const lockPath = memoryPath + ".lock";
307
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
308
+ try {
309
+ // Exclusive create — fails if lock already exists
310
+ fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, ts: Date.now() }), { flag: "wx" });
311
+ return lockPath;
312
+ } catch (_) {
313
+ // Lock exists — check if stale
314
+ try {
315
+ const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
316
+ if (Date.now() - lock.ts > LOCK_STALE_MS) {
317
+ // Stale lock — remove and retry
318
+ try { fs.unlinkSync(lockPath); } catch (_) {}
319
+ continue;
320
+ }
321
+ } catch (_) {
322
+ // Can't read lock — remove and retry
323
+ try { fs.unlinkSync(lockPath); } catch (_) {}
324
+ continue;
325
+ }
326
+ // Lock is fresh — wait and retry
327
+ const waitUntil = Date.now() + LOCK_RETRY_MS;
328
+ while (Date.now() < waitUntil) { /* busy wait */ }
329
+ }
330
+ }
331
+ // Couldn't acquire after retries — proceed without lock (best effort)
332
+ return null;
333
+ }
334
+
335
+ function releaseLock(lockPath) {
336
+ if (!lockPath) return;
337
+ try { fs.unlinkSync(lockPath); } catch (_) {}
338
+ }
339
+
340
+ // ── Backup rotation ──────────────────────────────────────
341
+
342
+ const MAX_BACKUPS = 3;
343
+
344
+ function rotateBackup(memoryPath) {
345
+ const dir = path.dirname(memoryPath);
346
+ const backupDir = path.join(dir, ".sticky-backups");
347
+ try {
348
+ fs.mkdirSync(backupDir, { recursive: true });
349
+ // Read current file
350
+ const current = fs.readFileSync(memoryPath, "utf-8");
351
+ // Rotate: remove oldest, shift names
352
+ for (let i = MAX_BACKUPS; i > 1; i--) {
353
+ const older = path.join(backupDir, `sticky-note.${i - 1}.json`);
354
+ const newer = path.join(backupDir, `sticky-note.${i}.json`);
355
+ try {
356
+ if (fs.existsSync(older)) fs.renameSync(older, newer);
357
+ } catch (_) {}
358
+ }
359
+ // Write newest backup
360
+ fs.writeFileSync(path.join(backupDir, "sticky-note.1.json"), current, "utf-8");
361
+ } catch (_) {
362
+ // Backup failure is non-fatal
363
+ }
364
+ }
365
+
366
+ function loadLatestBackup(memoryPath) {
367
+ const backupDir = path.join(path.dirname(memoryPath), ".sticky-backups");
368
+ for (let i = 1; i <= MAX_BACKUPS; i++) {
369
+ try {
370
+ const bp = path.join(backupDir, `sticky-note.${i}.json`);
371
+ return JSON.parse(fs.readFileSync(bp, "utf-8"));
372
+ } catch (_) {
373
+ continue;
374
+ }
375
+ }
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * Detect threads lost since last backup (e.g., after git reset/rollback).
381
+ * Returns array of thread IDs that are in backup but not in current.
382
+ */
383
+ function detectLostThreads(memoryPath) {
384
+ try {
385
+ const current = loadJson(memoryPath, { threads: [] });
386
+ const backup = loadLatestBackup(memoryPath);
387
+ if (!backup || !Array.isArray(backup.threads)) return [];
388
+ const currentIds = new Set(
389
+ (current.threads || []).filter(Boolean).map((t) => t.id)
390
+ );
391
+ return (backup.threads || []).filter(
392
+ (t) => t && t.id && !currentIds.has(t.id) && t.status !== "expired"
393
+ );
394
+ } catch (_) {
395
+ return [];
396
+ }
288
397
  }
289
398
 
290
399
  /**
291
400
  * Merge-on-write for the memory file. Re-reads the file from disk before
292
401
  * writing and preserves any threads (by ID) that exist on disk but not in
293
- * the in-memory copy. This prevents concurrent sessions or un-pulled commits
294
- * from silently dropping other users' threads.
402
+ * the in-memory copy. For same-ID threads, performs field-level 2-way merge
403
+ * to prevent concurrent sessions from clobbering each other's changes.
404
+ *
405
+ * Uses file locking, atomic writes, and rolling backups.
295
406
  */
296
407
  function saveMemoryMerged(memoryPath, memory) {
408
+ const lockPath = acquireLock(memoryPath);
297
409
  try {
298
- const onDisk = loadJson(memoryPath, { threads: [] });
299
- const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads.filter(Boolean) : [];
300
- const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads.filter(Boolean) : [];
301
- const inMemoryIds = new Set(inMemoryThreads.map((t) => t.id));
302
-
303
- for (const diskThread of onDiskThreads) {
304
- if (diskThread.id && !inMemoryIds.has(diskThread.id)) {
305
- memory.threads.push(diskThread);
410
+ // Create backup before writing
411
+ if (fs.existsSync(memoryPath)) {
412
+ rotateBackup(memoryPath);
413
+ }
414
+
415
+ // Load merge helpers (field-level thread merge)
416
+ let mergeOneThread2Way = null;
417
+ try {
418
+ const md = require(path.join(path.dirname(memoryPath), "merge-driver.js"));
419
+ mergeOneThread2Way = md.mergeOneThread2Way;
420
+ } catch (_) {}
421
+
422
+ try {
423
+ const onDisk = loadJson(memoryPath, { threads: [] });
424
+ const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads.filter(Boolean) : [];
425
+ const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads.filter(Boolean) : [];
426
+ const inMemoryMap = new Map(inMemoryThreads.map((t) => [t.id, t]));
427
+
428
+ for (const diskThread of onDiskThreads) {
429
+ if (!diskThread.id) continue;
430
+ if (!inMemoryMap.has(diskThread.id)) {
431
+ // Thread only on disk — preserve it
432
+ memory.threads.push(diskThread);
433
+ } else if (mergeOneThread2Way) {
434
+ // Thread on both — field-level merge
435
+ const inMemThread = inMemoryMap.get(diskThread.id);
436
+ const merged = mergeOneThread2Way(inMemThread, diskThread);
437
+ // Replace in-memory version with merged version
438
+ const idx = memory.threads.findIndex((t) => t && t.id === diskThread.id);
439
+ if (idx >= 0) memory.threads[idx] = merged;
440
+ }
306
441
  }
442
+ } catch (_) {
443
+ // If re-read/merge fails, proceed with what we have
307
444
  }
308
- } catch (_) {
309
- // If re-read fails, proceed with what we have
310
- }
311
445
 
312
- saveJson(memoryPath, memory);
446
+ saveJson(memoryPath, memory);
447
+ } finally {
448
+ releaseLock(lockPath);
449
+ }
313
450
  }
314
451
 
315
452
  function appendAuditLine(entry) {
@@ -747,6 +884,62 @@ function clearActiveResumeThreadId() {
747
884
  }
748
885
  }
749
886
 
887
+ // ── Copilot CLI MCP config helpers ────────────────────────
888
+
889
+ function getCopilotCliConfigDir() {
890
+ if (process.env.COPILOT_HOME) return process.env.COPILOT_HOME;
891
+ const home = process.env.HOME || process.env.USERPROFILE;
892
+ if (!home) return null;
893
+ return path.join(home, ".copilot");
894
+ }
895
+
896
+ function getCopilotCliMcpConfigPath() {
897
+ const dir = getCopilotCliConfigDir();
898
+ return dir ? path.join(dir, "mcp-config.json") : null;
899
+ }
900
+
901
+ /**
902
+ * Register an MCP server in Copilot CLI's ~/.copilot/mcp-config.json.
903
+ * Only writes if the ~/.copilot/ directory already exists (Copilot CLI installed).
904
+ * Returns true if written, false if skipped.
905
+ */
906
+ function ensureMcpInCopilotCliConfig(serverName, serverConfig) {
907
+ try {
908
+ const configDir = getCopilotCliConfigDir();
909
+ if (!configDir || !fs.existsSync(configDir)) return false;
910
+
911
+ const configPath = path.join(configDir, "mcp-config.json");
912
+ let config = {};
913
+ if (fs.existsSync(configPath)) {
914
+ try {
915
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
916
+ } catch (_) {
917
+ config = {};
918
+ }
919
+ }
920
+
921
+ config.mcpServers = config.mcpServers || {};
922
+ const existing = config.mcpServers[serverName];
923
+ // Copilot CLI requires "tools" field — fix entries missing it
924
+ if (existing) {
925
+ if (!existing.tools) {
926
+ existing.tools = ["*"];
927
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
928
+ return true;
929
+ }
930
+ return false; // already registered with tools
931
+ }
932
+
933
+ // Ensure tools field is present for Copilot CLI compatibility
934
+ if (!serverConfig.tools) serverConfig.tools = ["*"];
935
+ config.mcpServers[serverName] = serverConfig;
936
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
937
+ return true;
938
+ } catch (_) {
939
+ return false;
940
+ }
941
+ }
942
+
750
943
  // ── Exports ───────────────────────────────────────────────
751
944
 
752
945
  module.exports = {
@@ -816,5 +1009,14 @@ module.exports = {
816
1009
  normalizeSep,
817
1010
  // V2.7: auto-sync
818
1011
  syncStickyNote,
1012
+ // V2.9: Copilot CLI MCP config
1013
+ getCopilotCliMcpConfigPath,
1014
+ ensureMcpInCopilotCliConfig,
1015
+ // V2.10: rollback detection + backups
1016
+ detectLostThreads,
1017
+ loadLatestBackup,
1018
+ rotateBackup,
1019
+ acquireLock,
1020
+ releaseLock,
819
1021
  };
820
1022
  // tier1 test
@@ -1,89 +1,218 @@
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);
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Git custom merge driver for sticky-note.json
4
+ *
5
+ * Merges threads by ID with field-level 3-way merge instead of
6
+ * line-based union merge. For threads present in both sides, each
7
+ * field is merged individually using ancestor-aware rules.
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
+ * Also exports merge helpers for use by sticky-utils.js saveMemoryMerged().
15
+ */
16
+
17
+ const fs = require("fs");
18
+
19
+ // Fields that are merged as set-union (deduplicated)
20
+ const SET_UNION_FIELDS = [
21
+ "files_touched", "contributors", "related_session_ids",
22
+ ];
23
+
24
+ // Fields that are merged as ordered-dedupe with a size cap
25
+ const CAPPED_ARRAY_FIELDS = {
26
+ prompts: 20,
27
+ activities: 20,
28
+ };
29
+
30
+ // Fields merged by deduping on stringified value
31
+ const DEDUPE_ARRAY_FIELDS = [
32
+ "failed_approaches", "resume_chain", "resume_history",
33
+ ];
34
+
35
+ function loadJsonStrict(filePath) {
36
+ const raw = fs.readFileSync(filePath, "utf-8");
37
+ return JSON.parse(raw);
38
+ }
39
+
40
+ function loadJsonSafe(filePath) {
41
+ try {
42
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
43
+ } catch (_) {
44
+ return { version: "2", project: "", threads: [] };
45
+ }
46
+ }
47
+
48
+ function getTimestamp(thread) {
49
+ if (!thread) return 0;
50
+ const ts = thread.last_activity_at || thread.closed_at || thread.created_at || "";
51
+ if (!ts) return 0;
52
+ const t = new Date(ts).getTime();
53
+ return isNaN(t) ? 0 : t;
54
+ }
55
+
56
+ function jsonEqual(a, b) {
57
+ return JSON.stringify(a) === JSON.stringify(b);
58
+ }
59
+
60
+ function dedupeArray(arr) {
61
+ const seen = new Set();
62
+ const result = [];
63
+ for (const item of arr) {
64
+ const key = typeof item === "string" ? item : JSON.stringify(item);
65
+ if (!seen.has(key)) {
66
+ seen.add(key);
67
+ result.push(item);
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+
73
+ function sumToolCalls(a, b) {
74
+ if (!a && !b) return {};
75
+ const result = { ...(a || {}) };
76
+ for (const [key, val] of Object.entries(b || {})) {
77
+ result[key] = (result[key] || 0) + (typeof val === "number" ? val : 0);
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * Merge a single thread using 3-way (ancestor-aware) or 2-way merge.
84
+ * @param {object|null} ancestor - ancestor version (null for 2-way merge)
85
+ * @param {object} ours - our version
86
+ * @param {object} theirs - their version
87
+ * @returns {object} merged thread
88
+ */
89
+ function mergeOneThread(ancestor, ours, theirs) {
90
+ const merged = { ...ours };
91
+ const allKeys = new Set([...Object.keys(ours), ...Object.keys(theirs)]);
92
+
93
+ for (const key of allKeys) {
94
+ if (key === "id") continue; // never change ID
95
+ const oVal = ours[key];
96
+ const tVal = theirs[key];
97
+
98
+ // If both sides are the same, nothing to merge
99
+ if (jsonEqual(oVal, tVal)) continue;
100
+
101
+ if (ancestor) {
102
+ // 3-way merge: check what changed from ancestor
103
+ const aVal = ancestor[key];
104
+ const oursChanged = !jsonEqual(oVal, aVal);
105
+ const theirsChanged = !jsonEqual(tVal, aVal);
106
+
107
+ if (!oursChanged && theirsChanged) {
108
+ // Only theirs changed — take theirs
109
+ merged[key] = tVal;
110
+ continue;
111
+ } else if (oursChanged && !theirsChanged) {
112
+ // Only ours changed — already in merged
113
+ continue;
114
+ }
115
+ // Both changed — fall through to conflict resolution
116
+ }
117
+
118
+ // Conflict resolution (both changed, or 2-way merge)
119
+ if (SET_UNION_FIELDS.includes(key)) {
120
+ merged[key] = dedupeArray([...(oVal || []), ...(tVal || [])]);
121
+ } else if (key in CAPPED_ARRAY_FIELDS) {
122
+ merged[key] = dedupeArray([...(oVal || []), ...(tVal || [])]).slice(
123
+ -(CAPPED_ARRAY_FIELDS[key])
124
+ );
125
+ } else if (DEDUPE_ARRAY_FIELDS.includes(key)) {
126
+ merged[key] = dedupeArray([...(oVal || []), ...(tVal || [])]);
127
+ } else if (key === "tool_calls") {
128
+ merged[key] = sumToolCalls(oVal, tVal);
129
+ } else {
130
+ // Scalar conflict: take from the more recently active side
131
+ merged[key] = getTimestamp(ours) >= getTimestamp(theirs) ? oVal : tVal;
132
+ }
133
+ }
134
+
135
+ return merged;
136
+ }
137
+
138
+ /**
139
+ * 2-way merge for concurrent local writes (no ancestor available).
140
+ */
141
+ function mergeOneThread2Way(ours, theirs) {
142
+ return mergeOneThread(null, ours, theirs);
143
+ }
144
+
145
+ function mergeThreads(ancestor, ours, theirs) {
146
+ const ancestorMap = new Map((ancestor.threads || []).filter(Boolean).map((t) => [t.id, t]));
147
+ const oursMap = new Map((ours.threads || []).filter(Boolean).map((t) => [t.id, t]));
148
+ const theirsMap = new Map((theirs.threads || []).filter(Boolean).map((t) => [t.id, t]));
149
+
150
+ const allIds = new Set([...oursMap.keys(), ...theirsMap.keys()]);
151
+ const merged = [];
152
+
153
+ for (const id of allIds) {
154
+ const inOurs = oursMap.has(id);
155
+ const inTheirs = theirsMap.has(id);
156
+ const inAncestor = ancestorMap.has(id);
157
+
158
+ if (inOurs && inTheirs) {
159
+ // Both sides have it — field-level merge
160
+ merged.push(
161
+ mergeOneThread(ancestorMap.get(id) || null, oursMap.get(id), theirsMap.get(id))
162
+ );
163
+ } else if (inOurs && !inTheirs) {
164
+ if (inAncestor) {
165
+ // Was in ancestor, theirs removed it — respect the deletion
166
+ } else {
167
+ merged.push(oursMap.get(id));
168
+ }
169
+ } else if (!inOurs && inTheirs) {
170
+ if (inAncestor) {
171
+ // Was in ancestor, ours removed it — respect the deletion
172
+ } else {
173
+ merged.push(theirsMap.get(id));
174
+ }
175
+ }
176
+ }
177
+
178
+ return merged;
179
+ }
180
+
181
+ // ── Main (only when run directly by git) ──────────────────
182
+
183
+ if (require.main === module) {
184
+ const [ancestorPath, oursPath, theirsPath] = process.argv.slice(2);
185
+
186
+ if (!ancestorPath || !oursPath || !theirsPath) {
187
+ process.stderr.write("Usage: merge-driver.js <ancestor> <ours> <theirs>\n");
188
+ process.exit(1);
189
+ }
190
+
191
+ // Ancestor may not exist (new file) — safe fallback is OK
192
+ const ancestor = loadJsonSafe(ancestorPath);
193
+
194
+ // Ours and theirs must be valid JSON — fail to git conflict if not
195
+ let ours, theirs;
196
+ try {
197
+ ours = loadJsonStrict(oursPath);
198
+ } catch (err) {
199
+ process.stderr.write(`[sticky-note] merge-driver: failed to parse ours (${oursPath}): ${err.message}\n`);
200
+ process.exit(1);
201
+ }
202
+ try {
203
+ theirs = loadJsonStrict(theirsPath);
204
+ } catch (err) {
205
+ process.stderr.write(`[sticky-note] merge-driver: failed to parse theirs (${theirsPath}): ${err.message}\n`);
206
+ process.exit(1);
207
+ }
208
+
209
+ const mergedThreads = mergeThreads(ancestor, ours, theirs);
210
+ const result = { ...ours, threads: mergedThreads };
211
+
212
+ fs.writeFileSync(oursPath, JSON.stringify(result, null, 2) + "\n", "utf-8");
213
+ process.exit(0);
214
+ }
215
+
216
+ // ── Exports for use by sticky-utils.js ────────────────────
217
+
218
+ module.exports = { mergeOneThread, mergeOneThread2Way, mergeThreads, getTimestamp };