sticky-note-cli 2.9.2 → 3.0.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 +353 -14
- package/package.json +1 -1
- package/templates/gitignore-additions.txt +3 -0
- package/templates/hooks/data-branch.js +256 -0
- package/templates/hooks/session-end.js +55 -0
- package/templates/hooks/session-start.js +48 -0
- package/templates/hooks/sticky-utils.js +35 -15
- package/templates/hooks.json +47 -47
- package/templates/settings.json +81 -81
- 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
|
}
|
|
@@ -975,15 +984,9 @@ async function cmdInit() {
|
|
|
975
984
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
976
985
|
}
|
|
977
986
|
|
|
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
|
-
}
|
|
987
|
+
// Data branch sync handles persistence — no pre/post-commit hooks needed
|
|
988
|
+
print(" [OK] Skipping git pre/post-commit hooks (data branch sync handles this now)");
|
|
989
|
+
print(" Run `npx sticky-note migrate` to migrate existing .sticky-note/ data.");
|
|
987
990
|
|
|
988
991
|
// Create environment directory structure
|
|
989
992
|
const envDir = path.join(stickyNoteDir, "environment");
|
|
@@ -1123,11 +1126,35 @@ function cmdUpdate() {
|
|
|
1123
1126
|
if (installGitHook("post-rewrite")) {
|
|
1124
1127
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
1125
1128
|
}
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
if (
|
|
1130
|
-
|
|
1129
|
+
|
|
1130
|
+
// Remove sticky-note pre-commit and post-commit git hooks (replaced by data branch)
|
|
1131
|
+
const gitDir2 = path.join(process.cwd(), ".git");
|
|
1132
|
+
if (fs.existsSync(gitDir2)) {
|
|
1133
|
+
const gitHooksDir2 = path.join(gitDir2, "hooks");
|
|
1134
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
1135
|
+
const hookPath2 = path.join(gitHooksDir2, hookName);
|
|
1136
|
+
if (!fs.existsSync(hookPath2)) continue;
|
|
1137
|
+
const content2 = fs.readFileSync(hookPath2, "utf-8");
|
|
1138
|
+
if (!content2.includes(STICKY_PRE_COMMIT_MARKER) && !content2.includes(STICKY_POST_COMMIT_MARKER)) continue;
|
|
1139
|
+
let updated2 = content2;
|
|
1140
|
+
const preStart2 = updated2.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
1141
|
+
const preEnd2 = updated2.indexOf(STICKY_PRE_COMMIT_END);
|
|
1142
|
+
if (preStart2 !== -1 && preEnd2 !== -1) {
|
|
1143
|
+
updated2 = updated2.slice(0, preStart2) + updated2.slice(preEnd2 + STICKY_PRE_COMMIT_END.length);
|
|
1144
|
+
}
|
|
1145
|
+
const postStart2 = updated2.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
1146
|
+
const postEnd2 = updated2.indexOf(STICKY_POST_COMMIT_END);
|
|
1147
|
+
if (postStart2 !== -1 && postEnd2 !== -1) {
|
|
1148
|
+
updated2 = updated2.slice(0, postStart2) + updated2.slice(postEnd2 + STICKY_POST_COMMIT_END.length);
|
|
1149
|
+
}
|
|
1150
|
+
if (updated2.replace(/^#!.*\n/, "").trim() === "") {
|
|
1151
|
+
fs.unlinkSync(hookPath2);
|
|
1152
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (no longer needed)");
|
|
1153
|
+
} else {
|
|
1154
|
+
fs.writeFileSync(hookPath2, updated2, "utf-8");
|
|
1155
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1131
1158
|
}
|
|
1132
1159
|
|
|
1133
1160
|
// Update hook_version in config
|
|
@@ -3350,6 +3377,308 @@ async function cmdEnvAddPlugin() {
|
|
|
3350
3377
|
print("");
|
|
3351
3378
|
}
|
|
3352
3379
|
|
|
3380
|
+
// ──────────────────────────────────────────────
|
|
3381
|
+
// run-hook: locate and execute a hook from any directory
|
|
3382
|
+
// ──────────────────────────────────────────────
|
|
3383
|
+
|
|
3384
|
+
function cmdRunHook() {
|
|
3385
|
+
const { spawnSync } = require("child_process");
|
|
3386
|
+
const hookName = process.argv[3]; // e.g. "session-start"
|
|
3387
|
+
|
|
3388
|
+
if (!hookName) {
|
|
3389
|
+
process.stderr.write(
|
|
3390
|
+
"[sticky-note] run-hook: missing hook name\n" +
|
|
3391
|
+
" Usage: npx sticky-note run-hook <name>\n" +
|
|
3392
|
+
" Names: session-start, session-end, inject-context, pre-tool-use,\n" +
|
|
3393
|
+
" track-work, on-stop, on-error\n"
|
|
3394
|
+
);
|
|
3395
|
+
// Emit empty output so Claude Code doesn't choke on missing stdout JSON
|
|
3396
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
3397
|
+
process.exit(0);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// Walk up from cwd to find .claude/hooks/<name>.js
|
|
3401
|
+
const hookFile = hookName.endsWith(".js") ? hookName : `${hookName}.js`;
|
|
3402
|
+
let dir = process.cwd();
|
|
3403
|
+
let hookPath = null;
|
|
3404
|
+
for (let i = 0; i < 20; i++) {
|
|
3405
|
+
const candidate = path.join(dir, ".claude", "hooks", hookFile);
|
|
3406
|
+
if (fs.existsSync(candidate)) {
|
|
3407
|
+
hookPath = candidate;
|
|
3408
|
+
break;
|
|
3409
|
+
}
|
|
3410
|
+
const parent = path.dirname(dir);
|
|
3411
|
+
if (parent === dir) break; // filesystem root
|
|
3412
|
+
dir = parent;
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
if (!hookPath) {
|
|
3416
|
+
// Hook not installed — exit silently (non-fatal)
|
|
3417
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
3418
|
+
process.exit(0);
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// Spawn the hook with stdin/stdout/stderr inherited so Claude Code's pipe
|
|
3422
|
+
// flows through to the hook process unchanged.
|
|
3423
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
3424
|
+
stdio: "inherit",
|
|
3425
|
+
env: process.env,
|
|
3426
|
+
});
|
|
3427
|
+
process.exit(result.status != null ? result.status : 0);
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
// ──────────────────────────────────────────────
|
|
3431
|
+
// MIGRATE command
|
|
3432
|
+
// ──────────────────────────────────────────────
|
|
3433
|
+
|
|
3434
|
+
/**
|
|
3435
|
+
* One-time migration from .sticky-note/ (feature-branch storage) to
|
|
3436
|
+
* .git/sticky-note/ (data-branch storage).
|
|
3437
|
+
*
|
|
3438
|
+
* Idempotent — safe to run multiple times.
|
|
3439
|
+
*/
|
|
3440
|
+
async function cmdMigrate() {
|
|
3441
|
+
printBanner();
|
|
3442
|
+
|
|
3443
|
+
if (!isGitRepo()) {
|
|
3444
|
+
print(" [ERR] Not a git repository.");
|
|
3445
|
+
process.exit(1);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
const cwd = process.cwd();
|
|
3449
|
+
const oldDir = path.join(cwd, ".sticky-note");
|
|
3450
|
+
|
|
3451
|
+
// Locate .git directory using plumbing (works from subdirectories)
|
|
3452
|
+
let gitDir;
|
|
3453
|
+
try {
|
|
3454
|
+
gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
3455
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3456
|
+
cwd,
|
|
3457
|
+
}).trim();
|
|
3458
|
+
} catch (err) {
|
|
3459
|
+
print(" [ERR] Could not locate .git directory: " + err.message);
|
|
3460
|
+
process.exit(1);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
const newDir = path.join(gitDir, "sticky-note");
|
|
3464
|
+
print(" 📂 Source: " + oldDir);
|
|
3465
|
+
print(" 📂 Target: " + newDir);
|
|
3466
|
+
print("");
|
|
3467
|
+
|
|
3468
|
+
// Step 1: Copy files from .sticky-note/ to .git/sticky-note/
|
|
3469
|
+
mkdirSafe(newDir);
|
|
3470
|
+
mkdirSafe(path.join(newDir, "audit"));
|
|
3471
|
+
mkdirSafe(path.join(newDir, "presence"));
|
|
3472
|
+
|
|
3473
|
+
let copiedFiles = 0;
|
|
3474
|
+
const srcMemory = path.join(oldDir, "sticky-note.json");
|
|
3475
|
+
const dstMemory = path.join(newDir, "sticky-note.json");
|
|
3476
|
+
if (fs.existsSync(srcMemory)) {
|
|
3477
|
+
fs.copyFileSync(srcMemory, dstMemory);
|
|
3478
|
+
copiedFiles++;
|
|
3479
|
+
print(" [OK] Copied sticky-note.json");
|
|
3480
|
+
} else if (!fs.existsSync(dstMemory)) {
|
|
3481
|
+
// No existing data — create empty memory
|
|
3482
|
+
fs.writeFileSync(
|
|
3483
|
+
dstMemory,
|
|
3484
|
+
JSON.stringify({ version: "2", project: "", threads: [] }, null, 2) + "\n"
|
|
3485
|
+
);
|
|
3486
|
+
print(" [OK] Created empty sticky-note.json (no .sticky-note/ found)");
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
const oldAuditDir = path.join(oldDir, "audit");
|
|
3490
|
+
if (fs.existsSync(oldAuditDir)) {
|
|
3491
|
+
for (const file of fs.readdirSync(oldAuditDir)) {
|
|
3492
|
+
if (file.endsWith(".jsonl")) {
|
|
3493
|
+
const dst = path.join(newDir, "audit", file);
|
|
3494
|
+
if (!fs.existsSync(dst)) {
|
|
3495
|
+
fs.copyFileSync(path.join(oldAuditDir, file), dst);
|
|
3496
|
+
copiedFiles++;
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
const oldPresenceDir = path.join(oldDir, "presence");
|
|
3503
|
+
if (fs.existsSync(oldPresenceDir)) {
|
|
3504
|
+
for (const file of fs.readdirSync(oldPresenceDir)) {
|
|
3505
|
+
if (file.endsWith(".json")) {
|
|
3506
|
+
const dst = path.join(newDir, "presence", file);
|
|
3507
|
+
if (!fs.existsSync(dst)) {
|
|
3508
|
+
fs.copyFileSync(path.join(oldPresenceDir, file), dst);
|
|
3509
|
+
copiedFiles++;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
if (copiedFiles > 0) print(" [OK] Copied " + copiedFiles + " file(s) to .git/sticky-note/");
|
|
3515
|
+
|
|
3516
|
+
// Step 2: Commit files to sticky-note/data branch using plumbing
|
|
3517
|
+
print("");
|
|
3518
|
+
print(" 📌 Committing to sticky-note/data branch...");
|
|
3519
|
+
try {
|
|
3520
|
+
// Inline the plumbing here (data-branch.js lives in hooks, not in CLI path)
|
|
3521
|
+
_commitFilesToDataBranch(newDir);
|
|
3522
|
+
print(" [OK] sticky-note/data branch created/updated");
|
|
3523
|
+
} catch (err) {
|
|
3524
|
+
print(" [WARN] Could not commit to data branch: " + err.message);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// Step 3: Add .sticky-note/ to .gitignore
|
|
3528
|
+
print("");
|
|
3529
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
3530
|
+
const MARKER = "# sticky-note: data branch migration";
|
|
3531
|
+
const IGNORE_LINE = ".sticky-note/";
|
|
3532
|
+
let gitignoreUpdated = false;
|
|
3533
|
+
if (fs.existsSync(gitignorePath)) {
|
|
3534
|
+
const existing = fs.readFileSync(gitignorePath, "utf-8");
|
|
3535
|
+
const gitignoreLines = existing.split(/\r?\n/).map(l => l.trim());
|
|
3536
|
+
if (!gitignoreLines.includes(IGNORE_LINE)) {
|
|
3537
|
+
fs.appendFileSync(gitignorePath, "\n" + MARKER + "\n" + IGNORE_LINE + "\n");
|
|
3538
|
+
gitignoreUpdated = true;
|
|
3539
|
+
}
|
|
3540
|
+
} else {
|
|
3541
|
+
fs.writeFileSync(gitignorePath, MARKER + "\n" + IGNORE_LINE + "\n");
|
|
3542
|
+
gitignoreUpdated = true;
|
|
3543
|
+
}
|
|
3544
|
+
if (gitignoreUpdated) {
|
|
3545
|
+
print(" [OK] Added .sticky-note/ to .gitignore");
|
|
3546
|
+
} else {
|
|
3547
|
+
print(" [OK] .gitignore already contains .sticky-note/");
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
// Step 4: Remove sticky-note pre-commit and post-commit hooks
|
|
3551
|
+
print("");
|
|
3552
|
+
const gitHooksDir = path.join(gitDir, "hooks");
|
|
3553
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
3554
|
+
const hookPath = path.join(gitHooksDir, hookName);
|
|
3555
|
+
if (!fs.existsSync(hookPath)) continue;
|
|
3556
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
3557
|
+
if (content.includes(STICKY_PRE_COMMIT_MARKER) || content.includes(STICKY_POST_COMMIT_MARKER)) {
|
|
3558
|
+
// Remove sticky-note blocks
|
|
3559
|
+
let updated = content;
|
|
3560
|
+
// Remove pre-commit block
|
|
3561
|
+
const preStart = updated.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
3562
|
+
const preEnd = updated.indexOf(STICKY_PRE_COMMIT_END);
|
|
3563
|
+
if (preStart !== -1 && preEnd !== -1) {
|
|
3564
|
+
updated = updated.slice(0, preStart) + updated.slice(preEnd + STICKY_PRE_COMMIT_END.length);
|
|
3565
|
+
}
|
|
3566
|
+
// Remove post-commit block
|
|
3567
|
+
const postStart = updated.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
3568
|
+
const postEnd = updated.indexOf(STICKY_POST_COMMIT_END);
|
|
3569
|
+
if (postStart !== -1 && postEnd !== -1) {
|
|
3570
|
+
updated = updated.slice(0, postStart) + updated.slice(postEnd + STICKY_POST_COMMIT_END.length);
|
|
3571
|
+
}
|
|
3572
|
+
// If only the shebang remains, delete the file
|
|
3573
|
+
if (updated.replace(/^#!.*\n/, "").trim() === "") {
|
|
3574
|
+
fs.unlinkSync(hookPath);
|
|
3575
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (was sticky-note only)");
|
|
3576
|
+
} else {
|
|
3577
|
+
fs.writeFileSync(hookPath, updated, "utf-8");
|
|
3578
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// Step 5: Push data branch if remote exists
|
|
3584
|
+
print("");
|
|
3585
|
+
try {
|
|
3586
|
+
const remote = execFileSync("git", ["remote"], {
|
|
3587
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3588
|
+
cwd,
|
|
3589
|
+
}).trim().split("\n")[0];
|
|
3590
|
+
if (remote) {
|
|
3591
|
+
print(" 🔄 Pushing sticky-note/data to " + remote + "...");
|
|
3592
|
+
execFileSync("git", ["push", remote, "refs/heads/sticky-note/data:refs/heads/sticky-note/data"], {
|
|
3593
|
+
timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
|
|
3594
|
+
cwd,
|
|
3595
|
+
});
|
|
3596
|
+
print(" [OK] Pushed sticky-note/data");
|
|
3597
|
+
} else {
|
|
3598
|
+
print(" ⏭️ No remote configured — skipping push");
|
|
3599
|
+
}
|
|
3600
|
+
} catch (err) {
|
|
3601
|
+
print(" [WARN] Push failed: " + err.message);
|
|
3602
|
+
print(" [INFO] Your data is safe locally. Run `npx sticky-note migrate` again to retry.");
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
print("");
|
|
3606
|
+
print(" ✅ Migration complete!");
|
|
3607
|
+
print("");
|
|
3608
|
+
print(" Next steps:");
|
|
3609
|
+
print(" 1. Commit the .gitignore change: git add .gitignore && git commit -m 'chore: gitignore sticky-note data dir'");
|
|
3610
|
+
print(" 2. Each teammate runs: npx sticky-note init");
|
|
3611
|
+
print("");
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
/**
|
|
3615
|
+
* Inline git plumbing to commit files from a local dir to sticky-note/data.
|
|
3616
|
+
* Extracted from data-branch.js for use in CLI context (no hooks dir on PATH).
|
|
3617
|
+
*/
|
|
3618
|
+
function _commitFilesToDataBranch(srcDir) {
|
|
3619
|
+
const os = require("os");
|
|
3620
|
+
const crypto = require("crypto");
|
|
3621
|
+
const branchRef = "refs/heads/sticky-note/data";
|
|
3622
|
+
const tmpIndex = path.join(
|
|
3623
|
+
os.tmpdir(),
|
|
3624
|
+
"sticky-idx-" + process.pid + "-" + crypto.randomBytes(4).toString("hex")
|
|
3625
|
+
);
|
|
3626
|
+
|
|
3627
|
+
try {
|
|
3628
|
+
let parentSha = null;
|
|
3629
|
+
try {
|
|
3630
|
+
parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
|
|
3631
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3632
|
+
}).trim();
|
|
3633
|
+
} catch (_) {}
|
|
3634
|
+
|
|
3635
|
+
const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
3636
|
+
if (parentSha) {
|
|
3637
|
+
execFileSync("git", ["read-tree", parentSha], {
|
|
3638
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
// Walk srcDir and commit all files
|
|
3643
|
+
function walkAndAdd(dir, prefix) {
|
|
3644
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
3645
|
+
const full = path.join(dir, entry.name);
|
|
3646
|
+
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
|
|
3647
|
+
if (entry.isDirectory()) {
|
|
3648
|
+
walkAndAdd(full, relPath);
|
|
3649
|
+
} else if (entry.isFile() && !entry.name.startsWith(".sticky-")) {
|
|
3650
|
+
const content = fs.readFileSync(full);
|
|
3651
|
+
const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
|
3652
|
+
input: content, encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3653
|
+
}).trim();
|
|
3654
|
+
execFileSync(
|
|
3655
|
+
"git",
|
|
3656
|
+
["update-index", "--add", "--cacheinfo", "100644," + blobSha + "," + relPath],
|
|
3657
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv }
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
walkAndAdd(srcDir, "");
|
|
3663
|
+
|
|
3664
|
+
const treeSha = execFileSync("git", ["write-tree"], {
|
|
3665
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
3666
|
+
}).trim();
|
|
3667
|
+
|
|
3668
|
+
const commitArgs = ["commit-tree", treeSha, "-m", "chore(sticky-note): migrate to data branch"];
|
|
3669
|
+
if (parentSha) commitArgs.push("-p", parentSha);
|
|
3670
|
+
const commitSha = execFileSync("git", commitArgs, {
|
|
3671
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3672
|
+
}).trim();
|
|
3673
|
+
|
|
3674
|
+
execFileSync("git", ["update-ref", branchRef, commitSha], {
|
|
3675
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3676
|
+
});
|
|
3677
|
+
} finally {
|
|
3678
|
+
try { fs.unlinkSync(tmpIndex); } catch (_) {}
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3353
3682
|
// ──────────────────────────────────────────────
|
|
3354
3683
|
// Main
|
|
3355
3684
|
// ──────────────────────────────────────────────
|
|
@@ -3419,6 +3748,15 @@ async function main() {
|
|
|
3419
3748
|
case "mcp-server":
|
|
3420
3749
|
cmdMcpServer();
|
|
3421
3750
|
break;
|
|
3751
|
+
case "run-hook":
|
|
3752
|
+
cmdRunHook();
|
|
3753
|
+
break;
|
|
3754
|
+
case "migrate":
|
|
3755
|
+
cmdMigrate().catch((err) => {
|
|
3756
|
+
console.error(err);
|
|
3757
|
+
process.exit(1);
|
|
3758
|
+
});
|
|
3759
|
+
break;
|
|
3422
3760
|
case "--version":
|
|
3423
3761
|
case "-v":
|
|
3424
3762
|
print(`sticky-note v${VERSION}`);
|
|
@@ -3449,6 +3787,7 @@ async function main() {
|
|
|
3449
3787
|
print(" bootstrap Provision MCP servers with secrets from manifest");
|
|
3450
3788
|
print(" env Environment management (status, add-server, add-plugin)");
|
|
3451
3789
|
print(" mcp-server Launch the sticky-note MCP server (stdio JSON-RPC)");
|
|
3790
|
+
print(" run-hook <name> Run a hook from any subdirectory (finds .claude/hooks/ up the tree)");
|
|
3452
3791
|
print("");
|
|
3453
3792
|
print(" Options:");
|
|
3454
3793
|
print(" --version Show version");
|
package/package.json
CHANGED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* data-branch.js — Git plumbing for the sticky-note/data orphan branch.
|
|
5
|
+
*
|
|
6
|
+
* Reads/writes files via git plumbing (hash-object, update-index, write-tree,
|
|
7
|
+
* commit-tree, update-ref). No working-tree checkout is ever performed.
|
|
8
|
+
* All operations are synchronous; callers receive result objects on failure.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const crypto = require("crypto");
|
|
15
|
+
const { execFileSync } = require("child_process");
|
|
16
|
+
|
|
17
|
+
const DATA_BRANCH = "sticky-note/data";
|
|
18
|
+
const DATA_REF = "refs/heads/" + DATA_BRANCH;
|
|
19
|
+
|
|
20
|
+
const GIT_OPTS = { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] };
|
|
21
|
+
|
|
22
|
+
// ── Git helpers ───────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function getDefaultRemote() {
|
|
25
|
+
try {
|
|
26
|
+
const out = execFileSync("git", ["remote"], {
|
|
27
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
}).trim();
|
|
29
|
+
return out.split(/\r?\n/)[0] || null;
|
|
30
|
+
} catch (_) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Returns file content from a git ref as a string, or null if not found. */
|
|
36
|
+
function readFileFromBranch(ref, filePath) {
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync("git", ["show", ref + ":" + filePath], GIT_OPTS);
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Commit { relativePath: content } to a branch using git plumbing.
|
|
46
|
+
* Does not touch the working tree or the main index. Returns the new commit SHA.
|
|
47
|
+
*/
|
|
48
|
+
function commitFilesToBranch(branchName, fileMap) {
|
|
49
|
+
const branchRef = "refs/heads/" + branchName;
|
|
50
|
+
const tmpIndex = path.join(
|
|
51
|
+
os.tmpdir(),
|
|
52
|
+
"sticky-idx-" + process.pid + "-" + crypto.randomBytes(4).toString("hex")
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
let parentSha = null;
|
|
57
|
+
try {
|
|
58
|
+
parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
|
|
59
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
60
|
+
}).trim();
|
|
61
|
+
} catch (_) {
|
|
62
|
+
// Branch doesn't exist yet — first commit creates it as orphan
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
66
|
+
|
|
67
|
+
if (parentSha) {
|
|
68
|
+
execFileSync("git", ["read-tree", parentSha], {
|
|
69
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
70
|
+
env: indexEnv,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const [filePath, content] of Object.entries(fileMap)) {
|
|
75
|
+
const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
|
76
|
+
input: content,
|
|
77
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
78
|
+
}).trim();
|
|
79
|
+
|
|
80
|
+
execFileSync(
|
|
81
|
+
"git",
|
|
82
|
+
["update-index", "--add", "--cacheinfo", "100644," + blobSha + "," + filePath],
|
|
83
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const treeSha = execFileSync("git", ["write-tree"], {
|
|
88
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
89
|
+
env: indexEnv,
|
|
90
|
+
}).trim();
|
|
91
|
+
|
|
92
|
+
const commitArgs = ["commit-tree", treeSha, "-m", "chore(sticky-note): sync thread data"];
|
|
93
|
+
if (parentSha) {
|
|
94
|
+
commitArgs.push("-p", parentSha);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const commitSha = execFileSync("git", commitArgs, GIT_OPTS).trim();
|
|
98
|
+
|
|
99
|
+
execFileSync("git", ["update-ref", branchRef, commitSha], {
|
|
100
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return commitSha;
|
|
104
|
+
} finally {
|
|
105
|
+
try { fs.unlinkSync(tmpIndex); } catch (_) {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Fetch ─────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Fetch the remote data branch into a local remote-tracking ref.
|
|
113
|
+
* Returns { ok, sha, error, remoteRef }.
|
|
114
|
+
*/
|
|
115
|
+
function fetchDataBranch(remote, branchName) {
|
|
116
|
+
branchName = branchName || DATA_BRANCH;
|
|
117
|
+
remote = remote || getDefaultRemote();
|
|
118
|
+
if (!remote) return { ok: false, sha: null, error: "no remote configured", remoteRef: null };
|
|
119
|
+
|
|
120
|
+
const remoteRef = "refs/remotes/" + remote + "/" + branchName;
|
|
121
|
+
try {
|
|
122
|
+
execFileSync(
|
|
123
|
+
"git",
|
|
124
|
+
["fetch", remote, branchName + ":" + remoteRef],
|
|
125
|
+
{ timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
|
|
126
|
+
);
|
|
127
|
+
let sha = null;
|
|
128
|
+
try {
|
|
129
|
+
sha = execFileSync("git", ["rev-parse", "--verify", remoteRef], {
|
|
130
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
131
|
+
}).trim();
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
return { ok: true, sha, error: null, remoteRef };
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return { ok: false, sha: null, error: err.message, remoteRef };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Push with retry ────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Push the local data branch to the remote, retrying on rejection.
|
|
143
|
+
* On non-fast-forward rejection, fetches and merges before retrying.
|
|
144
|
+
* Returns { ok, error }.
|
|
145
|
+
*/
|
|
146
|
+
function pushDataBranch(remote, branchName, maxRetries, localMemPath, loadJsonFn, saveJsonFn) {
|
|
147
|
+
branchName = branchName || DATA_BRANCH;
|
|
148
|
+
remote = remote || getDefaultRemote();
|
|
149
|
+
maxRetries = maxRetries || 3;
|
|
150
|
+
if (!remote) return { ok: false, error: "no remote configured" };
|
|
151
|
+
|
|
152
|
+
const pushSpec = DATA_REF + ":" + DATA_REF;
|
|
153
|
+
|
|
154
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
155
|
+
try {
|
|
156
|
+
execFileSync("git", ["push", remote, pushSpec], {
|
|
157
|
+
timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
|
|
158
|
+
});
|
|
159
|
+
return { ok: true, error: null };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (attempt === maxRetries) {
|
|
162
|
+
return { ok: false, error: err.message };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Fetch, merge, and re-commit so the next push attempt advances the ref
|
|
166
|
+
try {
|
|
167
|
+
const fetchResult = fetchDataBranch(remote, branchName);
|
|
168
|
+
if (fetchResult.ok && fetchResult.remoteRef && localMemPath) {
|
|
169
|
+
const remoteContent = readFileFromBranch(fetchResult.remoteRef, "sticky-note.json");
|
|
170
|
+
if (remoteContent) {
|
|
171
|
+
mergeAndSaveFromRemote(localMemPath, remoteContent, loadJsonFn, saveJsonFn);
|
|
172
|
+
const mergedContent = fs.readFileSync(localMemPath, "utf-8");
|
|
173
|
+
commitFilesToBranch(branchName, { "sticky-note.json": mergedContent });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (_) {}
|
|
177
|
+
|
|
178
|
+
// Brief exponential backoff (busy-wait — synchronous context)
|
|
179
|
+
const end = Date.now() + Math.pow(2, attempt) * 300;
|
|
180
|
+
while (Date.now() < end) {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, error: "max retries exceeded" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Thread merge ──────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function _mostRecentTimestamp(thread) {
|
|
189
|
+
return thread.last_activity_at || thread.updated_at || thread.created_at || "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Merge two thread arrays, preferring the more-recently-active copy
|
|
194
|
+
* when both contain the same thread ID.
|
|
195
|
+
*/
|
|
196
|
+
function mergeThreadArrays(localThreads, remoteThreads) {
|
|
197
|
+
const merged = new Map();
|
|
198
|
+
|
|
199
|
+
for (const t of (localThreads || [])) {
|
|
200
|
+
if (t && t.id) merged.set(t.id, t);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const t of (remoteThreads || [])) {
|
|
204
|
+
if (!t || !t.id) continue;
|
|
205
|
+
const existing = merged.get(t.id);
|
|
206
|
+
if (!existing || _mostRecentTimestamp(t) > _mostRecentTimestamp(existing)) {
|
|
207
|
+
merged.set(t.id, t);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return Array.from(merged.values());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const EMPTY_MEMORY = { version: "2", project: "", threads: [] };
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Merge remote sticky-note.json content into the local memory file.
|
|
218
|
+
* Uses loadJsonFn/saveJsonFn when provided, otherwise reads/writes directly.
|
|
219
|
+
*/
|
|
220
|
+
function mergeAndSaveFromRemote(localMemPath, remoteContent, loadJsonFn, saveJsonFn) {
|
|
221
|
+
let remoteMemory;
|
|
222
|
+
try {
|
|
223
|
+
remoteMemory = JSON.parse(remoteContent);
|
|
224
|
+
} catch (_) {
|
|
225
|
+
return; // corrupt remote — skip
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const localMemory = loadJsonFn
|
|
229
|
+
? loadJsonFn(localMemPath, { ...EMPTY_MEMORY })
|
|
230
|
+
: { ...EMPTY_MEMORY };
|
|
231
|
+
|
|
232
|
+
const localThreads = Array.isArray(localMemory.threads) ? localMemory.threads.filter(Boolean) : [];
|
|
233
|
+
const remoteThreads = Array.isArray(remoteMemory.threads) ? remoteMemory.threads.filter(Boolean) : [];
|
|
234
|
+
localMemory.threads = mergeThreadArrays(localThreads, remoteThreads);
|
|
235
|
+
|
|
236
|
+
if (saveJsonFn) {
|
|
237
|
+
saveJsonFn(localMemPath, localMemory);
|
|
238
|
+
} else {
|
|
239
|
+
fs.mkdirSync(path.dirname(localMemPath), { recursive: true });
|
|
240
|
+
fs.writeFileSync(localMemPath, JSON.stringify(localMemory, null, 2) + "\n", "utf-8");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Exports ───────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
DATA_BRANCH,
|
|
248
|
+
DATA_REF,
|
|
249
|
+
getDefaultRemote,
|
|
250
|
+
readFileFromBranch,
|
|
251
|
+
commitFilesToBranch,
|
|
252
|
+
fetchDataBranch,
|
|
253
|
+
pushDataBranch,
|
|
254
|
+
mergeThreadArrays,
|
|
255
|
+
mergeAndSaveFromRemote,
|
|
256
|
+
};
|
|
@@ -29,9 +29,14 @@ try {
|
|
|
29
29
|
_safeExit();
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
let dataBranch = null;
|
|
33
|
+
try { dataBranch = require("./data-branch.js"); } catch (_) {}
|
|
34
|
+
|
|
32
35
|
const {
|
|
33
36
|
getMemoryPath,
|
|
34
37
|
getConfigPath,
|
|
38
|
+
getAuditDir,
|
|
39
|
+
getPresenceDir,
|
|
35
40
|
getUserPresencePath,
|
|
36
41
|
getAllAuditPaths,
|
|
37
42
|
loadJson,
|
|
@@ -724,6 +729,53 @@ function clearPresence(user) {
|
|
|
724
729
|
}
|
|
725
730
|
}
|
|
726
731
|
|
|
732
|
+
// ── Data-branch commit/push ───────────────────────────────
|
|
733
|
+
|
|
734
|
+
/** Collect files from a directory matching an extension into fileMap. */
|
|
735
|
+
function _collectDirFiles(dir, ext, prefix, fileMap) {
|
|
736
|
+
if (!dir || !fs.existsSync(dir)) return;
|
|
737
|
+
for (const file of fs.readdirSync(dir)) {
|
|
738
|
+
if (file.endsWith(ext)) {
|
|
739
|
+
fileMap[prefix + file] = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function commitAndPushDataBranch() {
|
|
745
|
+
if (!dataBranch) return;
|
|
746
|
+
try {
|
|
747
|
+
const fileMap = {};
|
|
748
|
+
const memPath = getMemoryPath();
|
|
749
|
+
if (fs.existsSync(memPath)) {
|
|
750
|
+
fileMap["sticky-note.json"] = fs.readFileSync(memPath, "utf-8");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
_collectDirFiles(getAuditDir(), ".jsonl", "audit/", fileMap);
|
|
754
|
+
_collectDirFiles(getPresenceDir(), ".json", "presence/", fileMap);
|
|
755
|
+
|
|
756
|
+
if (Object.keys(fileMap).length === 0) return;
|
|
757
|
+
|
|
758
|
+
dataBranch.commitFilesToBranch(dataBranch.DATA_BRANCH, fileMap);
|
|
759
|
+
|
|
760
|
+
const remote = dataBranch.getDefaultRemote();
|
|
761
|
+
if (remote) {
|
|
762
|
+
const pushResult = dataBranch.pushDataBranch(
|
|
763
|
+
remote, dataBranch.DATA_BRANCH, 3, getMemoryPath(), loadJson, saveJson
|
|
764
|
+
);
|
|
765
|
+
if (!pushResult.ok) {
|
|
766
|
+
process.stderr.write(
|
|
767
|
+
"[STICKY-NOTE] warning: failed to push data branch — " + pushResult.error + "\n" +
|
|
768
|
+
"[STICKY-NOTE] your data is safe locally in .git/sticky-note/\n"
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
} catch (err) {
|
|
773
|
+
process.stderr.write(
|
|
774
|
+
"[STICKY-NOTE] warning: data branch sync failed — " + err.message + "\n"
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
727
779
|
// ── Main ──────────────────────────────────────────────────
|
|
728
780
|
|
|
729
781
|
function main() {
|
|
@@ -946,6 +998,9 @@ function main() {
|
|
|
946
998
|
}
|
|
947
999
|
}
|
|
948
1000
|
|
|
1001
|
+
// Commit current data to sticky-note/data branch and push
|
|
1002
|
+
commitAndPushDataBranch();
|
|
1003
|
+
|
|
949
1004
|
const fileCount = filesTouched.length;
|
|
950
1005
|
const commitCount = commitShas.length;
|
|
951
1006
|
const statusLabel = isCopilotCli ? "updated" : "closed";
|
|
@@ -105,6 +105,10 @@ try {
|
|
|
105
105
|
// session-start to run.
|
|
106
106
|
try { utils.selfHealHookPaths && utils.selfHealHookPaths(); } catch (_) { /* non-fatal */ }
|
|
107
107
|
|
|
108
|
+
// Non-fatal: data-branch sync is disabled if the module is missing.
|
|
109
|
+
let dataBranch = null;
|
|
110
|
+
try { dataBranch = require("./data-branch.js"); } catch (_) {}
|
|
111
|
+
|
|
108
112
|
const {
|
|
109
113
|
getMemoryPath,
|
|
110
114
|
getConfigPath,
|
|
@@ -879,6 +883,47 @@ function ensureEnvironmentProvisioned() {
|
|
|
879
883
|
if (debug) process.stderr.write("[sticky-note] environment provisioning complete\n");
|
|
880
884
|
}
|
|
881
885
|
|
|
886
|
+
// ── Data branch fetch-and-merge ───────────────────────────
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Fetches the remote sticky-note/data branch and merges into local memory.
|
|
890
|
+
* Also reads from the LOCAL data branch (fresh clones, offline, tests).
|
|
891
|
+
* Non-blocking — the session continues with local data on any failure.
|
|
892
|
+
*/
|
|
893
|
+
function fetchAndMergeDataBranch() {
|
|
894
|
+
if (!dataBranch) return;
|
|
895
|
+
|
|
896
|
+
const memPath = getMemoryPath();
|
|
897
|
+
const mergeFrom = (ref) => {
|
|
898
|
+
const content = dataBranch.readFileFromBranch(ref, "sticky-note.json");
|
|
899
|
+
if (content) {
|
|
900
|
+
dataBranch.mergeAndSaveFromRemote(memPath, content, loadJson, saveJson);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
const remote = dataBranch.getDefaultRemote();
|
|
906
|
+
|
|
907
|
+
if (remote) {
|
|
908
|
+
const fetchResult = dataBranch.fetchDataBranch(remote);
|
|
909
|
+
if (!fetchResult.ok) {
|
|
910
|
+
process.stderr.write(
|
|
911
|
+
"[STICKY-NOTE] offline — using local thread cache (" +
|
|
912
|
+
(fetchResult.error || "fetch failed") + ")\n"
|
|
913
|
+
);
|
|
914
|
+
} else if (fetchResult.remoteRef) {
|
|
915
|
+
mergeFrom(fetchResult.remoteRef);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
mergeFrom(dataBranch.DATA_REF);
|
|
920
|
+
} catch (err) {
|
|
921
|
+
process.stderr.write(
|
|
922
|
+
"[STICKY-NOTE] offline — using local thread cache (" + err.message + ")\n"
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
882
927
|
// ── Main ──────────────────────────────────────────────────
|
|
883
928
|
|
|
884
929
|
function main() {
|
|
@@ -892,6 +937,9 @@ function main() {
|
|
|
892
937
|
hookInput = {};
|
|
893
938
|
}
|
|
894
939
|
|
|
940
|
+
// Fetch remote data branch and merge into local cache — before reading memory
|
|
941
|
+
fetchAndMergeDataBranch();
|
|
942
|
+
|
|
895
943
|
let sessionId = getSessionId(hookInput);
|
|
896
944
|
const aiTool = detectTool(hookInput);
|
|
897
945
|
const isCopilotCli = aiTool === "copilot-cli" || process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
|
|
@@ -38,8 +38,16 @@ const FILE_PATH_PATTERN = /[\w./\\-]+\.\w{1,10}/g;
|
|
|
38
38
|
// ── Path helpers ──────────────────────────────────────────
|
|
39
39
|
|
|
40
40
|
function _stickyDir() {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
try {
|
|
42
|
+
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
43
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
44
|
+
}).trim();
|
|
45
|
+
return path.resolve(gitDir, "sticky-note");
|
|
46
|
+
} catch (_) {
|
|
47
|
+
// Fallback for non-git environments (e.g., unit tests without git init)
|
|
48
|
+
const scriptDir = path.dirname(path.resolve(__filename));
|
|
49
|
+
return path.join(scriptDir, "..", "..", ".sticky-note");
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
// ── Error logging ─────────────────────────────────────────
|
|
@@ -81,9 +89,11 @@ function reportHookError(hookName, error, hint) {
|
|
|
81
89
|
// the first time any hook (e.g. session-start, inject-context) successfully
|
|
82
90
|
// loads, so the next session starts clean.
|
|
83
91
|
//
|
|
84
|
-
// The rewritten command
|
|
85
|
-
//
|
|
86
|
-
//
|
|
92
|
+
// The rewritten command uses `npx sticky-note run-hook <basename>` which walks
|
|
93
|
+
// up the directory tree to find `.claude/hooks/<basename>.js`, so it works even
|
|
94
|
+
// when Claude Code is launched from a subdirectory of the project root.
|
|
95
|
+
// We extract `<basename>` from the existing command so we never invent hook
|
|
96
|
+
// scripts that don't exist.
|
|
87
97
|
//
|
|
88
98
|
// Returns the list of (hookEvent, oldCommand, newCommand) tuples that were
|
|
89
99
|
// rewritten, or [] if nothing was stale.
|
|
@@ -100,12 +110,15 @@ function selfHealHookPaths(settingsPath) {
|
|
|
100
110
|
try { settings = JSON.parse(raw); } catch (_) { return result; }
|
|
101
111
|
if (!settings || !settings.hooks) return result;
|
|
102
112
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
113
|
+
// Two stale patterns:
|
|
114
|
+
// 1. Absolute paths (written by older sticky-note-cli or copied from another machine)
|
|
115
|
+
// 2. Old relative form `node ".claude/hooks/foo.js"` — fails when Claude Code
|
|
116
|
+
// launches from a subdirectory, because the path resolves relative to the
|
|
117
|
+
// launch cwd, not the directory containing .claude/settings.json.
|
|
118
|
+
const STALE_ABSOLUTE = /["'][A-Za-z]:[\\/]|["']\/(?:Users|home|root|tmp)\/|\$\(git rev-parse/;
|
|
119
|
+
const STALE_RELATIVE = /^node\s+["']\.claude[\\/]hooks[\\/][^"']+\.js["']\s*$/;
|
|
107
120
|
// Match the script basename in either quoted or bare form.
|
|
108
|
-
const BASENAME = /([A-Za-z0-9_.-]
|
|
121
|
+
const BASENAME = /([A-Za-z0-9_.-]+)\.(?:js|sh|cjs|mjs)(?:["'`\s]|$)/;
|
|
109
122
|
|
|
110
123
|
let changed = false;
|
|
111
124
|
for (const [event, entries] of Object.entries(settings.hooks)) {
|
|
@@ -113,11 +126,13 @@ function selfHealHookPaths(settingsPath) {
|
|
|
113
126
|
for (const entry of entries) {
|
|
114
127
|
for (const h of (entry && entry.hooks) || []) {
|
|
115
128
|
const cmd = h && h.command;
|
|
116
|
-
if (typeof cmd !== "string"
|
|
129
|
+
if (typeof cmd !== "string") continue;
|
|
130
|
+
const isStale = STALE_ABSOLUTE.test(cmd) || STALE_RELATIVE.test(cmd);
|
|
131
|
+
if (!isStale) continue;
|
|
117
132
|
const m = cmd.match(BASENAME);
|
|
118
133
|
if (!m) continue;
|
|
119
134
|
const basename = m[1];
|
|
120
|
-
const newCmd = `
|
|
135
|
+
const newCmd = `npx sticky-note run-hook ${basename}`;
|
|
121
136
|
result.push({ event, oldCommand: cmd, newCommand: newCmd });
|
|
122
137
|
h.command = newCmd;
|
|
123
138
|
changed = true;
|
|
@@ -130,10 +145,10 @@ function selfHealHookPaths(settingsPath) {
|
|
|
130
145
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
131
146
|
try {
|
|
132
147
|
const summary =
|
|
133
|
-
`[STICKY-NOTE] ⚠️ Detected ${result.length} stale
|
|
148
|
+
`[STICKY-NOTE] ⚠️ Detected ${result.length} stale hook path(s) in ` +
|
|
134
149
|
`${path.relative(process.cwd(), settingsPath) || settingsPath} — rewrote to ` +
|
|
135
|
-
`
|
|
136
|
-
`the fix with your team.`;
|
|
150
|
+
`"npx sticky-note run-hook <name>" so hooks work from any subdirectory. ` +
|
|
151
|
+
`Commit the change to share the fix with your team.`;
|
|
137
152
|
process.stderr.write(summary + "\n");
|
|
138
153
|
for (const { event, oldCommand, newCommand } of result.slice(0, 5)) {
|
|
139
154
|
process.stderr.write(`[STICKY-NOTE] ${event}: ${oldCommand} → ${newCommand}\n`);
|
|
@@ -1108,5 +1123,10 @@ module.exports = {
|
|
|
1108
1123
|
rotateBackup,
|
|
1109
1124
|
acquireLock,
|
|
1110
1125
|
releaseLock,
|
|
1126
|
+
// Cloud sync stubs (feature not yet implemented — on-stop.js uses these)
|
|
1127
|
+
useCloud: () => false,
|
|
1128
|
+
cloudReadThreads: async () => null,
|
|
1129
|
+
cloudWriteThread: async () => {},
|
|
1130
|
+
cloudAppendAudit: async () => {},
|
|
1111
1131
|
};
|
|
1112
1132
|
// tier1 test
|
package/templates/hooks.json
CHANGED
|
@@ -1,47 +1,47 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"hooks": {
|
|
4
|
-
"sessionStart": [
|
|
5
|
-
{
|
|
6
|
-
"type": "command",
|
|
7
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/session-start.js\" --copilot-cli",
|
|
8
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-start.js\" --copilot-cli"
|
|
9
|
-
}
|
|
10
|
-
],
|
|
11
|
-
"sessionEnd": [
|
|
12
|
-
{
|
|
13
|
-
"type": "command",
|
|
14
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/session-end.js\" --copilot-cli",
|
|
15
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-end.js\" --copilot-cli"
|
|
16
|
-
}
|
|
17
|
-
],
|
|
18
|
-
"userPromptSubmitted": [
|
|
19
|
-
{
|
|
20
|
-
"type": "command",
|
|
21
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/inject-context.js\" --copilot-cli",
|
|
22
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/inject-context.js\" --copilot-cli"
|
|
23
|
-
}
|
|
24
|
-
],
|
|
25
|
-
"preToolUse": [
|
|
26
|
-
{
|
|
27
|
-
"type": "command",
|
|
28
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/pre-tool-use.js\" --copilot-cli",
|
|
29
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/pre-tool-use.js\" --copilot-cli"
|
|
30
|
-
}
|
|
31
|
-
],
|
|
32
|
-
"postToolUse": [
|
|
33
|
-
{
|
|
34
|
-
"type": "command",
|
|
35
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/track-work.js\" --copilot-cli",
|
|
36
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/track-work.js\" --copilot-cli"
|
|
37
|
-
}
|
|
38
|
-
],
|
|
39
|
-
"errorOccurred": [
|
|
40
|
-
{
|
|
41
|
-
"type": "command",
|
|
42
|
-
"bash": "COPILOT_CLI=1 node \".claude/hooks/on-error.js\" --copilot-cli",
|
|
43
|
-
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/on-error.js\" --copilot-cli"
|
|
44
|
-
}
|
|
45
|
-
]
|
|
46
|
-
}
|
|
47
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"sessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/session-start.js\" --copilot-cli",
|
|
8
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-start.js\" --copilot-cli"
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"sessionEnd": [
|
|
12
|
+
{
|
|
13
|
+
"type": "command",
|
|
14
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/session-end.js\" --copilot-cli",
|
|
15
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/session-end.js\" --copilot-cli"
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"userPromptSubmitted": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/inject-context.js\" --copilot-cli",
|
|
22
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/inject-context.js\" --copilot-cli"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"preToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"type": "command",
|
|
28
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/pre-tool-use.js\" --copilot-cli",
|
|
29
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/pre-tool-use.js\" --copilot-cli"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"postToolUse": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/track-work.js\" --copilot-cli",
|
|
36
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/track-work.js\" --copilot-cli"
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"errorOccurred": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"bash": "COPILOT_CLI=1 node \".claude/hooks/on-error.js\" --copilot-cli",
|
|
43
|
+
"powershell": "$env:COPILOT_CLI='1'; node \".claude/hooks/on-error.js\" --copilot-cli"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}
|
package/templates/settings.json
CHANGED
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
{
|
|
2
|
-
"hooks": {
|
|
3
|
-
"SessionStart": [
|
|
4
|
-
{
|
|
5
|
-
"matcher": "",
|
|
6
|
-
"hooks": [
|
|
7
|
-
{
|
|
8
|
-
"type": "command",
|
|
9
|
-
"command": "
|
|
10
|
-
}
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
],
|
|
14
|
-
"SessionEnd": [
|
|
15
|
-
{
|
|
16
|
-
"matcher": "",
|
|
17
|
-
"hooks": [
|
|
18
|
-
{
|
|
19
|
-
"type": "command",
|
|
20
|
-
"command": "
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
],
|
|
25
|
-
"UserPromptSubmit": [
|
|
26
|
-
{
|
|
27
|
-
"matcher": "",
|
|
28
|
-
"hooks": [
|
|
29
|
-
{
|
|
30
|
-
"type": "command",
|
|
31
|
-
"command": "
|
|
32
|
-
}
|
|
33
|
-
]
|
|
34
|
-
}
|
|
35
|
-
],
|
|
36
|
-
"PreToolUse": [
|
|
37
|
-
{
|
|
38
|
-
"matcher": "",
|
|
39
|
-
"hooks": [
|
|
40
|
-
{
|
|
41
|
-
"type": "command",
|
|
42
|
-
"command": "
|
|
43
|
-
}
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
],
|
|
47
|
-
"PostToolUse": [
|
|
48
|
-
{
|
|
49
|
-
"matcher": "",
|
|
50
|
-
"hooks": [
|
|
51
|
-
{
|
|
52
|
-
"type": "command",
|
|
53
|
-
"command": "
|
|
54
|
-
}
|
|
55
|
-
]
|
|
56
|
-
}
|
|
57
|
-
],
|
|
58
|
-
"Stop": [
|
|
59
|
-
{
|
|
60
|
-
"matcher": "",
|
|
61
|
-
"hooks": [
|
|
62
|
-
{
|
|
63
|
-
"type": "command",
|
|
64
|
-
"command": "
|
|
65
|
-
}
|
|
66
|
-
]
|
|
67
|
-
}
|
|
68
|
-
],
|
|
69
|
-
"PostToolUseFailure": [
|
|
70
|
-
{
|
|
71
|
-
"matcher": "",
|
|
72
|
-
"hooks": [
|
|
73
|
-
{
|
|
74
|
-
"type": "command",
|
|
75
|
-
"command": "
|
|
76
|
-
}
|
|
77
|
-
]
|
|
78
|
-
}
|
|
79
|
-
]
|
|
80
|
-
}
|
|
81
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "npx sticky-note run-hook session-start"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"SessionEnd": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "npx sticky-note run-hook session-end"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"UserPromptSubmit": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": "",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "npx sticky-note run-hook inject-context"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"PreToolUse": [
|
|
37
|
+
{
|
|
38
|
+
"matcher": "",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "npx sticky-note run-hook pre-tool-use"
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"PostToolUse": [
|
|
48
|
+
{
|
|
49
|
+
"matcher": "",
|
|
50
|
+
"hooks": [
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "npx sticky-note run-hook track-work"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"Stop": [
|
|
59
|
+
{
|
|
60
|
+
"matcher": "",
|
|
61
|
+
"hooks": [
|
|
62
|
+
{
|
|
63
|
+
"type": "command",
|
|
64
|
+
"command": "npx sticky-note run-hook on-stop"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
"PostToolUseFailure": [
|
|
70
|
+
{
|
|
71
|
+
"matcher": "",
|
|
72
|
+
"hooks": [
|
|
73
|
+
{
|
|
74
|
+
"type": "command",
|
|
75
|
+
"command": "npx sticky-note run-hook on-error"
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": "2",
|
|
3
|
-
"project": "",
|
|
4
|
-
"threads": []
|
|
5
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"version": "2",
|
|
3
|
+
"project": "",
|
|
4
|
+
"threads": []
|
|
5
|
+
}
|