sticky-note-cli 2.8.1 → 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 +184 -29
- 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
|
@@ -83,6 +83,28 @@ function readTemplate(name) {
|
|
|
83
83
|
return fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf-8");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Replace $(git rev-parse --show-toplevel) with absolute root path
|
|
87
|
+
// so hook commands work from any CWD without shell expansion.
|
|
88
|
+
function resolveHookPaths(obj, rootDir) {
|
|
89
|
+
const root = rootDir.replace(/\\/g, "/");
|
|
90
|
+
const resolve = (s) =>
|
|
91
|
+
s.replace(/"\$\(git rev-parse --show-toplevel\)\//g, `"${root}/`);
|
|
92
|
+
const hooks = obj.hooks || {};
|
|
93
|
+
for (const entries of Object.values(hooks)) {
|
|
94
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
95
|
+
if (entry.hooks) {
|
|
96
|
+
for (const h of entry.hooks) {
|
|
97
|
+
if (h.command) h.command = resolve(h.command);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (entry.bash) entry.bash = resolve(entry.bash);
|
|
101
|
+
if (entry.powershell) entry.powershell = resolve(entry.powershell);
|
|
102
|
+
if (entry.command) entry.command = resolve(entry.command);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return obj;
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
function makeExecutable(filePath) {
|
|
87
109
|
try {
|
|
88
110
|
fs.chmodSync(filePath, 0o755);
|
|
@@ -304,6 +326,62 @@ function countJsonlLines(filePath) {
|
|
|
304
326
|
}
|
|
305
327
|
}
|
|
306
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
|
+
|
|
307
385
|
// ──────────────────────────────────────────────
|
|
308
386
|
// Auto-detection
|
|
309
387
|
// ──────────────────────────────────────────────
|
|
@@ -331,6 +409,28 @@ function detectMcpServers() {
|
|
|
331
409
|
} catch (err) { debugLog("detectMcpServers settings.json: " + (err.message || err)); }
|
|
332
410
|
}
|
|
333
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
|
+
|
|
334
434
|
const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
|
|
335
435
|
if (fs.existsSync(localSettingsPath)) {
|
|
336
436
|
try {
|
|
@@ -434,13 +534,18 @@ async function syncMcpFromStickyNote(rl) {
|
|
|
434
534
|
}
|
|
435
535
|
|
|
436
536
|
for (const s of missing) {
|
|
437
|
-
const
|
|
438
|
-
if (s.command)
|
|
439
|
-
if (s.args)
|
|
440
|
-
if (s.env)
|
|
441
|
-
if (s.url)
|
|
442
|
-
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;
|
|
443
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
|
+
}
|
|
444
549
|
}
|
|
445
550
|
|
|
446
551
|
fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
|
|
@@ -551,12 +656,14 @@ async function cmdInit() {
|
|
|
551
656
|
|
|
552
657
|
// Create settings.json (Claude Code)
|
|
553
658
|
const settingsTemplate = JSON.parse(readTemplate("settings.json"));
|
|
659
|
+
resolveHookPaths(settingsTemplate, process.cwd());
|
|
554
660
|
const settingsDest = path.join(process.cwd(), ".claude", "settings.json");
|
|
555
661
|
fs.writeFileSync(settingsDest, JSON.stringify(settingsTemplate, null, 2) + "\n");
|
|
556
662
|
print(" [OK] .claude/settings.json");
|
|
557
663
|
|
|
558
664
|
// Create hooks.json (Copilot CLI)
|
|
559
665
|
const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
|
|
666
|
+
resolveHookPaths(hooksTemplate, process.cwd());
|
|
560
667
|
const hooksDest = path.join(githubHooksDir, "hooks.json");
|
|
561
668
|
fs.writeFileSync(hooksDest, JSON.stringify(hooksTemplate, null, 2) + "\n");
|
|
562
669
|
print(" [OK] .github/hooks/hooks.json");
|
|
@@ -693,10 +800,11 @@ async function cmdInit() {
|
|
|
693
800
|
copyFile(mergeDriverSrc, mergeDriverDest);
|
|
694
801
|
print(" [OK] .sticky-note/merge-driver.js installed");
|
|
695
802
|
try {
|
|
803
|
+
const mergeRoot = process.cwd().replace(/\\/g, "/");
|
|
696
804
|
execFileSync("git", ["config", "merge.sticky-note.name", "Sticky Note thread merge"], {
|
|
697
805
|
cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
|
|
698
806
|
});
|
|
699
|
-
execFileSync("git", ["config", "merge.sticky-note.driver",
|
|
807
|
+
execFileSync("git", ["config", "merge.sticky-note.driver", `node "${mergeRoot}/.sticky-note/merge-driver.js" %O %A %B`], {
|
|
700
808
|
cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
|
|
701
809
|
});
|
|
702
810
|
print(" [OK] git merge driver configured (merge.sticky-note)");
|
|
@@ -831,6 +939,24 @@ async function cmdInit() {
|
|
|
831
939
|
debugLog("git add --force .claude/ failed: " + (err.message || err));
|
|
832
940
|
}
|
|
833
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
|
+
|
|
834
960
|
// Done!
|
|
835
961
|
print("\n ✨ Sticky Note V2 initialized!\n");
|
|
836
962
|
print(" Next steps:");
|
|
@@ -872,6 +998,7 @@ function cmdUpdate() {
|
|
|
872
998
|
const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
|
|
873
999
|
if (fs.existsSync(settingsPath)) {
|
|
874
1000
|
const settingsTemplate = JSON.parse(readTemplate("settings.json"));
|
|
1001
|
+
resolveHookPaths(settingsTemplate, process.cwd());
|
|
875
1002
|
const existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
876
1003
|
existing.hooks = settingsTemplate.hooks;
|
|
877
1004
|
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
|
|
@@ -882,6 +1009,7 @@ function cmdUpdate() {
|
|
|
882
1009
|
const hooksPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
|
|
883
1010
|
if (fs.existsSync(hooksPath)) {
|
|
884
1011
|
const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
|
|
1012
|
+
resolveHookPaths(hooksTemplate, process.cwd());
|
|
885
1013
|
fs.writeFileSync(hooksPath, JSON.stringify(hooksTemplate, null, 2) + "\n");
|
|
886
1014
|
print(" [OK] .github/hooks/hooks.json (updated)");
|
|
887
1015
|
}
|
|
@@ -964,10 +1092,11 @@ function cmdUpdate() {
|
|
|
964
1092
|
|
|
965
1093
|
// Ensure git merge driver config is set
|
|
966
1094
|
try {
|
|
1095
|
+
const mergeRoot = process.cwd().replace(/\\/g, "/");
|
|
967
1096
|
execFileSync("git", ["config", "merge.sticky-note.name", "Sticky Note thread merge"], {
|
|
968
1097
|
cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
|
|
969
1098
|
});
|
|
970
|
-
execFileSync("git", ["config", "merge.sticky-note.driver",
|
|
1099
|
+
execFileSync("git", ["config", "merge.sticky-note.driver", `node "${mergeRoot}/.sticky-note/merge-driver.js" %O %A %B`], {
|
|
971
1100
|
cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
|
|
972
1101
|
});
|
|
973
1102
|
print(" [OK] git merge driver configured (merge.sticky-note)");
|
|
@@ -2710,26 +2839,14 @@ async function cmdBootstrap() {
|
|
|
2710
2839
|
};
|
|
2711
2840
|
|
|
2712
2841
|
if (config.env) {
|
|
2713
|
-
|
|
2714
|
-
|
|
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 = {};
|
|
2715
2846
|
for (const [envKey, envVal] of Object.entries(config.env)) {
|
|
2716
|
-
|
|
2717
|
-
const varName = envVal.slice(2, -1);
|
|
2718
|
-
if (resolved[varName]) {
|
|
2719
|
-
resolvedEnv[envKey] = resolved[varName];
|
|
2720
|
-
} else {
|
|
2721
|
-
hasUnresolved = true;
|
|
2722
|
-
}
|
|
2723
|
-
} else {
|
|
2724
|
-
resolvedEnv[envKey] = envVal;
|
|
2725
|
-
}
|
|
2726
|
-
}
|
|
2727
|
-
if (hasUnresolved) {
|
|
2728
|
-
print(` ⚠️ ${name}: skipped (unresolved secrets)`);
|
|
2729
|
-
skipped++;
|
|
2730
|
-
continue;
|
|
2847
|
+
safeEnv[envKey] = envVal; // preserves "${GITHUB_TOKEN}" as-is
|
|
2731
2848
|
}
|
|
2732
|
-
serverEntry.env =
|
|
2849
|
+
serverEntry.env = safeEnv;
|
|
2733
2850
|
}
|
|
2734
2851
|
|
|
2735
2852
|
mcpJson.mcpServers[name] = serverEntry;
|
|
@@ -2738,6 +2855,31 @@ async function cmdBootstrap() {
|
|
|
2738
2855
|
|
|
2739
2856
|
fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
|
|
2740
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
|
+
|
|
2741
2883
|
// 7. Generate .env.example from manifest env_vars
|
|
2742
2884
|
if (Object.keys(envVarsMeta).length > 0) {
|
|
2743
2885
|
const lines = [
|
|
@@ -2765,6 +2907,7 @@ async function cmdBootstrap() {
|
|
|
2765
2907
|
print("");
|
|
2766
2908
|
print(" ── Summary ──");
|
|
2767
2909
|
print(` Provisioned: ${provisioned} server(s)`);
|
|
2910
|
+
if (copilotProvisioned > 0) print(` Copilot CLI: ${copilotProvisioned} server(s) added to ~/.copilot/mcp-config.json`);
|
|
2768
2911
|
if (skipped > 0) print(` Skipped: ${skipped} server(s) (missing secrets)`);
|
|
2769
2912
|
print(` Config: ${mcpJsonPath}`);
|
|
2770
2913
|
print("");
|
|
@@ -2823,6 +2966,13 @@ function cmdEnvStatus() {
|
|
|
2823
2966
|
const mcpServers = manifest.mcp_servers || {};
|
|
2824
2967
|
const serverNames = Object.keys(mcpServers);
|
|
2825
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
|
+
|
|
2826
2976
|
if (serverNames.length === 0) {
|
|
2827
2977
|
print(" MCP Servers: (none defined)");
|
|
2828
2978
|
} else {
|
|
@@ -2830,9 +2980,14 @@ function cmdEnvStatus() {
|
|
|
2830
2980
|
for (const name of serverNames) {
|
|
2831
2981
|
const config = mcpServers[name];
|
|
2832
2982
|
const isProvisioned = registeredServers.includes(name);
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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)`);
|
|
2836
2991
|
} else {
|
|
2837
2992
|
// Check if it needs secrets
|
|
2838
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 };
|