sticky-note-cli 2.9.1 → 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",
@@ -83,26 +84,116 @@ function readTemplate(name) {
83
84
  return fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf-8");
84
85
  }
85
86
 
86
- // Replace $(git rev-parse --show-toplevel) with absolute root path
87
- // so hook commands work from any CWD without shell expansion.
88
- function resolveHookPaths(obj, rootDir) {
89
- const root = rootDir.replace(/\\/g, "/");
90
- const resolve = (s) =>
91
- s.replace(/"\$\(git rev-parse --show-toplevel\)\//g, `"${root}/`);
92
- const hooks = obj.hooks || {};
87
+ // Hook commands in templates use relative paths (e.g. ".claude/hooks/foo.js")
88
+ // because Claude Code and Copilot CLI both resolve hook commands relative to
89
+ // the project root. Earlier versions rewrote these to absolute paths at init
90
+ // time, but that path was machine-specific and got committed to settings.json,
91
+ // causing every teammate's hooks to fail silently (issue #11). This is now a
92
+ // no-op, kept only to avoid breaking any external callers of the function.
93
+ function resolveHookPaths(_obj, _rootDir) {
94
+ return;
95
+ }
96
+
97
+ // Detects hook commands in an existing settings.json that point at an absolute
98
+ // path (likely written by an older sticky-note-cli on another developer's
99
+ // machine). Returns the list of stale command strings, or [] if all clean.
100
+ function findStaleHookPaths(settings) {
101
+ const stale = [];
102
+ const hooks = (settings && settings.hooks) || {};
93
103
  for (const entries of Object.values(hooks)) {
94
104
  for (const entry of Array.isArray(entries) ? entries : []) {
95
- if (entry.hooks) {
96
- for (const h of entry.hooks) {
97
- if (h.command) h.command = resolve(h.command);
105
+ for (const h of entry.hooks || []) {
106
+ const cmd = h && h.command;
107
+ if (typeof cmd !== "string") continue;
108
+ // Absolute paths (Unix /Users, /home, /root or Windows C:/, D:/ etc.)
109
+ // inside the hook command are the smoking gun. Match anywhere in the
110
+ // string — the path may be preceded by other tokens (env vars, shell
111
+ // prefixes) and may contain spaces.
112
+ if (
113
+ /["'][A-Za-z]:[\\/]/.test(cmd) ||
114
+ /["']\/(?:Users|home|root|tmp)\//.test(cmd) ||
115
+ cmd.includes("$(git rev-parse")
116
+ ) {
117
+ stale.push(cmd);
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);
98
126
  }
99
127
  }
100
- if (entry.bash) entry.bash = resolve(entry.bash);
101
- if (entry.powershell) entry.powershell = resolve(entry.powershell);
102
- if (entry.command) entry.command = resolve(entry.command);
103
128
  }
104
129
  }
105
- return obj;
130
+ return stale;
131
+ }
132
+
133
+ // Parse a semver-ish version string ("2.9.1", "2.9.1-beta.1", "v2.9") into
134
+ // [major, minor, patch] numbers. Pre-release suffixes are ignored. Returns
135
+ // null if unparseable.
136
+ function parseSemver(v) {
137
+ if (typeof v !== "string") return null;
138
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
139
+ if (!m) return null;
140
+ return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
141
+ }
142
+
143
+ // Returns -1 if a < b, 0 if equal, 1 if a > b. Unparseable values sort last.
144
+ function compareSemver(a, b) {
145
+ const pa = parseSemver(a), pb = parseSemver(b);
146
+ if (!pa && !pb) return 0;
147
+ if (!pa) return 1;
148
+ if (!pb) return -1;
149
+ for (let i = 0; i < 3; i++) {
150
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
151
+ }
152
+ return 0;
153
+ }
154
+
155
+ // When updating an existing config, only bump min_version forward — never
156
+ // downgrade if a teammate has already pinned a newer minimum.
157
+ function bumpMinVersion(existing, current) {
158
+ if (!existing) return current;
159
+ return compareSemver(existing, current) >= 0 ? existing : current;
160
+ }
161
+
162
+ // Add sticky-note-cli to the project package.json devDependencies so every
163
+ // teammate gets a pinned working copy on `npm install` (issue #12). No-ops
164
+ // gracefully if there is no package.json or if the entry is already present
165
+ // at >= the current version.
166
+ function pinCliInPackageJson() {
167
+ const pkgPath = path.join(process.cwd(), "package.json");
168
+ if (!fs.existsSync(pkgPath)) {
169
+ print(" ⏭️ No package.json found — skipping devDependency pin");
170
+ return;
171
+ }
172
+ let pkg;
173
+ try {
174
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
175
+ } catch (err) {
176
+ print(` ⏭️ Could not parse package.json: ${err.message}`);
177
+ return;
178
+ }
179
+ pkg.devDependencies = pkg.devDependencies || {};
180
+ const existing = pkg.devDependencies["sticky-note-cli"];
181
+ const desired = `^${VERSION}`;
182
+ if (existing) {
183
+ const existingClean = String(existing).replace(/^[\^~]/, "");
184
+ if (compareSemver(existingClean, VERSION) >= 0) {
185
+ print(` ⏭️ package.json devDependencies already pin sticky-note-cli ${existing}`);
186
+ return;
187
+ }
188
+ }
189
+ pkg.devDependencies["sticky-note-cli"] = desired;
190
+ // Keep keys sorted for clean diffs
191
+ pkg.devDependencies = Object.fromEntries(
192
+ Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
193
+ );
194
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
195
+ print(` [OK] package.json devDependencies: sticky-note-cli ${desired}`);
196
+ print(" Run `npm install` to lock the version for your team.");
106
197
  }
107
198
 
108
199
  function makeExecutable(filePath) {
@@ -654,17 +745,32 @@ async function cmdInit() {
654
745
  print(` [OK] .claude/hooks/${file}`);
655
746
  }
656
747
 
657
- // Create settings.json (Claude Code)
748
+ // Create settings.json (Claude Code) — uses relative hook paths so it can
749
+ // be safely committed and shared across teammates (issue #11).
658
750
  const settingsTemplate = JSON.parse(readTemplate("settings.json"));
659
- resolveHookPaths(settingsTemplate, process.cwd());
660
751
  const settingsDest = path.join(process.cwd(), ".claude", "settings.json");
752
+ if (fs.existsSync(settingsDest)) {
753
+ const existing = readJsonSafe(settingsDest, {});
754
+ const stale = findStaleHookPaths(existing);
755
+ if (stale.length > 0) {
756
+ print(" ⚠️ .claude/settings.json contains absolute or shell-expanded hook paths:");
757
+ for (const cmd of stale.slice(0, 3)) print(` ${cmd}`);
758
+ print(" Rewriting with relative paths so hooks work for every teammate.");
759
+ }
760
+ }
661
761
  fs.writeFileSync(settingsDest, JSON.stringify(settingsTemplate, null, 2) + "\n");
662
762
  print(" [OK] .claude/settings.json");
663
763
 
664
- // Create hooks.json (Copilot CLI)
764
+ // Create hooks.json (Copilot CLI) — also relative paths.
665
765
  const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
666
- resolveHookPaths(hooksTemplate, process.cwd());
667
766
  const hooksDest = path.join(githubHooksDir, "hooks.json");
767
+ if (fs.existsSync(hooksDest)) {
768
+ const existing = readJsonSafe(hooksDest, {});
769
+ const stale = findStaleHookPaths(existing);
770
+ if (stale.length > 0) {
771
+ print(" ⚠️ .github/hooks/hooks.json contains absolute or shell-expanded hook paths — rewriting.");
772
+ }
773
+ }
668
774
  fs.writeFileSync(hooksDest, JSON.stringify(hooksTemplate, null, 2) + "\n");
669
775
  print(" [OK] .github/hooks/hooks.json");
670
776
 
@@ -718,6 +824,7 @@ async function cmdInit() {
718
824
  configTemplate.stale_days = staleDaysResolved;
719
825
  configTemplate.inject_token_budget = injectTokenBudgetResolved;
720
826
  configTemplate.hook_version = VERSION;
827
+ configTemplate.min_version = VERSION;
721
828
  fs.writeFileSync(configDest, JSON.stringify(configTemplate, null, 2) + "\n");
722
829
  } else {
723
830
  // Update existing config with new settings
@@ -728,10 +835,15 @@ async function cmdInit() {
728
835
  existing.stale_days = staleDaysResolved;
729
836
  existing.inject_token_budget = injectTokenBudgetResolved;
730
837
  existing.hook_version = VERSION;
838
+ existing.min_version = bumpMinVersion(existing.min_version, VERSION);
731
839
  fs.writeFileSync(configDest, JSON.stringify(existing, null, 2) + "\n");
732
840
  }
733
841
  print(" [OK] .sticky-note/sticky-note-config.json");
734
842
 
843
+ // Pin sticky-note-cli in the project package.json devDependencies so every
844
+ // teammate gets the same version on `npm install` (issue #12).
845
+ pinCliInPackageJson();
846
+
735
847
  // Update .gitignore
736
848
  const gitignorePath = path.join(process.cwd(), ".gitignore");
737
849
  const ignoreAdditions = fs
@@ -872,15 +984,9 @@ async function cmdInit() {
872
984
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
873
985
  }
874
986
 
875
- // Install pre-commit hook (auto-stage .sticky-note/ files)
876
- if (installPreCommitHook()) {
877
- print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
878
- }
879
-
880
- // Install post-commit hook (auto-commit leftover .sticky-note/ changes)
881
- if (installPostCommitHook()) {
882
- print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
883
- }
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.");
884
990
 
885
991
  // Create environment directory structure
886
992
  const envDir = path.join(stickyNoteDir, "environment");
@@ -998,8 +1104,11 @@ function cmdUpdate() {
998
1104
  const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
999
1105
  if (fs.existsSync(settingsPath)) {
1000
1106
  const settingsTemplate = JSON.parse(readTemplate("settings.json"));
1001
- resolveHookPaths(settingsTemplate, process.cwd());
1002
1107
  const existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
1108
+ const stale = findStaleHookPaths(existing);
1109
+ if (stale.length > 0) {
1110
+ print(" ⚠️ Existing settings.json had absolute hook paths — replacing with relative paths.");
1111
+ }
1003
1112
  existing.hooks = settingsTemplate.hooks;
1004
1113
  fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
1005
1114
  print(" [OK] .claude/settings.json (hooks updated)");
@@ -1009,7 +1118,6 @@ function cmdUpdate() {
1009
1118
  const hooksPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
1010
1119
  if (fs.existsSync(hooksPath)) {
1011
1120
  const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
1012
- resolveHookPaths(hooksTemplate, process.cwd());
1013
1121
  fs.writeFileSync(hooksPath, JSON.stringify(hooksTemplate, null, 2) + "\n");
1014
1122
  print(" [OK] .github/hooks/hooks.json (updated)");
1015
1123
  }
@@ -1018,11 +1126,35 @@ function cmdUpdate() {
1018
1126
  if (installGitHook("post-rewrite")) {
1019
1127
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
1020
1128
  }
1021
- if (installPreCommitHook()) {
1022
- print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
1023
- }
1024
- if (installPostCommitHook()) {
1025
- 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
+ }
1026
1158
  }
1027
1159
 
1028
1160
  // Update hook_version in config
@@ -1161,6 +1293,33 @@ function cmdStatus() {
1161
1293
  ` ${fs.existsSync(hooksJsonPath) ? "[OK]" : "[ERR]"} .github/hooks/hooks.json`
1162
1294
  );
1163
1295
 
1296
+ // Issue #11 guard: warn about absolute hook paths in shared config files.
1297
+ for (const [label, p] of [
1298
+ [".claude/settings.json", settingsPath],
1299
+ [".github/hooks/hooks.json", hooksJsonPath],
1300
+ ]) {
1301
+ if (!fs.existsSync(p)) continue;
1302
+ const data = readJsonSafe(p, {});
1303
+ const stale = findStaleHookPaths(data);
1304
+ if (stale.length > 0) {
1305
+ print(` ⚠️ ${label} has absolute hook paths — hooks will fail for teammates.`);
1306
+ print(" Run `npx sticky-note init` to rewrite with relative paths.");
1307
+ }
1308
+ }
1309
+
1310
+ // Issue #12 guard: warn if installed CLI is below the project's min_version.
1311
+ const configPathForVersionCheck = path.join(stickyDir, "sticky-note-config.json");
1312
+ if (fs.existsSync(configPathForVersionCheck)) {
1313
+ const cfg = readJsonSafe(configPathForVersionCheck, {});
1314
+ if (cfg.min_version && compareSemver(VERSION, cfg.min_version) < 0) {
1315
+ print(
1316
+ ` ⚠️ Installed sticky-note-cli v${VERSION} is below the project's ` +
1317
+ `min_version v${cfg.min_version}.`
1318
+ );
1319
+ print(" Run `npm i -D sticky-note-cli@latest` to update.");
1320
+ }
1321
+ }
1322
+
1164
1323
  // Sticky Note data — V2 format
