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 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
- // Install pre-commit hook (auto-stage .sticky-note/ files)
979
- if (installPreCommitHook()) {
980
- print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
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
- if (installPreCommitHook()) {
1127
- print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
1128
- }
1129
- if (installPostCommitHook()) {
1130
- print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.9.2",
3
+ "version": "3.0.0",
4
4
  "description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,6 @@
1
+ # Sticky Note - data moved to sticky-note/data branch (.git/sticky-note/ cache)
2
+ .sticky-note/
3
+
1
4
  # Sticky Note - local overrides
2
5
  .claude/settings.local.json
3
6
 
@@ -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
- const scriptDir = path.dirname(path.resolve(__filename));
42
- return path.join(scriptDir, "..", "..", ".sticky-note");
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 always uses the relative form `node ".claude/hooks/<basename>"`,
85
- // matching what `templates/settings.json` ships today. We extract `<basename>`
86
- // from the existing command so we never invent hook scripts that don't exist.
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
- // Match the same shapes findStaleHookPaths() in bin/cli.js looks for:
104
- // any absolute path inside the command string, or a $(git rev-parse ...)
105
- // shell expansion that won't work on Windows / under Claude Code.
106
- const STALE = /["'][A-Za-z]:[\\/]|["']\/(?:Users|home|root|tmp)\/|\$\(git rev-parse/;
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_.-]+\.(?:js|sh|cjs|mjs))(?:["'`\s]|$)/;
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" || !STALE.test(cmd)) continue;
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 = `node ".claude/hooks/${basename}"`;
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 absolute hook path(s) in ` +
148
+ `[STICKY-NOTE] ⚠️ Detected ${result.length} stale hook path(s) in ` +
134
149
  `${path.relative(process.cwd(), settingsPath) || settingsPath} — rewrote to ` +
135
- `relative paths so hooks work on this machine. Commit the change to share ` +
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
@@ -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
+ }
@@ -1,81 +1,81 @@
1
- {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "node \".claude/hooks/session-start.js\""
10
- }
11
- ]
12
- }
13
- ],
14
- "SessionEnd": [
15
- {
16
- "matcher": "",
17
- "hooks": [
18
- {
19
- "type": "command",
20
- "command": "node \".claude/hooks/session-end.js\""
21
- }
22
- ]
23
- }
24
- ],
25
- "UserPromptSubmit": [
26
- {
27
- "matcher": "",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "node \".claude/hooks/inject-context.js\""
32
- }
33
- ]
34
- }
35
- ],
36
- "PreToolUse": [
37
- {
38
- "matcher": "",
39
- "hooks": [
40
- {
41
- "type": "command",
42
- "command": "node \".claude/hooks/pre-tool-use.js\""
43
- }
44
- ]
45
- }
46
- ],
47
- "PostToolUse": [
48
- {
49
- "matcher": "",
50
- "hooks": [
51
- {
52
- "type": "command",
53
- "command": "node \".claude/hooks/track-work.js\""
54
- }
55
- ]
56
- }
57
- ],
58
- "Stop": [
59
- {
60
- "matcher": "",
61
- "hooks": [
62
- {
63
- "type": "command",
64
- "command": "node \".claude/hooks/on-stop.js\""
65
- }
66
- ]
67
- }
68
- ],
69
- "PostToolUseFailure": [
70
- {
71
- "matcher": "",
72
- "hooks": [
73
- {
74
- "type": "command",
75
- "command": "node \".claude/hooks/on-error.js\""
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
+ }