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 +154 -27
- package/package.json +1 -1
- package/templates/environment/manifest.json +37 -37
- package/templates/hooks/session-start.js +60 -8
- package/templates/hooks/sticky-utils.js +217 -15
- package/templates/merge-driver.js +218 -89
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
|
|
460
|
-
if (s.command)
|
|
461
|
-
if (s.args)
|
|
462
|
-
if (s.env)
|
|
463
|
-
if (s.url)
|
|
464
|
-
mcpJson.mcpServers[s.name] =
|
|
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
|
-
|
|
2742
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2863
|
-
|
|
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,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"])
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
294
|
-
* from
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
* For threads present in both sides,
|
|
7
|
-
*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 };
|