1165
1324
  const memoryPath = path.join(stickyDir, "sticky-note.json");
1166
1325
  const configPath = path.join(stickyDir, "sticky-note-config.json");
@@ -1243,7 +1402,7 @@ function cmdStatus() {
1243
1402
  const lastSeen = info.last_seen || "";
1244
1403
  if (lastSeen) {
1245
1404
  const ts = new Date(lastSeen).getTime();
1246
- if (!isNaN(ts) && now - ts < 15 * 60 * 1000) {
1405
+ if (!isNaN(ts) && now - ts < 60 * 60 * 1000) {
1247
1406
  activeUsers.push(path.basename(f, ".json"));
1248
1407
  }
1249
1408
  }
@@ -1687,7 +1846,7 @@ function cmdWho() {
1687
1846
  for (const [user, info] of Object.entries(data)) {
1688
1847
  const lastSeen = info.last_seen || "";
1689
1848
  const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1690
- const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1849
+ const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
1691
1850
  const ago = lastSeen ? _relativeTime(lastSeen) : "unknown";
1692
1851
  const files = (info.active_files || []).slice(0, 5).join(", ");
1693
1852
  const marker = active ? "[ACTIVE]" : "[IDLE]";
@@ -1713,7 +1872,7 @@ function cmdWho() {
1713
1872
  const info = readJsonSafe(path.join(presenceDir, f), {});
1714
1873
  const lastSeen = info.last_seen || "";
1715
1874
  const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1716
- const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1875
+ const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
1717
1876
  users.push({ user, info, lastSeen, active });
1718
1877
  }
1719
1878
 
@@ -3218,6 +3377,308 @@ async function cmdEnvAddPlugin() {
3218
3377
  print("");
3219
3378
  }
3220
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
+
3221
3682
  // ──────────────────────────────────────────────
3222
3683
  // Main
3223
3684
  // ──────────────────────────────────────────────
@@ -3287,6 +3748,15 @@ async function main() {
3287
3748
  case "mcp-server":
3288
3749
  cmdMcpServer();
3289
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;
3290
3760
  case "--version":
3291
3761
  case "-v":
3292
3762
  print(`sticky-note v${VERSION}`);
@@ -3317,6 +3787,7 @@ async function main() {
3317
3787
  print(" bootstrap Provision MCP servers with secrets from manifest");
3318
3788
  print(" env Environment management (status, add-server, add-plugin)");
3319
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)");
3320
3791
  print("");
3321
3792
  print(" Options:");
3322
3793
  print(" --version Show version");