sticky-note-cli 2.9.2 → 3.1.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/README.md +94 -10
- package/bin/cli.js +1153 -174
- package/bin/mcp-server.js +218 -36
- package/package.json +1 -1
- package/templates/CLAUDE.md +23 -0
- package/templates/gitignore-additions.txt +3 -0
- package/templates/hooks/data-branch.js +256 -0
- package/templates/hooks/inject-context.js +26 -12
- package/templates/hooks/on-error.js +22 -9
- package/templates/hooks/on-stop.js +12 -7
- package/templates/hooks/pre-tool-use.js +49 -52
- package/templates/hooks/session-end.js +190 -21
- package/templates/hooks/session-start.js +81 -9
- package/templates/hooks/sticky-codex.ps1 +20 -4
- package/templates/hooks/sticky-codex.sh +14 -5
- package/templates/hooks/sticky-utils.js +335 -29
- package/templates/hooks/track-work.js +27 -13
- package/templates/hooks.json +47 -47
- package/templates/settings.json +81 -81
- package/templates/sticky-note-config.json +2 -0
- package/templates/sticky-note-install.yml +80 -0
- package/templates/sticky-note.json +5 -5
package/bin/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ const VERSION = require("../package.json").version;
|
|
|
25
25
|
|
|
26
26
|
const HOOK_FILES = [
|
|
27
27
|
"sticky-utils.js",
|
|
28
|
+
"data-branch.js", // git plumbing module for sticky-note/data branch
|
|
28
29
|
"session-start.js",
|
|
29
30
|
"session-end.js",
|
|
30
31
|
"inject-context.js",
|
|
@@ -115,6 +116,14 @@ function findStaleHookPaths(settings) {
|
|
|
115
116
|
) {
|
|
116
117
|
stale.push(cmd);
|
|
117
118
|
}
|
|
119
|
+
// Old relative-path format: node ".claude/hooks/foo.js"
|
|
120
|
+
// This fails when Claude Code launches from a subdirectory of the
|
|
121
|
+
// project root, because the path resolves relative to the launch cwd,
|
|
122
|
+
// not relative to where .claude/settings.json lives. The fix is to
|
|
123
|
+
// use `npx sticky-note run-hook foo` which walks up to find the hooks.
|
|
124
|
+
if (/node\s+["']\.claude[\\/]hooks[\\/][^"']+\.js["']/.test(cmd)) {
|
|
125
|
+
stale.push(cmd);
|
|
126
|
+
}
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
129
|
}
|
|
@@ -195,118 +204,6 @@ function makeExecutable(filePath) {
|
|
|
195
204
|
}
|
|
196
205
|
}
|
|
197
206
|
|
|
198
|
-
const STICKY_PRE_COMMIT_MARKER = "# [sticky-note:auto-stage]";
|
|
199
|
-
const STICKY_PRE_COMMIT_END = "# [/sticky-note:auto-stage]";
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Install a pre-commit git hook that auto-stages .sticky-note/ tracked files.
|
|
203
|
-
* If an existing pre-commit hook is present, our block is prepended (or updated)
|
|
204
|
-
* so it chains cleanly with linters, husky, etc.
|
|
205
|
-
*/
|
|
206
|
-
function installPreCommitHook() {
|
|
207
|
-
try {
|
|
208
|
-
const gitDir = path.join(process.cwd(), ".git");
|
|
209
|
-
if (!fs.existsSync(gitDir)) return false;
|
|
210
|
-
const hooksDir = path.join(gitDir, "hooks");
|
|
211
|
-
mkdirSafe(hooksDir);
|
|
212
|
-
const dest = path.join(hooksDir, "pre-commit");
|
|
213
|
-
|
|
214
|
-
const stickyBlock = [
|
|
215
|
-
STICKY_PRE_COMMIT_MARKER,
|
|
216
|
-
'# Auto-stage .sticky-note/ files so they ride along with every commit.',
|
|
217
|
-
'_sn_root="$(git rev-parse --show-toplevel 2>/dev/null)"',
|
|
218
|
-
'if [ -d "$_sn_root/.sticky-note" ]; then',
|
|
219
|
-
' [ -f "$_sn_root/.sticky-note/sticky-note.json" ] && git add "$_sn_root/.sticky-note/sticky-note.json" 2>/dev/null || true',
|
|
220
|
-
' [ -f "$_sn_root/.sticky-note/merge-driver.js" ] && git add "$_sn_root/.sticky-note/merge-driver.js" 2>/dev/null || true',
|
|
221
|
-
' [ -d "$_sn_root/.sticky-note/audit" ] && git add "$_sn_root/.sticky-note/audit/" 2>/dev/null || true',
|
|
222
|
-
' [ -d "$_sn_root/.sticky-note/presence" ] && git add "$_sn_root/.sticky-note/presence/" 2>/dev/null || true',
|
|
223
|
-
' [ -d "$_sn_root/.sticky-note/environment" ] && git add "$_sn_root/.sticky-note/environment/" 2>/dev/null || true',
|
|
224
|
-
'fi',
|
|
225
|
-
STICKY_PRE_COMMIT_END,
|
|
226
|
-
].join("\n");
|
|
227
|
-
|
|
228
|
-
if (fs.existsSync(dest)) {
|
|
229
|
-
let existing = fs.readFileSync(dest, "utf-8");
|
|
230
|
-
const startIdx = existing.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
231
|
-
const endIdx = existing.indexOf(STICKY_PRE_COMMIT_END);
|
|
232
|
-
|
|
233
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
234
|
-
// Update our existing block in-place
|
|
235
|
-
existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_PRE_COMMIT_END.length);
|
|
236
|
-
} else {
|
|
237
|
-
// Prepend our block after the shebang (or at the top)
|
|
238
|
-
const shebangMatch = existing.match(/^#!.*\n/);
|
|
239
|
-
if (shebangMatch) {
|
|
240
|
-
existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
|
|
241
|
-
} else {
|
|
242
|
-
existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
fs.writeFileSync(dest, existing, "utf-8");
|
|
246
|
-
} else {
|
|
247
|
-
// No existing hook — create a fresh one
|
|
248
|
-
fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
|
|
249
|
-
}
|
|
250
|
-
makeExecutable(dest);
|
|
251
|
-
return true;
|
|
252
|
-
} catch (err) {
|
|
253
|
-
debugLog("installPreCommitHook failed: " + (err.message || err));
|
|
254
|
-
return false;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const STICKY_POST_COMMIT_MARKER = "# [sticky-note:auto-sync]";
|
|
259
|
-
const STICKY_POST_COMMIT_END = "# [/sticky-note:auto-sync]";
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Install a post-commit git hook that auto-commits leftover .sticky-note/ changes.
|
|
263
|
-
* Uses the same prepend/update strategy as installPreCommitHook.
|
|
264
|
-
*/
|
|
265
|
-
function installPostCommitHook() {
|
|
266
|
-
try {
|
|
267
|
-
const gitDir = path.join(process.cwd(), ".git");
|
|
268
|
-
if (!fs.existsSync(gitDir)) return false;
|
|
269
|
-
const hooksDir = path.join(gitDir, "hooks");
|
|
270
|
-
mkdirSafe(hooksDir);
|
|
271
|
-
const dest = path.join(hooksDir, "post-commit");
|
|
272
|
-
const src = path.join(TEMPLATES_DIR, "hooks", "post-commit");
|
|
273
|
-
const template = fs.existsSync(src) ? fs.readFileSync(src, "utf-8") : "";
|
|
274
|
-
|
|
275
|
-
// Extract just the body (skip shebang + first comment line from template)
|
|
276
|
-
const bodyLines = template.split("\n").filter(l => !l.startsWith("#!/"));
|
|
277
|
-
const stickyBlock = [
|
|
278
|
-
STICKY_POST_COMMIT_MARKER,
|
|
279
|
-
...bodyLines,
|
|
280
|
-
STICKY_POST_COMMIT_END,
|
|
281
|
-
].join("\n");
|
|
282
|
-
|
|
283
|
-
if (fs.existsSync(dest)) {
|
|
284
|
-
let existing = fs.readFileSync(dest, "utf-8");
|
|
285
|
-
const startIdx = existing.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
286
|
-
const endIdx = existing.indexOf(STICKY_POST_COMMIT_END);
|
|
287
|
-
|
|
288
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
289
|
-
existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_POST_COMMIT_END.length);
|
|
290
|
-
} else {
|
|
291
|
-
const shebangMatch = existing.match(/^#!.*\n/);
|
|
292
|
-
if (shebangMatch) {
|
|
293
|
-
existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
|
|
294
|
-
} else {
|
|
295
|
-
existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
fs.writeFileSync(dest, existing, "utf-8");
|
|
299
|
-
} else {
|
|
300
|
-
fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
|
|
301
|
-
}
|
|
302
|
-
makeExecutable(dest);
|
|
303
|
-
return true;
|
|
304
|
-
} catch (err) {
|
|
305
|
-
debugLog("installPostCommitHook failed: " + (err.message || err));
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
207
|
function installGitHook(hookName) {
|
|
311
208
|
try {
|
|
312
209
|
const gitDir = path.join(process.cwd(), ".git");
|
|
@@ -638,6 +535,10 @@ async function syncMcpFromStickyNote(rl) {
|
|
|
638
535
|
// ──────────────────────────────────────────────
|
|
639
536
|
|
|
640
537
|
async function cmdInit() {
|
|
538
|
+
const args = process.argv.slice(3);
|
|
539
|
+
const ciMode = args.includes("--ci") || args.includes("--no-prompts");
|
|
540
|
+
const v3Mode = args.includes("--v3");
|
|
541
|
+
|
|
641
542
|
printBanner();
|
|
642
543
|
|
|
643
544
|
// Preflight checks
|
|
@@ -651,71 +552,112 @@ async function cmdInit() {
|
|
|
651
552
|
print("");
|
|
652
553
|
|
|
653
554
|
// Auto-detect MCP servers and skills
|
|
654
|
-
print("
|
|
555
|
+
print(" Scanning for existing configuration...");
|
|
655
556
|
const detectedMcp = detectMcpServers();
|
|
656
557
|
const detectedSkills = detectSkills();
|
|
657
558
|
|
|
658
559
|
if (detectedMcp.length > 0) {
|
|
659
|
-
print(`
|
|
560
|
+
print(` Found ${detectedMcp.length} MCP server(s):`);
|
|
660
561
|
for (const s of detectedMcp) {
|
|
661
|
-
print(`
|
|
562
|
+
print(` - ${s.name} (${s.source})`);
|
|
662
563
|
}
|
|
663
564
|
}
|
|
664
565
|
if (detectedSkills.length > 0) {
|
|
665
|
-
print(`
|
|
566
|
+
print(` Found ${detectedSkills.length} skill(s):`);
|
|
666
567
|
for (const s of detectedSkills) {
|
|
667
|
-
print(`
|
|
568
|
+
print(` - ${s}`);
|
|
668
569
|
}
|
|
669
570
|
}
|
|
670
571
|
if (detectedMcp.length === 0 && detectedSkills.length === 0) {
|
|
671
|
-
print("
|
|
572
|
+
print(" No existing MCP servers or skills detected");
|
|
672
573
|
}
|
|
673
574
|
print("");
|
|
674
575
|
|
|
675
|
-
|
|
676
|
-
const rl = readline.createInterface({
|
|
677
|
-
input: process.stdin,
|
|
678
|
-
output: process.stdout,
|
|
679
|
-
});
|
|
576
|
+
let mcpServers, skills, conventions, staleDaysResolved, injectTokenBudgetResolved;
|
|
680
577
|
|
|
681
|
-
|
|
682
|
-
|
|
578
|
+
if (ciMode) {
|
|
579
|
+
// CI mode: no prompts, use env vars
|
|
580
|
+
const mcpRaw = process.env.STICKY_MCP_SERVERS || "";
|
|
581
|
+
const convRaw = process.env.STICKY_CONVENTIONS || "";
|
|
582
|
+
const staleDaysRaw = process.env.STICKY_STALE_DAYS || "14";
|
|
683
583
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
584
|
+
mcpServers = detectedMcp.slice();
|
|
585
|
+
if (mcpRaw) {
|
|
586
|
+
try {
|
|
587
|
+
const parsed = JSON.parse(mcpRaw);
|
|
588
|
+
if (Array.isArray(parsed)) mcpServers = parsed;
|
|
589
|
+
} catch (_) {
|
|
590
|
+
mcpRaw.split(",").map(s => s.trim()).filter(Boolean).forEach(name => {
|
|
591
|
+
mcpServers.push({ name, source: "ci-env" });
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
skills = [...detectedSkills];
|
|
596
|
+
conventions = convRaw ? convRaw.split(",").map(s => s.trim()).filter(Boolean) : [];
|
|
597
|
+
staleDaysResolved = parseIntOr(staleDaysRaw, 14);
|
|
598
|
+
injectTokenBudgetResolved = 1000;
|
|
599
|
+
} else {
|
|
600
|
+
// Interactive prompts
|
|
601
|
+
const rl = readline.createInterface({
|
|
602
|
+
input: process.stdin,
|
|
603
|
+
output: process.stdout,
|
|
604
|
+
});
|
|
697
605
|
|
|
698
|
-
|
|
699
|
-
|
|
606
|
+
print(" Team Configuration");
|
|
607
|
+
print(" (Press Enter to accept detected defaults)\n");
|
|
700
608
|
|
|
701
|
-
|
|
609
|
+
const mcpDefault = detectedMcp.map((s) => s.name).join(", ");
|
|
610
|
+
const mcpServersRaw = await ask(
|
|
611
|
+
rl,
|
|
612
|
+
"MCP servers (comma-separated)",
|
|
613
|
+
mcpDefault || ""
|
|
614
|
+
);
|
|
615
|
+
const conventionsRaw = await ask(
|
|
616
|
+
rl,
|
|
617
|
+
"Team conventions (comma-separated)",
|
|
618
|
+
""
|
|
619
|
+
);
|
|
620
|
+
const staleDays = await ask(rl, "Stale thread age in days", "14");
|
|
621
|
+
const injectTokenBudget = await ask(rl, "Inject context token budget", "1000");
|
|
702
622
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
? mcpServersRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
706
|
-
: [];
|
|
707
|
-
const mcpServers = mcpNames.map((name) => {
|
|
708
|
-
const detected = detectedMcp.find((d) => d.name === name);
|
|
709
|
-
return detected || { name, source: "manual" };
|
|
710
|
-
});
|
|
623
|
+
// Sync MCP servers from teammate's sticky-note config
|
|
624
|
+
await syncMcpFromStickyNote(rl);
|
|
711
625
|
|
|
712
|
-
|
|
626
|
+
rl.close();
|
|
713
627
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
628
|
+
// Build MCP server list: prefer detected objects, fall back to names
|
|
629
|
+
const mcpNames = mcpServersRaw
|
|
630
|
+
? mcpServersRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
631
|
+
: [];
|
|
632
|
+
mcpServers = mcpNames.map((name) => {
|
|
633
|
+
const detected = detectedMcp.find((d) => d.name === name);
|
|
634
|
+
return detected || { name, source: "manual" };
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
skills = [...detectedSkills];
|
|
638
|
+
|
|
639
|
+
conventions = conventionsRaw
|
|
640
|
+
? conventionsRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
641
|
+
: [];
|
|
642
|
+
staleDaysResolved = parseIntOr(staleDays, 14);
|
|
643
|
+
injectTokenBudgetResolved = parseIntOr(injectTokenBudget, 1000);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// V3: write .env.sticky if STICKY_URL is available
|
|
647
|
+
if (v3Mode || process.env.STICKY_URL) {
|
|
648
|
+
const envStickyPath = path.join(process.cwd(), ".env.sticky");
|
|
649
|
+
const stickyUrl = process.env.STICKY_URL || "";
|
|
650
|
+
const stickyApiKey = process.env.STICKY_API_KEY || "";
|
|
651
|
+
if (stickyUrl) {
|
|
652
|
+
let content = `STICKY_URL=${stickyUrl}\n`;
|
|
653
|
+
if (stickyApiKey) content += `STICKY_API_KEY=${stickyApiKey}\n`;
|
|
654
|
+
fs.writeFileSync(envStickyPath, content, "utf-8");
|
|
655
|
+
print(" [OK] .env.sticky (cloud backend configured)");
|
|
656
|
+
} else if (v3Mode && !ciMode) {
|
|
657
|
+
print(" [WARN] --v3 specified but STICKY_URL not set.");
|
|
658
|
+
print(" Run `npx sticky-note deploy-backend` to provision a cloud backend.");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
719
661
|
|
|
720
662
|
print("\n 📁 Creating files...\n");
|
|
721
663
|
|
|
@@ -975,15 +917,9 @@ async function cmdInit() {
|
|
|
975
917
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
976
918
|
}
|
|
977
919
|
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Install post-commit hook (auto-commit leftover .sticky-note/ changes)
|
|
984
|
-
if (installPostCommitHook()) {
|
|
985
|
-
print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
|
|
986
|
-
}
|
|
920
|
+
// Data branch sync handles persistence — no pre/post-commit hooks needed
|
|
921
|
+
print(" [OK] Skipping git pre/post-commit hooks (data branch sync handles this now)");
|
|
922
|
+
print(" Run `npx sticky-note migrate` to migrate existing .sticky-note/ data.");
|
|
987
923
|
|
|
988
924
|
// Create environment directory structure
|
|
989
925
|
const envDir = path.join(stickyNoteDir, "environment");
|
|
@@ -1123,11 +1059,35 @@ function cmdUpdate() {
|
|
|
1123
1059
|
if (installGitHook("post-rewrite")) {
|
|
1124
1060
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
1125
1061
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1062
|
+
|
|
1063
|
+
// Remove sticky-note pre-commit and post-commit git hooks (replaced by data branch)
|
|
1064
|
+
const gitDir2 = path.join(process.cwd(), ".git");
|
|
1065
|
+
if (fs.existsSync(gitDir2)) {
|
|
1066
|
+
const gitHooksDir2 = path.join(gitDir2, "hooks");
|
|
1067
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
1068
|
+
const hookPath2 = path.join(gitHooksDir2, hookName);
|
|
1069
|
+
if (!fs.existsSync(hookPath2)) continue;
|
|
1070
|
+
const content2 = fs.readFileSync(hookPath2, "utf-8");
|
|
1071
|
+
if (!content2.includes(STICKY_PRE_COMMIT_MARKER) && !content2.includes(STICKY_POST_COMMIT_MARKER)) continue;
|
|
1072
|
+
let updated2 = content2;
|
|
1073
|
+
const preStart2 = updated2.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
1074
|
+
const preEnd2 = updated2.indexOf(STICKY_PRE_COMMIT_END);
|
|
1075
|
+
if (preStart2 !== -1 && preEnd2 !== -1) {
|
|
1076
|
+
updated2 = updated2.slice(0, preStart2) + updated2.slice(preEnd2 + STICKY_PRE_COMMIT_END.length);
|
|
1077
|
+
}
|
|
1078
|
+
const postStart2 = updated2.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
1079
|
+
const postEnd2 = updated2.indexOf(STICKY_POST_COMMIT_END);
|
|
1080
|
+
if (postStart2 !== -1 && postEnd2 !== -1) {
|
|
1081
|
+
updated2 = updated2.slice(0, postStart2) + updated2.slice(postEnd2 + STICKY_POST_COMMIT_END.length);
|
|
1082
|
+
}
|
|
1083
|
+
if (updated2.replace(/^#!.*\n/, "").trim() === "") {
|
|
1084
|
+
fs.unlinkSync(hookPath2);
|
|
1085
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (no longer needed)");
|
|
1086
|
+
} else {
|
|
1087
|
+
fs.writeFileSync(hookPath2, updated2, "utf-8");
|
|
1088
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1131
1091
|
}
|
|
1132
1092
|
|
|
1133
1093
|
// Update hook_version in config
|
|
@@ -1391,6 +1351,76 @@ function cmdStatus() {
|
|
|
1391
1351
|
` ${isGitRepo() ? "[OK]" : "[ERR]"} Git repository`
|
|
1392
1352
|
);
|
|
1393
1353
|
|
|
1354
|
+
// V3: Cloud backend health
|
|
1355
|
+
const envStickyPath = path.join(process.cwd(), ".env.sticky");
|
|
1356
|
+
print("\n Cloud Backend (V3):");
|
|
1357
|
+
if (fs.existsSync(envStickyPath)) {
|
|
1358
|
+
try {
|
|
1359
|
+
const envRaw = fs.readFileSync(envStickyPath, "utf-8");
|
|
1360
|
+
let stickyUrl = "";
|
|
1361
|
+
for (const line of envRaw.split(/\r?\n/)) {
|
|
1362
|
+
const trimmed = line.trim();
|
|
1363
|
+
if (trimmed.startsWith("STICKY_URL=")) {
|
|
1364
|
+
stickyUrl = trimmed.slice("STICKY_URL=".length).trim();
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (stickyUrl) {
|
|
1368
|
+
print(` [OK] .env.sticky found (${stickyUrl})`);
|
|
1369
|
+
try {
|
|
1370
|
+
const http = require(stickyUrl.startsWith("https") ? "https" : "http");
|
|
1371
|
+
const healthCheck = new Promise((resolve) => {
|
|
1372
|
+
const req = http.get(`${stickyUrl}/health`, { timeout: 5000 }, (res) => {
|
|
1373
|
+
let body = "";
|
|
1374
|
+
res.on("data", (chunk) => body += chunk);
|
|
1375
|
+
res.on("end", () => {
|
|
1376
|
+
if (res.statusCode === 200) {
|
|
1377
|
+
resolve({ ok: true, body });
|
|
1378
|
+
} else {
|
|
1379
|
+
resolve({ ok: false, status: res.statusCode });
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
req.on("error", (err) => resolve({ ok: false, error: err.message }));
|
|
1384
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "timeout" }); });
|
|
1385
|
+
});
|
|
1386
|
+
const result = healthCheck;
|
|
1387
|
+
// Synchronous-friendly: use execFileSync with curl/node to check
|
|
1388
|
+
try {
|
|
1389
|
+
const healthResult = execFileSync("node", ["-e", `
|
|
1390
|
+
const url = ${JSON.stringify(stickyUrl + "/health")};
|
|
1391
|
+
const mod = url.startsWith("https") ? require("https") : require("http");
|
|
1392
|
+
const req = mod.get(url, {timeout: 5000}, (res) => {
|
|
1393
|
+
let b = "";
|
|
1394
|
+
res.on("data", c => b += c);
|
|
1395
|
+
res.on("end", () => {
|
|
1396
|
+
if (res.statusCode === 200) process.stdout.write("OK:" + b);
|
|
1397
|
+
else process.stdout.write("ERR:" + res.statusCode);
|
|
1398
|
+
});
|
|
1399
|
+
});
|
|
1400
|
+
req.on("error", e => process.stdout.write("ERR:" + e.message));
|
|
1401
|
+
req.on("timeout", () => { req.destroy(); process.stdout.write("ERR:timeout"); });
|
|
1402
|
+
`], { encoding: "utf-8", timeout: 8000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
1403
|
+
if (healthResult.startsWith("OK:")) {
|
|
1404
|
+
print(` [OK] Backend reachable`);
|
|
1405
|
+
} else {
|
|
1406
|
+
print(` [ERR] Backend unreachable (${healthResult.slice(4)})`);
|
|
1407
|
+
}
|
|
1408
|
+
} catch (_) {
|
|
1409
|
+
print(` [ERR] Backend unreachable (health check failed)`);
|
|
1410
|
+
}
|
|
1411
|
+
} catch (_) {
|
|
1412
|
+
print(` [ERR] Backend health check failed`);
|
|
1413
|
+
}
|
|
1414
|
+
} else {
|
|
1415
|
+
print(" [--] .env.sticky found but STICKY_URL not set");
|
|
1416
|
+
}
|
|
1417
|
+
} catch (_) {
|
|
1418
|
+
print(" [ERR] .env.sticky unreadable");
|
|
1419
|
+
}
|
|
1420
|
+
} else {
|
|
1421
|
+
print(" [--] No .env.sticky (local-only mode, V2.5 compatible)");
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1394
1424
|
// V2.5: Attribution engine health
|
|
1395
1425
|
print("\n Attribution Engine (V2.5):");
|
|
1396
1426
|
const attrPath = path.join(process.cwd(), ".claude", "hooks", "sticky-attribution.js");
|
|
@@ -1624,6 +1654,138 @@ function cmdResume() {
|
|
|
1624
1654
|
print(" Run `npx sticky-note resume --clear` to cancel.\n");
|
|
1625
1655
|
}
|
|
1626
1656
|
|
|
1657
|
+
// ──────────────────────────────────────────────
|
|
1658
|
+
// TRANSCRIPT command
|
|
1659
|
+
// ──────────────────────────────────────────────
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Extract human-readable "[role] text" lines from a raw session transcript
|
|
1663
|
+
* (Claude Code JSONL format, with a fallback for Copilot CLI-style hook
|
|
1664
|
+
* entries). Tool use/results are summarized rather than dumped in full —
|
|
1665
|
+
* pass --raw to cmdTranscript to see the untouched JSONL instead.
|
|
1666
|
+
*/
|
|
1667
|
+
function formatTranscriptText(rawTranscript) {
|
|
1668
|
+
const lines = (rawTranscript || "").split(/\r?\n/).filter((l) => l.trim());
|
|
1669
|
+
const out = [];
|
|
1670
|
+
|
|
1671
|
+
for (const line of lines) {
|
|
1672
|
+
let entry;
|
|
1673
|
+
try { entry = JSON.parse(line); } catch (_) { continue; }
|
|
1674
|
+
|
|
1675
|
+
const message = entry.message;
|
|
1676
|
+
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
1677
|
+
const role = message.role || entry.role || "unknown";
|
|
1678
|
+
const content = message.content;
|
|
1679
|
+
const blocks = Array.isArray(content) ? content : [content];
|
|
1680
|
+
|
|
1681
|
+
for (const block of blocks) {
|
|
1682
|
+
if (typeof block === "string") {
|
|
1683
|
+
if (block.trim()) out.push(`[${role}] ${block.trim()}`);
|
|
1684
|
+
} else if (block && typeof block === "object") {
|
|
1685
|
+
if (block.type === "text" && block.text) {
|
|
1686
|
+
out.push(`[${role}] ${block.text.trim()}`);
|
|
1687
|
+
} else if (block.type === "tool_use") {
|
|
1688
|
+
out.push(`[${role}] → used tool: ${block.name || "?"}`);
|
|
1689
|
+
} else if (block.type === "tool_result") {
|
|
1690
|
+
const resultText = typeof block.content === "string"
|
|
1691
|
+
? block.content
|
|
1692
|
+
: Array.isArray(block.content)
|
|
1693
|
+
? block.content.filter((c) => c && typeof c === "object" && c.type === "text").map((c) => c.text || "").join(" ")
|
|
1694
|
+
: "";
|
|
1695
|
+
if (resultText.trim()) {
|
|
1696
|
+
out.push(`[tool result]${block.is_error ? " (error)" : ""} ${resultText.trim().slice(0, 300)}`);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Copilot CLI-style hook entries
|
|
1705
|
+
if (entry.tool_name) {
|
|
1706
|
+
out.push(`[tool] ${entry.tool_name}`);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
return out;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function _loadStickyUtils() {
|
|
1714
|
+
try {
|
|
1715
|
+
return require(path.join(__dirname, "..", "templates", "hooks", "sticky-utils.js"));
|
|
1716
|
+
} catch (_) {
|
|
1717
|
+
try {
|
|
1718
|
+
return require(path.join(process.cwd(), ".claude", "hooks", "sticky-utils.js"));
|
|
1719
|
+
} catch (_) {
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
function cmdTranscript() {
|
|
1726
|
+
printBanner();
|
|
1727
|
+
const args = process.argv.slice(3);
|
|
1728
|
+
const raw = args.includes("--raw");
|
|
1729
|
+
const threadIdArg = args.find((a) => !a.startsWith("--"));
|
|
1730
|
+
|
|
1731
|
+
const utils = _loadStickyUtils();
|
|
1732
|
+
if (!utils) {
|
|
1733
|
+
print(" [ERR] Could not load sticky-utils.js. Run `npx sticky-note init` first.");
|
|
1734
|
+
process.exit(1);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const memory = utils.loadJson(utils.getMemoryPath(), { threads: [] });
|
|
1738
|
+
const threads = (memory.threads || []).filter(Boolean);
|
|
1739
|
+
|
|
1740
|
+
if (!threadIdArg || args.includes("--list")) {
|
|
1741
|
+
const withTranscripts = threads.filter((t) => t.transcript_captured);
|
|
1742
|
+
if (!withTranscripts.length) {
|
|
1743
|
+
print(" No threads have a captured transcript yet.");
|
|
1744
|
+
print(" (Check capture_transcripts in sticky-note-config.json if you expected one.)");
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
print("\n Threads with a captured transcript:\n");
|
|
1748
|
+
for (const t of withTranscripts) {
|
|
1749
|
+
const note = t.last_note ? ` — ${t.last_note.slice(0, 60)}` : "";
|
|
1750
|
+
print(` ${t.id.slice(0, 8)} ${t.user || "?"}${note}`);
|
|
1751
|
+
}
|
|
1752
|
+
print(`\n Usage: npx sticky-note transcript <thread-id> [--raw]\n`);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const match = threads.find((t) => t.id === threadIdArg || t.id.startsWith(threadIdArg));
|
|
1757
|
+
if (!match) {
|
|
1758
|
+
print(` [ERR] No thread found matching "${threadIdArg}".`);
|
|
1759
|
+
print(" Run `npx sticky-note transcript --list` to see threads with a captured transcript.");
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const filePath = utils.getTranscriptPath(match.id);
|
|
1764
|
+
if (!fs.existsSync(filePath)) {
|
|
1765
|
+
print(` No transcript captured for thread ${match.id.slice(0, 8)}.`);
|
|
1766
|
+
print(" (capture_transcripts may be disabled, or this thread predates the feature.)");
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter((l) => l.trim());
|
|
1771
|
+
print(`\n ── Transcript for thread ${match.id.slice(0, 8)} (${lines.length} session${lines.length === 1 ? "" : "s"}) ──\n`);
|
|
1772
|
+
|
|
1773
|
+
for (const line of lines) {
|
|
1774
|
+
let entry;
|
|
1775
|
+
try { entry = JSON.parse(line); } catch (_) { continue; }
|
|
1776
|
+
const shortSession = (entry.session_id || "?").slice(0, 8);
|
|
1777
|
+
print(` ── Session ${shortSession} · ${entry.tool || "?"} · ${entry.captured_at || "?"}${entry.redacted ? " · redacted" : ""} ──`);
|
|
1778
|
+
if (raw) {
|
|
1779
|
+
print(entry.transcript || "");
|
|
1780
|
+
} else {
|
|
1781
|
+
const formatted = formatTranscriptText(entry.transcript);
|
|
1782
|
+
if (!formatted.length) print(" (no readable turns found — try --raw)");
|
|
1783
|
+
for (const f of formatted) print(" " + f);
|
|
1784
|
+
}
|
|
1785
|
+
print("");
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1627
1789
|
// ──────────────────────────────────────────────
|
|
1628
1790
|
// AUDIT command
|
|
1629
1791
|
// ──────────────────────────────────────────────
|
|
@@ -2693,6 +2855,493 @@ function cmdGc() {
|
|
|
2693
2855
|
print("");
|
|
2694
2856
|
}
|
|
2695
2857
|
|
|
2858
|
+
// ──────────────────────────────────────────────
|
|
2859
|
+
// DEPLOY-BACKEND command (V3)
|
|
2860
|
+
// ──────────────────────────────────────────────
|
|
2861
|
+
|
|
2862
|
+
function cmdDeployBackend() {
|
|
2863
|
+
printBanner();
|
|
2864
|
+
print(" Deploy Cloudflare KV Backend\n");
|
|
2865
|
+
|
|
2866
|
+
// Check wrangler
|
|
2867
|
+
try {
|
|
2868
|
+
execSync("wrangler --version", { stdio: "pipe" });
|
|
2869
|
+
} catch (_) {
|
|
2870
|
+
print(" [ERR] `wrangler` CLI not found.");
|
|
2871
|
+
print(" Install it: npm install -g wrangler");
|
|
2872
|
+
print(" Then authenticate: wrangler login\n");
|
|
2873
|
+
process.exit(1);
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
const serverDir = path.join(__dirname, "..", "sticky-server");
|
|
2877
|
+
if (!fs.existsSync(path.join(serverDir, "worker.js"))) {
|
|
2878
|
+
print(" [ERR] sticky-server/worker.js not found.");
|
|
2879
|
+
print(" Make sure you have the full sticky-note package.\n");
|
|
2880
|
+
process.exit(1);
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
print(" Step 1: Creating KV namespace...");
|
|
2884
|
+
try {
|
|
2885
|
+
const result = execSync("wrangler kv:namespace create STICKY_KV", {
|
|
2886
|
+
cwd: serverDir,
|
|
2887
|
+
encoding: "utf-8",
|
|
2888
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2889
|
+
});
|
|
2890
|
+
print(" [OK] KV namespace created");
|
|
2891
|
+
|
|
2892
|
+
// Extract namespace ID from wrangler output
|
|
2893
|
+
const idMatch = result.match(/id\s*=\s*"([^"]+)"/);
|
|
2894
|
+
if (idMatch) {
|
|
2895
|
+
const namespaceId = idMatch[1];
|
|
2896
|
+
// Update wrangler.toml with real namespace ID
|
|
2897
|
+
const tomlPath = path.join(serverDir, "wrangler.toml");
|
|
2898
|
+
let toml = fs.readFileSync(tomlPath, "utf-8");
|
|
2899
|
+
toml = toml.replace("PLACEHOLDER_KV_NAMESPACE_ID", namespaceId);
|
|
2900
|
+
fs.writeFileSync(tomlPath, toml);
|
|
2901
|
+
print(` [OK] wrangler.toml updated with namespace ID`);
|
|
2902
|
+
}
|
|
2903
|
+
} catch (err) {
|
|
2904
|
+
print(" [ERR] Failed to create KV namespace: " + (err.message || err));
|
|
2905
|
+
print(" Make sure you're logged in: wrangler login\n");
|
|
2906
|
+
process.exit(1);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
print(" Step 2: Deploying Worker...");
|
|
2910
|
+
try {
|
|
2911
|
+
const result = execSync("wrangler deploy", {
|
|
2912
|
+
cwd: serverDir,
|
|
2913
|
+
encoding: "utf-8",
|
|
2914
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2915
|
+
});
|
|
2916
|
+
// Extract worker URL
|
|
2917
|
+
const urlMatch = result.match(/(https:\/\/[^\s]+\.workers\.dev)/);
|
|
2918
|
+
const workerUrl = urlMatch ? urlMatch[1] : null;
|
|
2919
|
+
|
|
2920
|
+
const apiKey = require("crypto").randomBytes(24).toString("base64url");
|
|
2921
|
+
|
|
2922
|
+
if (workerUrl) {
|
|
2923
|
+
print(` [OK] Worker deployed: ${workerUrl}`);
|
|
2924
|
+
|
|
2925
|
+
print(" Step 3: Setting API key on Worker...");
|
|
2926
|
+
let secretSet = false;
|
|
2927
|
+
try {
|
|
2928
|
+
execSync("wrangler secret put STICKY_API_KEY", {
|
|
2929
|
+
cwd: serverDir,
|
|
2930
|
+
input: apiKey,
|
|
2931
|
+
encoding: "utf-8",
|
|
2932
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2933
|
+
});
|
|
2934
|
+
print(" [OK] STICKY_API_KEY secret set on Worker");
|
|
2935
|
+
secretSet = true;
|
|
2936
|
+
} catch (err) {
|
|
2937
|
+
print(" [WARN] Could not set STICKY_API_KEY automatically: " + (err.message || err));
|
|
2938
|
+
print(` Set it manually: echo '${apiKey}' | wrangler secret put STICKY_API_KEY`);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
// Always write .env.sticky so the key is available even if secret-put failed
|
|
2942
|
+
const envPath = path.join(process.cwd(), ".env.sticky");
|
|
2943
|
+
fs.writeFileSync(envPath, `STICKY_URL=${workerUrl}\nSTICKY_API_KEY=${apiKey}\n`, "utf-8");
|
|
2944
|
+
print(" [OK] .env.sticky written with STICKY_URL and STICKY_API_KEY");
|
|
2945
|
+
|
|
2946
|
+
if (secretSet) {
|
|
2947
|
+
print("\n Share STICKY_URL and STICKY_API_KEY with your team securely");
|
|
2948
|
+
print(" (e.g. org secrets, 1Password). Do not commit .env.sticky.\n");
|
|
2949
|
+
} else {
|
|
2950
|
+
print("\n [!] API key is in .env.sticky but NOT yet set on the Worker.");
|
|
2951
|
+
print(" Run the manual command above, then share with your team.\n");
|
|
2952
|
+
}
|
|
2953
|
+
} else {
|
|
2954
|
+
print(" [OK] Worker deployed (URL not detected — check wrangler output)");
|
|
2955
|
+
}
|
|
2956
|
+
} catch (err) {
|
|
2957
|
+
print(" [ERR] Failed to deploy Worker: " + (err.message || err));
|
|
2958
|
+
process.exit(1);
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
print("\n Backend ready! Run `npx sticky-note init --v3` in your repos.\n");
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
// ──────────────────────────────────────────────
|
|
2965
|
+
// MIGRATE --to cloud command (V3 cloud migration)
|
|
2966
|
+
// ──────────────────────────────────────────────
|
|
2967
|
+
|
|
2968
|
+
async function cmdMigrateToCloud() {
|
|
2969
|
+
|
|
2970
|
+
printBanner();
|
|
2971
|
+
print(" Migrate V2 -> V3 Cloud\n");
|
|
2972
|
+
|
|
2973
|
+
// Check cloud config
|
|
2974
|
+
const stickyUrl = process.env.STICKY_URL || "";
|
|
2975
|
+
const stickyApiKey = process.env.STICKY_API_KEY || "";
|
|
2976
|
+
|
|
2977
|
+
// Try .env.sticky
|
|
2978
|
+
let envUrl = stickyUrl;
|
|
2979
|
+
let envKey = stickyApiKey;
|
|
2980
|
+
const envStickyPath = path.join(process.cwd(), ".env.sticky");
|
|
2981
|
+
if (!envUrl && fs.existsSync(envStickyPath)) {
|
|
2982
|
+
const raw = fs.readFileSync(envStickyPath, "utf-8");
|
|
2983
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
2984
|
+
const trimmed = line.trim();
|
|
2985
|
+
if (trimmed.startsWith("STICKY_URL=")) envUrl = trimmed.slice("STICKY_URL=".length);
|
|
2986
|
+
if (trimmed.startsWith("STICKY_API_KEY=")) envKey = trimmed.slice("STICKY_API_KEY=".length);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
if (!envUrl) {
|
|
2991
|
+
print(" [ERR] STICKY_URL not found.");
|
|
2992
|
+
print(" Set it in .env.sticky or run `npx sticky-note deploy-backend` first.\n");
|
|
2993
|
+
process.exit(1);
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// Detect project name
|
|
2997
|
+
let projectName = "default";
|
|
2998
|
+
try {
|
|
2999
|
+
const remote = execSync("git remote get-url origin", { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
3000
|
+
const match = remote.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
3001
|
+
if (match) projectName = match[1];
|
|
3002
|
+
} catch (_) {}
|
|
3003
|
+
|
|
3004
|
+
const headers = {
|
|
3005
|
+
"Content-Type": "application/json",
|
|
3006
|
+
"X-Sticky-Project": projectName,
|
|
3007
|
+
};
|
|
3008
|
+
if (envKey) headers["X-Sticky-API-Key"] = envKey;
|
|
3009
|
+
|
|
3010
|
+
async function postToCloud(endpoint, body) {
|
|
3011
|
+
try {
|
|
3012
|
+
const resp = await fetch(`${envUrl}${endpoint}`, {
|
|
3013
|
+
method: "POST",
|
|
3014
|
+
headers,
|
|
3015
|
+
body: JSON.stringify(body),
|
|
3016
|
+
});
|
|
3017
|
+
return resp.ok;
|
|
3018
|
+
} catch (_) {
|
|
3019
|
+
return false;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
async function putToCloud(endpoint, body) {
|
|
3024
|
+
try {
|
|
3025
|
+
const resp = await fetch(`${envUrl}${endpoint}`, {
|
|
3026
|
+
method: "PUT",
|
|
3027
|
+
headers,
|
|
3028
|
+
body: JSON.stringify(body),
|
|
3029
|
+
});
|
|
3030
|
+
return resp.ok;
|
|
3031
|
+
} catch (_) {
|
|
3032
|
+
return false;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
print(` Project: ${projectName}`);
|
|
3037
|
+
print(` Backend: ${envUrl}\n`);
|
|
3038
|
+
|
|
3039
|
+
// Migrate threads
|
|
3040
|
+
const stickyDir = path.join(process.cwd(), ".sticky-note");
|
|
3041
|
+
const memoryPath = path.join(stickyDir, "sticky-note.json");
|
|
3042
|
+
let threadCount = 0;
|
|
3043
|
+
let threadErrors = 0;
|
|
3044
|
+
if (fs.existsSync(memoryPath)) {
|
|
3045
|
+
const memory = readJsonSafe(memoryPath, { threads: [] });
|
|
3046
|
+
const threads = memory.threads || [];
|
|
3047
|
+
print(` Migrating ${threads.length} thread(s)...`);
|
|
3048
|
+
for (const thread of threads) {
|
|
3049
|
+
const ok = await putToCloud(`/threads/${thread.id}`, thread);
|
|
3050
|
+
if (ok) threadCount++;
|
|
3051
|
+
else threadErrors++;
|
|
3052
|
+
}
|
|
3053
|
+
print(` [OK] ${threadCount} threads migrated${threadErrors ? `, ${threadErrors} failed` : ""}`);
|
|
3054
|
+
} else {
|
|
3055
|
+
print(" No sticky-note.json found — skipping threads.");
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
// Migrate audit records
|
|
3059
|
+
const auditDir = path.join(stickyDir, "audit");
|
|
3060
|
+
let auditCount = 0;
|
|
3061
|
+
let auditErrors = 0;
|
|
3062
|
+
if (fs.existsSync(auditDir)) {
|
|
3063
|
+
const files = fs.readdirSync(auditDir).filter(f => f.endsWith(".jsonl"));
|
|
3064
|
+
for (const file of files) {
|
|
3065
|
+
const raw = fs.readFileSync(path.join(auditDir, file), "utf-8");
|
|
3066
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
3067
|
+
const trimmed = line.trim();
|
|
3068
|
+
if (!trimmed) continue;
|
|
3069
|
+
try {
|
|
3070
|
+
const record = JSON.parse(trimmed);
|
|
3071
|
+
const ok = await postToCloud("/audit", record);
|
|
3072
|
+
if (ok) auditCount++;
|
|
3073
|
+
else auditErrors++;
|
|
3074
|
+
} catch (_) {
|
|
3075
|
+
auditErrors++;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
print(` [OK] ${auditCount} audit records migrated${auditErrors ? `, ${auditErrors} failed` : ""}`);
|
|
3080
|
+
} else {
|
|
3081
|
+
print(" No audit directory found — skipping audit.");
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// Migrate presence
|
|
3085
|
+
const presenceDir = path.join(stickyDir, "presence");
|
|
3086
|
+
let presenceCount = 0;
|
|
3087
|
+
if (fs.existsSync(presenceDir)) {
|
|
3088
|
+
const files = fs.readdirSync(presenceDir).filter(f => f.endsWith(".json"));
|
|
3089
|
+
for (const file of files) {
|
|
3090
|
+
try {
|
|
3091
|
+
const record = JSON.parse(fs.readFileSync(path.join(presenceDir, file), "utf-8"));
|
|
3092
|
+
const ok = await postToCloud("/presence", record);
|
|
3093
|
+
if (ok) presenceCount++;
|
|
3094
|
+
} catch (_) {}
|
|
3095
|
+
}
|
|
3096
|
+
if (presenceCount) print(` [OK] ${presenceCount} presence records migrated`);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
// Migrate config
|
|
3100
|
+
const configPath = path.join(stickyDir, "sticky-note-config.json");
|
|
3101
|
+
if (fs.existsSync(configPath)) {
|
|
3102
|
+
const config = readJsonSafe(configPath, {});
|
|
3103
|
+
const ok = await putToCloud("/config", config);
|
|
3104
|
+
if (ok) print(" [OK] Config migrated");
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
print(`\n Migration complete. ${threadCount} threads, ${auditCount} audit records.\n`);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
// ──────────────────────────────────────────────
|
|
3111
|
+
// MCP-SERVER command — launch the stdio MCP server
|
|
3112
|
+
// (delegates to bin/mcp-server.js; cloud or local determined there)
|
|
3113
|
+
// ──────────────────────────────────────────────
|
|
3114
|
+
|
|
3115
|
+
// V3 cloud-aware MCP server is kept in bin/mcp-server.js.
|
|
3116
|
+
// This stub was the inline V3 implementation; replaced by the cleaner
|
|
3117
|
+
// delegating version from main. Kept as _cmdMcpServerInline for reference.
|
|
3118
|
+
async function _cmdMcpServerInline() {
|
|
3119
|
+
// MCP server runs over stdio using JSON-RPC 2.0
|
|
3120
|
+
const stickyUrl = process.env.STICKY_URL || "";
|
|
3121
|
+
const stickyApiKey = process.env.STICKY_API_KEY || "";
|
|
3122
|
+
|
|
3123
|
+
// Try .env.sticky
|
|
3124
|
+
let envUrl = stickyUrl;
|
|
3125
|
+
let envKey = stickyApiKey;
|
|
3126
|
+
const envStickyPath = path.join(process.cwd(), ".env.sticky");
|
|
3127
|
+
if (!envUrl && fs.existsSync(envStickyPath)) {
|
|
3128
|
+
const raw = fs.readFileSync(envStickyPath, "utf-8");
|
|
3129
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
3130
|
+
const trimmed = line.trim();
|
|
3131
|
+
if (trimmed.startsWith("STICKY_URL=")) envUrl = trimmed.slice("STICKY_URL=".length);
|
|
3132
|
+
if (trimmed.startsWith("STICKY_API_KEY=")) envKey = trimmed.slice("STICKY_API_KEY=".length);
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// Detect project
|
|
3137
|
+
let projectName = "default";
|
|
3138
|
+
try {
|
|
3139
|
+
const remote = execSync("git remote get-url origin", { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
3140
|
+
const match = remote.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
3141
|
+
if (match) projectName = match[1];
|
|
3142
|
+
} catch (_) {}
|
|
3143
|
+
|
|
3144
|
+
const baseHeaders = {
|
|
3145
|
+
"Content-Type": "application/json",
|
|
3146
|
+
"X-Sticky-Project": projectName,
|
|
3147
|
+
};
|
|
3148
|
+
if (envKey) baseHeaders["X-Sticky-API-Key"] = envKey;
|
|
3149
|
+
|
|
3150
|
+
async function cloudGet(endpoint) {
|
|
3151
|
+
if (!envUrl) return null;
|
|
3152
|
+
try {
|
|
3153
|
+
const resp = await fetch(`${envUrl}${endpoint}`, { headers: baseHeaders });
|
|
3154
|
+
if (!resp.ok) return null;
|
|
3155
|
+
return await resp.json();
|
|
3156
|
+
} catch (_) {
|
|
3157
|
+
return null;
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
async function cloudPost(endpoint, body) {
|
|
3162
|
+
if (!envUrl) return null;
|
|
3163
|
+
try {
|
|
3164
|
+
const resp = await fetch(`${envUrl}${endpoint}`, {
|
|
3165
|
+
method: "PUT",
|
|
3166
|
+
headers: baseHeaders,
|
|
3167
|
+
body: JSON.stringify(body),
|
|
3168
|
+
});
|
|
3169
|
+
if (!resp.ok) return null;
|
|
3170
|
+
return await resp.json();
|
|
3171
|
+
} catch (_) {
|
|
3172
|
+
return null;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
// Fallback: read from local files
|
|
3177
|
+
const stickyDir = path.join(process.cwd(), ".sticky-note");
|
|
3178
|
+
const memoryPath = path.join(stickyDir, "sticky-note.json");
|
|
3179
|
+
|
|
3180
|
+
function localThreads(filter) {
|
|
3181
|
+
const memory = readJsonSafe(memoryPath, { threads: [] });
|
|
3182
|
+
let threads = memory.threads || [];
|
|
3183
|
+
if (filter) threads = threads.filter(filter);
|
|
3184
|
+
return threads;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// MCP tool definitions
|
|
3188
|
+
const tools = [
|
|
3189
|
+
{ name: "get_open_threads", description: "Get all open threads", inputSchema: { type: "object", properties: {} } },
|
|
3190
|
+
{ name: "get_stuck_threads", description: "Get all stuck threads", inputSchema: { type: "object", properties: {} } },
|
|
3191
|
+
{ name: "search_threads", description: "Search threads by query", inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] } },
|
|
3192
|
+
{ name: "get_session_context", description: "Get a specific thread by ID", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
3193
|
+
{ name: "write_thread", description: "Create or update a thread", inputSchema: { type: "object", properties: { thread: { type: "object" } }, required: ["thread"] } },
|
|
3194
|
+
{ name: "get_team_config", description: "Get team configuration", inputSchema: { type: "object", properties: {} } },
|
|
3195
|
+
{ name: "get_presence", description: "Get active developer presence", inputSchema: { type: "object", properties: {} } },
|
|
3196
|
+
{ name: "get_audit_trail", description: "Query audit trail", inputSchema: { type: "object", properties: { file: { type: "string" }, user: { type: "string" }, since: { type: "string" }, tool: { type: "string" } } } },
|
|
3197
|
+
];
|
|
3198
|
+
|
|
3199
|
+
// Handle MCP tool calls
|
|
3200
|
+
async function handleToolCall(name, args) {
|
|
3201
|
+
switch (name) {
|
|
3202
|
+
case "get_open_threads": {
|
|
3203
|
+
const result = await cloudGet("/threads?status=open");
|
|
3204
|
+
return result ? result.threads : localThreads(t => t.status === "open");
|
|
3205
|
+
}
|
|
3206
|
+
case "get_stuck_threads": {
|
|
3207
|
+
const result = await cloudGet("/threads?status=stuck");
|
|
3208
|
+
return result ? result.threads : localThreads(t => t.status === "stuck");
|
|
3209
|
+
}
|
|
3210
|
+
case "search_threads": {
|
|
3211
|
+
const q = args.query || "";
|
|
3212
|
+
const result = await cloudGet(`/threads?q=${encodeURIComponent(q)}`);
|
|
3213
|
+
if (result) return result.threads;
|
|
3214
|
+
const qLower = q.toLowerCase();
|
|
3215
|
+
return localThreads(t =>
|
|
3216
|
+
(t.narrative || "").toLowerCase().includes(qLower) ||
|
|
3217
|
+
(t.handoff_summary || "").toLowerCase().includes(qLower) ||
|
|
3218
|
+
(t.files_touched || []).some(f => f.toLowerCase().includes(qLower))
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
case "get_session_context": {
|
|
3222
|
+
const id = args.id;
|
|
3223
|
+
const result = await cloudGet(`/threads/${id}`);
|
|
3224
|
+
if (result && !result.error) return result;
|
|
3225
|
+
const memory = readJsonSafe(memoryPath, { threads: [] });
|
|
3226
|
+
return (memory.threads || []).find(t => t.id === id || t.id.startsWith(id)) || null;
|
|
3227
|
+
}
|
|
3228
|
+
case "write_thread": {
|
|
3229
|
+
const thread = args.thread;
|
|
3230
|
+
if (envUrl) await cloudPost(`/threads/${thread.id}`, thread);
|
|
3231
|
+
return { ok: true };
|
|
3232
|
+
}
|
|
3233
|
+
case "get_team_config": {
|
|
3234
|
+
const result = await cloudGet("/config");
|
|
3235
|
+
if (result) return result;
|
|
3236
|
+
const configPath = path.join(stickyDir, "sticky-note-config.json");
|
|
3237
|
+
return readJsonSafe(configPath, {});
|
|
3238
|
+
}
|
|
3239
|
+
case "get_presence": {
|
|
3240
|
+
const result = await cloudGet("/presence");
|
|
3241
|
+
if (result) return result.presence;
|
|
3242
|
+
// Local fallback: read presence files
|
|
3243
|
+
const presDir = path.join(stickyDir, "presence");
|
|
3244
|
+
try {
|
|
3245
|
+
return fs.readdirSync(presDir).filter(f => f.endsWith(".json")).map(f =>
|
|
3246
|
+
JSON.parse(fs.readFileSync(path.join(presDir, f), "utf-8"))
|
|
3247
|
+
);
|
|
3248
|
+
} catch (_) {
|
|
3249
|
+
return [];
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
case "get_audit_trail": {
|
|
3253
|
+
const params = new URLSearchParams();
|
|
3254
|
+
if (args.file) params.set("file", args.file);
|
|
3255
|
+
if (args.user) params.set("user", args.user);
|
|
3256
|
+
if (args.since) params.set("since", args.since);
|
|
3257
|
+
if (args.tool) params.set("tool", args.tool);
|
|
3258
|
+
const qs = params.toString();
|
|
3259
|
+
const result = await cloudGet(`/audit${qs ? "?" + qs : ""}`);
|
|
3260
|
+
if (result) return result.audit;
|
|
3261
|
+
// Local fallback: grep JSONL
|
|
3262
|
+
const auditDir = path.join(stickyDir, "audit");
|
|
3263
|
+
const records = [];
|
|
3264
|
+
try {
|
|
3265
|
+
for (const f of fs.readdirSync(auditDir).filter(f => f.endsWith(".jsonl"))) {
|
|
3266
|
+
const raw = fs.readFileSync(path.join(auditDir, f), "utf-8");
|
|
3267
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
3268
|
+
if (!line.trim()) continue;
|
|
3269
|
+
try {
|
|
3270
|
+
const r = JSON.parse(line);
|
|
3271
|
+
if (args.user && r.user !== args.user) continue;
|
|
3272
|
+
if (args.file && !(r.file || "").includes(args.file)) continue;
|
|
3273
|
+
if (args.tool && r.tool !== args.tool) continue;
|
|
3274
|
+
if (args.since && new Date(r.timestamp) < new Date(args.since)) continue;
|
|
3275
|
+
records.push(r);
|
|
3276
|
+
} catch (_) {}
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
} catch (_) {}
|
|
3280
|
+
return records;
|
|
3281
|
+
}
|
|
3282
|
+
default:
|
|
3283
|
+
return { error: `Unknown tool: ${name}` };
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
// MCP stdio protocol
|
|
3288
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
3289
|
+
let buffer = "";
|
|
3290
|
+
|
|
3291
|
+
rl.on("line", async (line) => {
|
|
3292
|
+
try {
|
|
3293
|
+
const msg = JSON.parse(line);
|
|
3294
|
+
|
|
3295
|
+
if (msg.method === "initialize") {
|
|
3296
|
+
const response = {
|
|
3297
|
+
jsonrpc: "2.0",
|
|
3298
|
+
id: msg.id,
|
|
3299
|
+
result: {
|
|
3300
|
+
protocolVersion: "2024-11-05",
|
|
3301
|
+
capabilities: { tools: {} },
|
|
3302
|
+
serverInfo: { name: "sticky-note", version: VERSION },
|
|
3303
|
+
},
|
|
3304
|
+
};
|
|
3305
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
3306
|
+
} else if (msg.method === "notifications/initialized") {
|
|
3307
|
+
// No response needed
|
|
3308
|
+
} else if (msg.method === "tools/list") {
|
|
3309
|
+
const response = {
|
|
3310
|
+
jsonrpc: "2.0",
|
|
3311
|
+
id: msg.id,
|
|
3312
|
+
result: { tools },
|
|
3313
|
+
};
|
|
3314
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
3315
|
+
} else if (msg.method === "tools/call") {
|
|
3316
|
+
const toolName = msg.params.name;
|
|
3317
|
+
const toolArgs = msg.params.arguments || {};
|
|
3318
|
+
const result = await handleToolCall(toolName, toolArgs);
|
|
3319
|
+
const response = {
|
|
3320
|
+
jsonrpc: "2.0",
|
|
3321
|
+
id: msg.id,
|
|
3322
|
+
result: {
|
|
3323
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
3324
|
+
},
|
|
3325
|
+
};
|
|
3326
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
3327
|
+
} else if (msg.id) {
|
|
3328
|
+
// Unknown method with id — return error
|
|
3329
|
+
const response = {
|
|
3330
|
+
jsonrpc: "2.0",
|
|
3331
|
+
id: msg.id,
|
|
3332
|
+
error: { code: -32601, message: `Unknown method: ${msg.method}` },
|
|
3333
|
+
};
|
|
3334
|
+
process.stdout.write(JSON.stringify(response) + "\n");
|
|
3335
|
+
}
|
|
3336
|
+
} catch (err) {
|
|
3337
|
+
// Parse error
|
|
3338
|
+
if (line.trim()) {
|
|
3339
|
+
process.stderr.write(`[sticky-note mcp] Parse error: ${err.message}\n`);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
});
|
|
3343
|
+
}
|
|
3344
|
+
|
|
2696
3345
|
// ──────────────────────────────────────────────
|
|
2697
3346
|
// sync — commit and optionally push .sticky-note/ files
|
|
2698
3347
|
// ──────────────────────────────────────────────
|
|
@@ -3350,6 +3999,318 @@ async function cmdEnvAddPlugin() {
|
|
|
3350
3999
|
print("");
|
|
3351
4000
|
}
|
|
3352
4001
|
|
|
4002
|
+
// ──────────────────────────────────────────────
|
|
4003
|
+
// run-hook: locate and execute a hook from any directory
|
|
4004
|
+
// ──────────────────────────────────────────────
|
|
4005
|
+
|
|
4006
|
+
function cmdRunHook() {
|
|
4007
|
+
const { spawnSync } = require("child_process");
|
|
4008
|
+
const hookName = process.argv[3]; // e.g. "session-start"
|
|
4009
|
+
|
|
4010
|
+
if (!hookName) {
|
|
4011
|
+
process.stderr.write(
|
|
4012
|
+
"[sticky-note] run-hook: missing hook name\n" +
|
|
4013
|
+
" Usage: npx sticky-note run-hook <name>\n" +
|
|
4014
|
+
" Names: session-start, session-end, inject-context, pre-tool-use,\n" +
|
|
4015
|
+
" track-work, on-stop, on-error\n"
|
|
4016
|
+
);
|
|
4017
|
+
// Emit empty output so Claude Code doesn't choke on missing stdout JSON
|
|
4018
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
4019
|
+
process.exit(0);
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// Walk up from cwd to find .claude/hooks/<name>.js
|
|
4023
|
+
const hookFile = hookName.endsWith(".js") ? hookName : `${hookName}.js`;
|
|
4024
|
+
let dir = process.cwd();
|
|
4025
|
+
let hookPath = null;
|
|
4026
|
+
for (let i = 0; i < 20; i++) {
|
|
4027
|
+
const candidate = path.join(dir, ".claude", "hooks", hookFile);
|
|
4028
|
+
if (fs.existsSync(candidate)) {
|
|
4029
|
+
hookPath = candidate;
|
|
4030
|
+
break;
|
|
4031
|
+
}
|
|
4032
|
+
const parent = path.dirname(dir);
|
|
4033
|
+
if (parent === dir) break; // filesystem root
|
|
4034
|
+
dir = parent;
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
if (!hookPath) {
|
|
4038
|
+
// Hook not installed — exit silently (non-fatal)
|
|
4039
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
4040
|
+
process.exit(0);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
// Spawn the hook with stdin/stdout/stderr inherited so Claude Code's pipe
|
|
4044
|
+
// flows through to the hook process unchanged.
|
|
4045
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
4046
|
+
stdio: "inherit",
|
|
4047
|
+
env: process.env,
|
|
4048
|
+
});
|
|
4049
|
+
process.exit(result.status != null ? result.status : 0);
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
// ──────────────────────────────────────────────
|
|
4053
|
+
// MIGRATE command (dispatcher)
|
|
4054
|
+
// Default: migrate to git data branch (from main)
|
|
4055
|
+
// --to cloud: migrate local data to Cloudflare KV (from V3)
|
|
4056
|
+
// ──────────────────────────────────────────────
|
|
4057
|
+
|
|
4058
|
+
async function cmdMigrate() {
|
|
4059
|
+
const args = process.argv.slice(3);
|
|
4060
|
+
if (args.includes("--to") && args.includes("cloud")) {
|
|
4061
|
+
return cmdMigrateToCloud();
|
|
4062
|
+
}
|
|
4063
|
+
return _cmdMigrateDataBranch();
|
|
4064
|
+
}
|
|
4065
|
+
|
|
4066
|
+
/**
|
|
4067
|
+
* One-time migration from .sticky-note/ (feature-branch storage) to
|
|
4068
|
+
* .git/sticky-note/ (data-branch storage).
|
|
4069
|
+
*
|
|
4070
|
+
* Idempotent — safe to run multiple times.
|
|
4071
|
+
*/
|
|
4072
|
+
async function _cmdMigrateDataBranch() {
|
|
4073
|
+
printBanner();
|
|
4074
|
+
|
|
4075
|
+
if (!isGitRepo()) {
|
|
4076
|
+
print(" [ERR] Not a git repository.");
|
|
4077
|
+
process.exit(1);
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
const cwd = process.cwd();
|
|
4081
|
+
const oldDir = path.join(cwd, ".sticky-note");
|
|
4082
|
+
|
|
4083
|
+
// Locate .git directory using plumbing (works from subdirectories)
|
|
4084
|
+
let gitDir;
|
|
4085
|
+
try {
|
|
4086
|
+
gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
4087
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
4088
|
+
cwd,
|
|
4089
|
+
}).trim();
|
|
4090
|
+
} catch (err) {
|
|
4091
|
+
print(" [ERR] Could not locate .git directory: " + err.message);
|
|
4092
|
+
process.exit(1);
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
const newDir = path.join(gitDir, "sticky-note");
|
|
4096
|
+
print(" 📂 Source: " + oldDir);
|
|
4097
|
+
print(" 📂 Target: " + newDir);
|
|
4098
|
+
print("");
|
|
4099
|
+
|
|
4100
|
+
// Step 1: Copy files from .sticky-note/ to .git/sticky-note/
|
|
4101
|
+
mkdirSafe(newDir);
|
|
4102
|
+
mkdirSafe(path.join(newDir, "audit"));
|
|
4103
|
+
mkdirSafe(path.join(newDir, "presence"));
|
|
4104
|
+
|
|
4105
|
+
let copiedFiles = 0;
|
|
4106
|
+
const srcMemory = path.join(oldDir, "sticky-note.json");
|
|
4107
|
+
const dstMemory = path.join(newDir, "sticky-note.json");
|
|
4108
|
+
if (fs.existsSync(srcMemory)) {
|
|
4109
|
+
fs.copyFileSync(srcMemory, dstMemory);
|
|
4110
|
+
copiedFiles++;
|
|
4111
|
+
print(" [OK] Copied sticky-note.json");
|
|
4112
|
+
} else if (!fs.existsSync(dstMemory)) {
|
|
4113
|
+
// No existing data — create empty memory
|
|
4114
|
+
fs.writeFileSync(
|
|
4115
|
+
dstMemory,
|
|
4116
|
+
JSON.stringify({ version: "2", project: "", threads: [] }, null, 2) + "\n"
|
|
4117
|
+
);
|
|
4118
|
+
print(" [OK] Created empty sticky-note.json (no .sticky-note/ found)");
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
const oldAuditDir = path.join(oldDir, "audit");
|
|
4122
|
+
if (fs.existsSync(oldAuditDir)) {
|
|
4123
|
+
for (const file of fs.readdirSync(oldAuditDir)) {
|
|
4124
|
+
if (file.endsWith(".jsonl")) {
|
|
4125
|
+
const dst = path.join(newDir, "audit", file);
|
|
4126
|
+
if (!fs.existsSync(dst)) {
|
|
4127
|
+
fs.copyFileSync(path.join(oldAuditDir, file), dst);
|
|
4128
|
+
copiedFiles++;
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
|
|
4134
|
+
const oldPresenceDir = path.join(oldDir, "presence");
|
|
4135
|
+
if (fs.existsSync(oldPresenceDir)) {
|
|
4136
|
+
for (const file of fs.readdirSync(oldPresenceDir)) {
|
|
4137
|
+
if (file.endsWith(".json")) {
|
|
4138
|
+
const dst = path.join(newDir, "presence", file);
|
|
4139
|
+
if (!fs.existsSync(dst)) {
|
|
4140
|
+
fs.copyFileSync(path.join(oldPresenceDir, file), dst);
|
|
4141
|
+
copiedFiles++;
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
if (copiedFiles > 0) print(" [OK] Copied " + copiedFiles + " file(s) to .git/sticky-note/");
|
|
4147
|
+
|
|
4148
|
+
// Step 2: Commit files to sticky-note/data branch using plumbing
|
|
4149
|
+
print("");
|
|
4150
|
+
print(" 📌 Committing to sticky-note/data branch...");
|
|
4151
|
+
try {
|
|
4152
|
+
// Inline the plumbing here (data-branch.js lives in hooks, not in CLI path)
|
|
4153
|
+
_commitFilesToDataBranch(newDir);
|
|
4154
|
+
print(" [OK] sticky-note/data branch created/updated");
|
|
4155
|
+
} catch (err) {
|
|
4156
|
+
print(" [WARN] Could not commit to data branch: " + err.message);
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
// Step 3: Add .sticky-note/ to .gitignore
|
|
4160
|
+
print("");
|
|
4161
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
4162
|
+
const MARKER = "# sticky-note: data branch migration";
|
|
4163
|
+
const IGNORE_LINE = ".sticky-note/";
|
|
4164
|
+
let gitignoreUpdated = false;
|
|
4165
|
+
if (fs.existsSync(gitignorePath)) {
|
|
4166
|
+
const existing = fs.readFileSync(gitignorePath, "utf-8");
|
|
4167
|
+
const gitignoreLines = existing.split(/\r?\n/).map(l => l.trim());
|
|
4168
|
+
if (!gitignoreLines.includes(IGNORE_LINE)) {
|
|
4169
|
+
fs.appendFileSync(gitignorePath, "\n" + MARKER + "\n" + IGNORE_LINE + "\n");
|
|
4170
|
+
gitignoreUpdated = true;
|
|
4171
|
+
}
|
|
4172
|
+
} else {
|
|
4173
|
+
fs.writeFileSync(gitignorePath, MARKER + "\n" + IGNORE_LINE + "\n");
|
|
4174
|
+
gitignoreUpdated = true;
|
|
4175
|
+
}
|
|
4176
|
+
if (gitignoreUpdated) {
|
|
4177
|
+
print(" [OK] Added .sticky-note/ to .gitignore");
|
|
4178
|
+
} else {
|
|
4179
|
+
print(" [OK] .gitignore already contains .sticky-note/");
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
// Step 4: Remove sticky-note pre-commit and post-commit hooks
|
|
4183
|
+
print("");
|
|
4184
|
+
const gitHooksDir = path.join(gitDir, "hooks");
|
|
4185
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
4186
|
+
const hookPath = path.join(gitHooksDir, hookName);
|
|
4187
|
+
if (!fs.existsSync(hookPath)) continue;
|
|
4188
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
4189
|
+
if (content.includes(STICKY_PRE_COMMIT_MARKER) || content.includes(STICKY_POST_COMMIT_MARKER)) {
|
|
4190
|
+
// Remove sticky-note blocks
|
|
4191
|
+
let updated = content;
|
|
4192
|
+
// Remove pre-commit block
|
|
4193
|
+
const preStart = updated.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
4194
|
+
const preEnd = updated.indexOf(STICKY_PRE_COMMIT_END);
|
|
4195
|
+
if (preStart !== -1 && preEnd !== -1) {
|
|
4196
|
+
updated = updated.slice(0, preStart) + updated.slice(preEnd + STICKY_PRE_COMMIT_END.length);
|
|
4197
|
+
}
|
|
4198
|
+
// Remove post-commit block
|
|
4199
|
+
const postStart = updated.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
4200
|
+
const postEnd = updated.indexOf(STICKY_POST_COMMIT_END);
|
|
4201
|
+
if (postStart !== -1 && postEnd !== -1) {
|
|
4202
|
+
updated = updated.slice(0, postStart) + updated.slice(postEnd + STICKY_POST_COMMIT_END.length);
|
|
4203
|
+
}
|
|
4204
|
+
// If only the shebang remains, delete the file
|
|
4205
|
+
if (updated.replace(/^#!.*\n/, "").trim() === "") {
|
|
4206
|
+
fs.unlinkSync(hookPath);
|
|
4207
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (was sticky-note only)");
|
|
4208
|
+
} else {
|
|
4209
|
+
fs.writeFileSync(hookPath, updated, "utf-8");
|
|
4210
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
4211
|
+
}
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
// Step 5: Push data branch if remote exists
|
|
4216
|
+
print("");
|
|
4217
|
+
try {
|
|
4218
|
+
const remote = execFileSync("git", ["remote"], {
|
|
4219
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
4220
|
+
cwd,
|
|
4221
|
+
}).trim().split("\n")[0];
|
|
4222
|
+
if (remote) {
|
|
4223
|
+
print(" 🔄 Pushing sticky-note/data to " + remote + "...");
|
|
4224
|
+
execFileSync("git", ["push", remote, "refs/heads/sticky-note/data:refs/heads/sticky-note/data"], {
|
|
4225
|
+
timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
|
|
4226
|
+
cwd,
|
|
4227
|
+
});
|
|
4228
|
+
print(" [OK] Pushed sticky-note/data");
|
|
4229
|
+
} else {
|
|
4230
|
+
print(" ⏭️ No remote configured — skipping push");
|
|
4231
|
+
}
|
|
4232
|
+
} catch (err) {
|
|
4233
|
+
print(" [WARN] Push failed: " + err.message);
|
|
4234
|
+
print(" [INFO] Your data is safe locally. Run `npx sticky-note migrate` again to retry.");
|
|
4235
|
+
}
|
|
4236
|
+
|
|
4237
|
+
print("");
|
|
4238
|
+
print(" ✅ Migration complete!");
|
|
4239
|
+
print("");
|
|
4240
|
+
print(" Next steps:");
|
|
4241
|
+
print(" 1. Commit the .gitignore change: git add .gitignore && git commit -m 'chore: gitignore sticky-note data dir'");
|
|
4242
|
+
print(" 2. Each teammate runs: npx sticky-note init");
|
|
4243
|
+
print("");
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
/**
|
|
4247
|
+
* Inline git plumbing to commit files from a local dir to sticky-note/data.
|
|
4248
|
+
* Extracted from data-branch.js for use in CLI context (no hooks dir on PATH).
|
|
4249
|
+
*/
|
|
4250
|
+
function _commitFilesToDataBranch(srcDir) {
|
|
4251
|
+
const os = require("os");
|
|
4252
|
+
const crypto = require("crypto");
|
|
4253
|
+
const branchRef = "refs/heads/sticky-note/data";
|
|
4254
|
+
const tmpIndex = path.join(
|
|
4255
|
+
os.tmpdir(),
|
|
4256
|
+
"sticky-idx-" + process.pid + "-" + crypto.randomBytes(4).toString("hex")
|
|
4257
|
+
);
|
|
4258
|
+
|
|
4259
|
+
try {
|
|
4260
|
+
let parentSha = null;
|
|
4261
|
+
try {
|
|
4262
|
+
parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
|
|
4263
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
4264
|
+
}).trim();
|
|
4265
|
+
} catch (_) {}
|
|
4266
|
+
|
|
4267
|
+
const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
4268
|
+
if (parentSha) {
|
|
4269
|
+
execFileSync("git", ["read-tree", parentSha], {
|
|
4270
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
4271
|
+
});
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
// Walk srcDir and commit all files
|
|
4275
|
+
function walkAndAdd(dir, prefix) {
|
|
4276
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
4277
|
+
const full = path.join(dir, entry.name);
|
|
4278
|
+
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
|
|
4279
|
+
if (entry.isDirectory()) {
|
|
4280
|
+
walkAndAdd(full, relPath);
|
|
4281
|
+
} else if (entry.isFile() && !entry.name.startsWith(".sticky-")) {
|
|
4282
|
+
const content = fs.readFileSync(full);
|
|
4283
|
+
const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
|
4284
|
+
input: content, encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
4285
|
+
}).trim();
|
|
4286
|
+
execFileSync(
|
|
4287
|
+
"git",
|
|
4288
|
+
["update-index", "--add", "--cacheinfo", "100644," + blobSha + "," + relPath],
|
|
4289
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv }
|
|
4290
|
+
);
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
walkAndAdd(srcDir, "");
|
|
4295
|
+
|
|
4296
|
+
const treeSha = execFileSync("git", ["write-tree"], {
|
|
4297
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
4298
|
+
}).trim();
|
|
4299
|
+
|
|
4300
|
+
const commitArgs = ["commit-tree", treeSha, "-m", "chore(sticky-note): migrate to data branch"];
|
|
4301
|
+
if (parentSha) commitArgs.push("-p", parentSha);
|
|
4302
|
+
const commitSha = execFileSync("git", commitArgs, {
|
|
4303
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
4304
|
+
}).trim();
|
|
4305
|
+
|
|
4306
|
+
execFileSync("git", ["update-ref", branchRef, commitSha], {
|
|
4307
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
4308
|
+
});
|
|
4309
|
+
} finally {
|
|
4310
|
+
try { fs.unlinkSync(tmpIndex); } catch (_) {}
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
|
|
3353
4314
|
// ──────────────────────────────────────────────
|
|
3354
4315
|
// Main
|
|
3355
4316
|
// ──────────────────────────────────────────────
|
|
@@ -3374,6 +4335,9 @@ async function main() {
|
|
|
3374
4335
|
case "resume":
|
|
3375
4336
|
cmdResume();
|
|
3376
4337
|
break;
|
|
4338
|
+
case "transcript":
|
|
4339
|
+
cmdTranscript();
|
|
4340
|
+
break;
|
|
3377
4341
|
case "audit":
|
|
3378
4342
|
cmdAudit();
|
|
3379
4343
|
break;
|
|
@@ -3398,6 +4362,9 @@ async function main() {
|
|
|
3398
4362
|
case "checkpoint":
|
|
3399
4363
|
cmdCheckpoint();
|
|
3400
4364
|
break;
|
|
4365
|
+
case "deploy-backend":
|
|
4366
|
+
cmdDeployBackend();
|
|
4367
|
+
break;
|
|
3401
4368
|
case "overlap":
|
|
3402
4369
|
cmdOverlap();
|
|
3403
4370
|
break;
|
|
@@ -3419,6 +4386,12 @@ async function main() {
|
|
|
3419
4386
|
case "mcp-server":
|
|
3420
4387
|
cmdMcpServer();
|
|
3421
4388
|
break;
|
|
4389
|
+
case "run-hook":
|
|
4390
|
+
cmdRunHook();
|
|
4391
|
+
break;
|
|
4392
|
+
case "migrate":
|
|
4393
|
+
await cmdMigrate();
|
|
4394
|
+
break;
|
|
3422
4395
|
case "--version":
|
|
3423
4396
|
case "-v":
|
|
3424
4397
|
print(`sticky-note v${VERSION}`);
|
|
@@ -3429,11 +4402,14 @@ async function main() {
|
|
|
3429
4402
|
printBanner();
|
|
3430
4403
|
print(" Usage: npx sticky-note <command>\n");
|
|
3431
4404
|
print(" Commands:");
|
|
3432
|
-
print(" init Interactive setup — creates
|
|
4405
|
+
print(" init Interactive setup — creates hooks and config");
|
|
4406
|
+
print(" init --v3 V3 setup with Cloudflare KV cloud backend");
|
|
4407
|
+
print(" init --ci Non-interactive setup for CI/GitHub Actions");
|
|
3433
4408
|
print(" update Update hook scripts (preserves data)");
|
|
3434
4409
|
print(" status Diagnostic report: threads, audit, attribution health");
|
|
3435
4410
|
print(" threads List open/stuck threads");
|
|
3436
4411
|
print(" resume Resume a previous thread (--list, --clear, <id>)");
|
|
4412
|
+
print(" transcript Show a thread's captured verbatim transcript (--list, --raw, <id>)");
|
|
3437
4413
|
print(" resume-thread Smart thread resume (--query, --user, --file, --json)");
|
|
3438
4414
|
print(" audit Query audit trail (--file, --user, --since, --session)");
|
|
3439
4415
|
print(" who Show active and recent team members");
|
|
@@ -3442,13 +4418,16 @@ async function main() {
|
|
|
3442
4418
|
print(" reset Wipe all threads and start fresh (--force, --keep-audit)");
|
|
3443
4419
|
print(" get-line-attribution File→thread attribution with line ranges (--file, --lines)");
|
|
3444
4420
|
print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
|
|
4421
|
+
print(" deploy-backend Provision Cloudflare KV backend (V3)");
|
|
4422
|
+
print(" migrate Migrate to git data branch (default) or cloud (--to cloud)");
|
|
3445
4423
|
print(" overlap Detect file overlaps with open/stuck threads");
|
|
3446
4424
|
print(" claim Claim ownership of files you're working on (--list, --clear)");
|
|
3447
4425
|
print(" sync Commit .sticky-note/ changes to git (--push to also push)");
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
4426
|
+
print(" pull Auto-sync .sticky-note/ then git pull (alias: git spull)");
|
|
4427
|
+
print(" bootstrap Provision MCP servers with secrets from manifest");
|
|
4428
|
+
print(" env Environment management (status, add-server, add-plugin)");
|
|
4429
|
+
print(" mcp-server Launch the sticky-note MCP server (stdio JSON-RPC)");
|
|
4430
|
+
print(" run-hook <name> Run a hook from any subdirectory (finds .claude/hooks/ up the tree)");
|
|
3452
4431
|
print("");
|
|
3453
4432
|
print(" Options:");
|
|
3454
4433
|
print(" --version Show version");
|