sticky-note-cli 2.9.1 → 2.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,10 @@
17
17
 
18
18
  <br/>
19
19
 
20
+ <p align="center">
21
+ <img src="docs/images/demo.gif" alt="Sticky Note demo" width="600" />
22
+ </p>
23
+
20
24
  <p align="center">
21
25
  <img src="https://raw.githubusercontent.com/BandaruDheeraj/sticky-note/main/docs/images/architecture.svg" alt="Architecture diagram" width="800" />
22
26
  </p>
@@ -154,7 +158,7 @@ threads only — non-stuck threads are held for lazy injection.
154
158
  - **Stuck threads** — All stuck threads are injected eagerly (V2.5).
155
159
  - **Team config** — Conventions, MCP servers, and skills from
156
160
  `sticky-note-config.json`.
157
- - **Active presence** — Developers seen in the last 15 minutes and the
161
+ - **Active presence** — Developers seen in the last hour and the
158
162
  files they're working on.
159
163
 
160
164
  The hook also clears the injected-this-session tracking set, snapshots
package/bin/cli.js CHANGED
@@ -83,26 +83,108 @@ function readTemplate(name) {
83
83
  return fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf-8");
84
84
  }
85
85
 
86
- // Replace $(git rev-parse --show-toplevel) with absolute root path
87
- // so hook commands work from any CWD without shell expansion.
88
- function resolveHookPaths(obj, rootDir) {
89
- const root = rootDir.replace(/\\/g, "/");
90
- const resolve = (s) =>
91
- s.replace(/"\$\(git rev-parse --show-toplevel\)\//g, `"${root}/`);
92
- const hooks = obj.hooks || {};
86
+ // Hook commands in templates use relative paths (e.g. ".claude/hooks/foo.js")
87
+ // because Claude Code and Copilot CLI both resolve hook commands relative to
88
+ // the project root. Earlier versions rewrote these to absolute paths at init
89
+ // time, but that path was machine-specific and got committed to settings.json,
90
+ // causing every teammate's hooks to fail silently (issue #11). This is now a
91
+ // no-op, kept only to avoid breaking any external callers of the function.
92
+ function resolveHookPaths(_obj, _rootDir) {
93
+ return;
94
+ }
95
+
96
+ // Detects hook commands in an existing settings.json that point at an absolute
97
+ // path (likely written by an older sticky-note-cli on another developer's
98
+ // machine). Returns the list of stale command strings, or [] if all clean.
99
+ function findStaleHookPaths(settings) {
100
+ const stale = [];
101
+ const hooks = (settings && settings.hooks) || {};
93
102
  for (const entries of Object.values(hooks)) {
94
103
  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);
104
+ for (const h of entry.hooks || []) {
105
+ const cmd = h && h.command;
106
+ if (typeof cmd !== "string") continue;
107
+ // Absolute paths (Unix /Users, /home, /root or Windows C:/, D:/ etc.)
108
+ // inside the hook command are the smoking gun. Match anywhere in the
109
+ // string — the path may be preceded by other tokens (env vars, shell
110
+ // prefixes) and may contain spaces.
111
+ if (
112
+ /["'][A-Za-z]:[\\/]/.test(cmd) ||
113
+ /["']\/(?:Users|home|root|tmp)\//.test(cmd) ||
114
+ cmd.includes("$(git rev-parse")
115
+ ) {
116
+ stale.push(cmd);
98
117
  }
99
118
  }
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
119
  }
104
120
  }
105
- return obj;
121
+ return stale;
122
+ }
123
+
124
+ // Parse a semver-ish version string ("2.9.1", "2.9.1-beta.1", "v2.9") into
125
+ // [major, minor, patch] numbers. Pre-release suffixes are ignored. Returns
126
+ // null if unparseable.
127
+ function parseSemver(v) {
128
+ if (typeof v !== "string") return null;
129
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
130
+ if (!m) return null;
131
+ return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
132
+ }
133
+
134
+ // Returns -1 if a < b, 0 if equal, 1 if a > b. Unparseable values sort last.
135
+ function compareSemver(a, b) {
136
+ const pa = parseSemver(a), pb = parseSemver(b);
137
+ if (!pa && !pb) return 0;
138
+ if (!pa) return 1;
139
+ if (!pb) return -1;
140
+ for (let i = 0; i < 3; i++) {
141
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
142
+ }
143
+ return 0;
144
+ }
145
+
146
+ // When updating an existing config, only bump min_version forward — never
147
+ // downgrade if a teammate has already pinned a newer minimum.
148
+ function bumpMinVersion(existing, current) {
149
+ if (!existing) return current;
150
+ return compareSemver(existing, current) >= 0 ? existing : current;
151
+ }
152
+
153
+ // Add sticky-note-cli to the project package.json devDependencies so every
154
+ // teammate gets a pinned working copy on `npm install` (issue #12). No-ops
155
+ // gracefully if there is no package.json or if the entry is already present
156
+ // at >= the current version.
157
+ function pinCliInPackageJson() {
158
+ const pkgPath = path.join(process.cwd(), "package.json");
159
+ if (!fs.existsSync(pkgPath)) {
160
+ print(" ⏭️ No package.json found — skipping devDependency pin");
161
+ return;
162
+ }
163
+ let pkg;
164
+ try {
165
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
166
+ } catch (err) {
167
+ print(` ⏭️ Could not parse package.json: ${err.message}`);
168
+ return;
169
+ }
170
+ pkg.devDependencies = pkg.devDependencies || {};
171
+ const existing = pkg.devDependencies["sticky-note-cli"];
172
+ const desired = `^${VERSION}`;
173
+ if (existing) {
174
+ const existingClean = String(existing).replace(/^[\^~]/, "");
175
+ if (compareSemver(existingClean, VERSION) >= 0) {
176
+ print(` ⏭️ package.json devDependencies already pin sticky-note-cli ${existing}`);
177
+ return;
178
+ }
179
+ }
180
+ pkg.devDependencies["sticky-note-cli"] = desired;
181
+ // Keep keys sorted for clean diffs
182
+ pkg.devDependencies = Object.fromEntries(
183
+ Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
184
+ );
185
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
186
+ print(` [OK] package.json devDependencies: sticky-note-cli ${desired}`);
187
+ print(" Run `npm install` to lock the version for your team.");
106
188
  }
107
189
 
108
190
  function makeExecutable(filePath) {
@@ -654,17 +736,32 @@ async function cmdInit() {
654
736
  print(` [OK] .claude/hooks/${file}`);
655
737
  }
656
738
 
657
- // Create settings.json (Claude Code)
739
+ // Create settings.json (Claude Code) — uses relative hook paths so it can
740
+ // be safely committed and shared across teammates (issue #11).
658
741
  const settingsTemplate = JSON.parse(readTemplate("settings.json"));
659
- resolveHookPaths(settingsTemplate, process.cwd());
660
742
  const settingsDest = path.join(process.cwd(), ".claude", "settings.json");
743
+ if (fs.existsSync(settingsDest)) {
744
+ const existing = readJsonSafe(settingsDest, {});
745
+ const stale = findStaleHookPaths(existing);
746
+ if (stale.length > 0) {
747
+ print(" ⚠️ .claude/settings.json contains absolute or shell-expanded hook paths:");
748
+ for (const cmd of stale.slice(0, 3)) print(` ${cmd}`);
749
+ print(" Rewriting with relative paths so hooks work for every teammate.");
750
+ }
751
+ }
661
752
  fs.writeFileSync(settingsDest, JSON.stringify(settingsTemplate, null, 2) + "\n");
662
753
  print(" [OK] .claude/settings.json");
663
754
 
664
- // Create hooks.json (Copilot CLI)
755
+ // Create hooks.json (Copilot CLI) — also relative paths.
665
756
  const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
666
- resolveHookPaths(hooksTemplate, process.cwd());
667
757
  const hooksDest = path.join(githubHooksDir, "hooks.json");
758
+ if (fs.existsSync(hooksDest)) {
759
+ const existing = readJsonSafe(hooksDest, {});
760
+ const stale = findStaleHookPaths(existing);
761
+ if (stale.length > 0) {
762
+ print(" ⚠️ .github/hooks/hooks.json contains absolute or shell-expanded hook paths — rewriting.");
763
+ }
764
+ }
668
765
  fs.writeFileSync(hooksDest, JSON.stringify(hooksTemplate, null, 2) + "\n");
669
766
  print(" [OK] .github/hooks/hooks.json");
670
767
 
@@ -718,6 +815,7 @@ async function cmdInit() {
718
815
  configTemplate.stale_days = staleDaysResolved;
719
816
  configTemplate.inject_token_budget = injectTokenBudgetResolved;
720
817
  configTemplate.hook_version = VERSION;
818
+ configTemplate.min_version = VERSION;
721
819
  fs.writeFileSync(configDest, JSON.stringify(configTemplate, null, 2) + "\n");
722
820
  } else {
723
821
  // Update existing config with new settings
@@ -728,10 +826,15 @@ async function cmdInit() {
728
826
  existing.stale_days = staleDaysResolved;
729
827
  existing.inject_token_budget = injectTokenBudgetResolved;
730
828
  existing.hook_version = VERSION;
829
+ existing.min_version = bumpMinVersion(existing.min_version, VERSION);
731
830
  fs.writeFileSync(configDest, JSON.stringify(existing, null, 2) + "\n");
732
831
  }
733
832
  print(" [OK] .sticky-note/sticky-note-config.json");
734
833
 
834
+ // Pin sticky-note-cli in the project package.json devDependencies so every
835
+ // teammate gets the same version on `npm install` (issue #12).
836
+ pinCliInPackageJson();
837
+
735
838
  // Update .gitignore
736
839
  const gitignorePath = path.join(process.cwd(), ".gitignore");
737
840
  const ignoreAdditions = fs
@@ -998,8 +1101,11 @@ function cmdUpdate() {
998
1101
  const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
999
1102
  if (fs.existsSync(settingsPath)) {
1000
1103
  const settingsTemplate = JSON.parse(readTemplate("settings.json"));
1001
- resolveHookPaths(settingsTemplate, process.cwd());
1002
1104
  const existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
1105
+ const stale = findStaleHookPaths(existing);
1106
+ if (stale.length > 0) {
1107
+ print(" ⚠️ Existing settings.json had absolute hook paths — replacing with relative paths.");
1108
+ }
1003
1109
  existing.hooks = settingsTemplate.hooks;
1004
1110
  fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
1005
1111
  print(" [OK] .claude/settings.json (hooks updated)");
@@ -1009,7 +1115,6 @@ function cmdUpdate() {
1009
1115
  const hooksPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
1010
1116
  if (fs.existsSync(hooksPath)) {
1011
1117
  const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
1012
- resolveHookPaths(hooksTemplate, process.cwd());
1013
1118
  fs.writeFileSync(hooksPath, JSON.stringify(hooksTemplate, null, 2) + "\n");
1014
1119
  print(" [OK] .github/hooks/hooks.json (updated)");
1015
1120
  }
@@ -1161,6 +1266,33 @@ function cmdStatus() {
1161
1266
  ` ${fs.existsSync(hooksJsonPath) ? "[OK]" : "[ERR]"} .github/hooks/hooks.json`
1162
1267
  );
1163
1268
 
1269
+ // Issue #11 guard: warn about absolute hook paths in shared config files.
1270
+ for (const [label, p] of [
1271
+ [".claude/settings.json", settingsPath],
1272
+ [".github/hooks/hooks.json", hooksJsonPath],
1273
+ ]) {
1274
+ if (!fs.existsSync(p)) continue;
1275
+ const data = readJsonSafe(p, {});
1276
+ const stale = findStaleHookPaths(data);
1277
+ if (stale.length > 0) {
1278
+ print(` ⚠️ ${label} has absolute hook paths — hooks will fail for teammates.`);
1279
+ print(" Run `npx sticky-note init` to rewrite with relative paths.");
1280
+ }
1281
+ }
1282
+
1283
+ // Issue #12 guard: warn if installed CLI is below the project's min_version.
1284
+ const configPathForVersionCheck = path.join(stickyDir, "sticky-note-config.json");
1285
+ if (fs.existsSync(configPathForVersionCheck)) {
1286
+ const cfg = readJsonSafe(configPathForVersionCheck, {});
1287
+ if (cfg.min_version && compareSemver(VERSION, cfg.min_version) < 0) {
1288
+ print(
1289
+ ` ⚠️ Installed sticky-note-cli v${VERSION} is below the project's ` +
1290
+ `min_version v${cfg.min_version}.`
1291
+ );
1292
+ print(" Run `npm i -D sticky-note-cli@latest` to update.");
1293
+ }
1294
+ }
1295
+
1164
1296
  // Sticky Note data — V2 format
1165
1297
  const memoryPath = path.join(stickyDir, "sticky-note.json");
1166
1298
  const configPath = path.join(stickyDir, "sticky-note-config.json");
@@ -1243,7 +1375,7 @@ function cmdStatus() {
1243
1375
  const lastSeen = info.last_seen || "";
1244
1376
  if (lastSeen) {
1245
1377
  const ts = new Date(lastSeen).getTime();
1246
- if (!isNaN(ts) && now - ts < 15 * 60 * 1000) {
1378
+ if (!isNaN(ts) && now - ts < 60 * 60 * 1000) {
1247
1379
  activeUsers.push(path.basename(f, ".json"));
1248
1380
  }
1249
1381
  }
@@ -1687,7 +1819,7 @@ function cmdWho() {
1687
1819
  for (const [user, info] of Object.entries(data)) {
1688
1820
  const lastSeen = info.last_seen || "";
1689
1821
  const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1690
- const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1822
+ const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
1691
1823
  const ago = lastSeen ? _relativeTime(lastSeen) : "unknown";
1692
1824
  const files = (info.active_files || []).slice(0, 5).join(", ");
1693
1825
  const marker = active ? "[ACTIVE]" : "[IDLE]";
@@ -1713,7 +1845,7 @@ function cmdWho() {
1713
1845
  const info = readJsonSafe(path.join(presenceDir, f), {});
1714
1846
  const lastSeen = info.last_seen || "";
1715
1847
  const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1716
- const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1848
+ const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
1717
1849
  users.push({ user, info, lastSeen, active });
1718
1850
  }
1719
1851
 
package/bin/mcp-server.js CHANGED
@@ -234,7 +234,7 @@ function toolGetPresence() {
234
234
  const active = entries
235
235
  .filter(([_user, data]) => {
236
236
  const lastSeen = new Date(data.last_seen || 0);
237
- return now - lastSeen < 15 * 60 * 1000;
237
+ return now - lastSeen < 60 * 60 * 1000;
238
238
  })
239
239
  .map(([user, data]) => ({
240
240
  user,
@@ -298,7 +298,7 @@ function toolCheckOverlaps(params) {
298
298
  for (const [user, data] of Object.entries(presence)) {
299
299
  if (user === currentUser) continue;
300
300
  const lastSeen = new Date(data.last_seen || 0);
301
- if (now - lastSeen > 15 * 60 * 1000) continue;
301
+ if (now - lastSeen > 60 * 60 * 1000) continue;
302
302
 
303
303
  const presenceFiles = (data.active_files || []).map((f) =>
304
304
  f.replace(/\\/g, "/")
@@ -576,7 +576,7 @@ const TOOLS = {
576
576
  },
577
577
  get_presence: {
578
578
  description:
579
- "Get current developer presence — who is actively working and on which files. Only shows users seen in the last 15 minutes.",
579
+ "Get current developer presence — who is actively working and on which files. Only shows users seen in the last hour.",
580
580
  inputSchema: {
581
581
  type: "object",
582
582
  properties: {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.9.1",
3
+ "version": "2.9.2",
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": {
@@ -32,5 +32,8 @@
32
32
  },
33
33
  "scripts": {
34
34
  "test": "node test/smoke.test.js"
35
+ },
36
+ "devDependencies": {
37
+ "sticky-note-cli": "^2.9.1"
35
38
  }
36
39
  }
@@ -159,6 +159,7 @@ Example format:
159
159
  - `.sticky-note/.sticky-head` — transient HEAD snapshot
160
160
  - `.sticky-note/.sticky-injected` — transient injection tracking (V2.5)
161
161
  - `.sticky-note/.sticky-active-resume` — transient active resume marker (V2.5)
162
+ - `.sticky-note/.sticky-banner-shown` — transient startup banner dedup
162
163
 
163
164
  When a session ends or the user asks to commit, **always include
164
165
  `sticky-note.json` and the `audit/` and `presence/` directories**
@@ -162,6 +162,7 @@ Example format:
162
162
  - `.sticky-note/.sticky-head` — transient HEAD snapshot
163
163
  - `.sticky-note/.sticky-injected` — transient injection tracking (V2.5)
164
164
  - `.sticky-note/.sticky-active-resume` — transient active resume marker (V2.5)
165
+ - `.sticky-note/.sticky-banner-shown` — transient startup banner dedup
165
166
 
166
167
  When a session ends or the user asks to commit, **always include
167
168
  `sticky-note.json` and the `audit/` and `presence/` directories**
@@ -8,6 +8,7 @@
8
8
  .sticky-note/.sticky-injected
9
9
  .sticky-note/.sticky-active-resume
10
10
  .sticky-note/.sticky-checkpoint
11
+ .sticky-note/.sticky-banner-shown
11
12
 
12
13
  # Sticky Note - debug log
13
14
  .sticky-note/hook-debug.log
@@ -39,10 +39,20 @@ function _safeExit() {
39
39
  let utils;
40
40
  try {
41
41
  utils = require("./sticky-utils.js");
42
- } catch (_) {
42
+ } catch (err) {
43
+ try {
44
+ process.stderr.write(
45
+ `[STICKY-NOTE] UserPromptSubmit hook error: failed to load sticky-utils.js — ${err.message}\n` +
46
+ `[STICKY-NOTE] hint: re-run \`npx sticky-note init\` to restore .claude/hooks/.\n`
47
+ );
48
+ } catch (_) { /* ignore */ }
43
49
  _safeExit();
44
50
  }
45
51
 
52
+ // Issue #13: also self-heal from this hook (Copilot CLI doesn't fire
53
+ // SessionStart, so inject-context may be the first hook to run successfully).
54
+ try { utils.selfHealHookPaths && utils.selfHealHookPaths(); } catch (_) { /* non-fatal */ }
55
+
46
56
  let gitNotes;
47
57
  try {
48
58
  gitNotes = require("./sticky-git-notes.js");
@@ -626,6 +636,9 @@ if (require.main === module) {
626
636
  try {
627
637
  main();
628
638
  } catch (exc) {
639
+ try {
640
+ utils.reportHookError("UserPromptSubmit", exc);
641
+ } catch (_) { /* ignore */ }
629
642
  try {
630
643
  appendAuditLine({
631
644
  type: "inject_result",
@@ -22,7 +22,13 @@ function safeExit() {
22
22
  let utils;
23
23
  try {
24
24
  utils = require("./sticky-utils.js");
25
- } catch (_) {
25
+ } catch (err) {
26
+ try {
27
+ process.stderr.write(
28
+ `[STICKY-NOTE] Stop hook error: failed to load sticky-utils.js — ${err.message}\n` +
29
+ `[STICKY-NOTE] hint: re-run \`npx sticky-note init\` to restore .claude/hooks/.\n`
30
+ );
31
+ } catch (_) { /* ignore */ }
26
32
  safeExit();
27
33
  }
28
34
 
@@ -285,6 +291,8 @@ async function main() {
285
291
  }
286
292
 
287
293
  main().catch((err) => {
288
- try { utils.logHookError("on-stop", err); } catch (_) {}
294
+ try { utils.reportHookError("Stop", err); } catch (_) {
295
+ try { utils.logHookError("on-stop", err); } catch (_) {}
296
+ }
289
297
  safeExit();
290
298
  });
@@ -33,13 +33,78 @@ function _safeExit() {
33
33
  process.exit(0);
34
34
  }
35
35
 
36
+ // Issue #12: warn (non-blocking) when the running CLI is below the project's
37
+ // configured min_version. Returns "" when nothing to say. Also writes a
38
+ // stderr banner so the user sees it directly even if the hook output is
39
+ // swallowed by the host tool.
40
+ function _parseSemver(v) {
41
+ if (typeof v !== "string") return null;
42
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
43
+ if (!m) return null;
44
+ return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
45
+ }
46
+ function _cmpSemver(a, b) {
47
+ const pa = _parseSemver(a), pb = _parseSemver(b);
48
+ if (!pa || !pb) return 0;
49
+ for (let i = 0; i < 3; i++) {
50
+ if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
51
+ }
52
+ return 0;
53
+ }
54
+ function _installedCliVersion() {
55
+ // Try the package.json shipped alongside this hook script's CLI.
56
+ try {
57
+ const candidates = [
58
+ path.join(__dirname, "..", "..", "package.json"),
59
+ path.join(__dirname, "..", "..", "..", "sticky-note-cli", "package.json"),
60
+ ];
61
+ for (const p of candidates) {
62
+ try {
63
+ const pkg = JSON.parse(require("fs").readFileSync(p, "utf-8"));
64
+ if (pkg && pkg.name === "sticky-note-cli" && pkg.version) return pkg.version;
65
+ } catch (_) { /* try next */ }
66
+ }
67
+ } catch (_) { /* ignore */ }
68
+ return null;
69
+ }
70
+ function formatVersionWarning(config) {
71
+ try {
72
+ const min = config && config.min_version;
73
+ if (!min) return "";
74
+ const installed = _installedCliVersion();
75
+ if (!installed) return "";
76
+ if (_cmpSemver(installed, min) >= 0) return "";
77
+ const msg =
78
+ `[STICKY-NOTE] ⚠️ Installed sticky-note-cli v${installed} is below ` +
79
+ `this project's min_version v${min}. Run \`npm i -D sticky-note-cli@latest\` ` +
80
+ `to update — features and schema may differ until you do.`;
81
+ try { process.stderr.write(msg + "\n"); } catch (_) { /* non-fatal */ }
82
+ return msg;
83
+ } catch (_) {
84
+ return "";
85
+ }
86
+ }
87
+
36
88
  let utils;
37
89
  try {
38
90
  utils = require("./sticky-utils.js");
39
- } catch (_) {
91
+ } catch (err) {
92
+ try {
93
+ process.stderr.write(
94
+ `[STICKY-NOTE] session-start hook error: failed to load sticky-utils.js — ${err.message}\n` +
95
+ `[STICKY-NOTE] hint: re-run \`npx sticky-note init\` to restore .claude/hooks/.\n`
96
+ );
97
+ } catch (_) { /* ignore */ }
40
98
  _safeExit();
41
99
  }
42
100
 
101
+ // Issue #13: self-heal stale absolute hook paths in .claude/settings.json
102
+ // before doing anything else, so the next session loads cleanly even if some
103
+ // other hook (e.g. on-stop, inject-context) was pointing at a path that no
104
+ // longer exists. Failures here are non-fatal — we still want the rest of
105
+ // session-start to run.
106
+ try { utils.selfHealHookPaths && utils.selfHealHookPaths(); } catch (_) { /* non-fatal */ }
107
+
43
108
  const {
44
109
  getMemoryPath,
45
110
  getConfigPath,
@@ -121,6 +186,59 @@ function autoCloseCopilotCliThreads(memory, autoCloseHours) {
121
186
 
122
187
  // ── Formatting helpers ────────────────────────────────────
123
188
 
189
+ function formatStuckBanner(threads) {
190
+ const stuck = (threads || []).filter((t) => t && t.status === "stuck");
191
+ if (stuck.length === 0) return "";
192
+
193
+ const R = "\x1b[0m";
194
+ const Y = "\x1b[33m";
195
+ const BY = "\x1b[1;33m";
196
+ const BR = "\x1b[1;31m";
197
+ const B = "\x1b[1m";
198
+ const C = "\x1b[36m";
199
+ const D = "\x1b[2m";
200
+ const bar = "━".repeat(52);
201
+
202
+ const lines = [
203
+ "",
204
+ `${Y}${bar}${R}`,
205
+ `${BY} ⚠️ STUCK THREADS${R}`,
206
+ `${Y}${bar}${R}`,
207
+ ];
208
+
209
+ for (const t of stuck.slice(0, 5)) {
210
+ const user = t.user || t.author || "unknown";
211
+ const branch = t.branch ? ` ${D}·${R} ${C}${t.branch}${R}` : "";
212
+ const files = (t.files_touched || []).slice(0, 3).join(", ");
213
+ const narrative = t.narrative || t.last_note || "";
214
+ const snip =
215
+ narrative.length > 80 ? narrative.substring(0, 80) + "…" : narrative;
216
+ const threadId = (t.id || "").substring(0, 8);
217
+ const failed = t.failed_approaches || [];
218
+
219
+ lines.push(
220
+ `${Y} ┃${R} ${BR}[STUCK]${R} ${B}${user}${R}${branch}`
221
+ );
222
+ if (files) lines.push(`${Y} ┃${R} Files: ${files}`);
223
+ if (snip) lines.push(`${Y} ┃${R} ${D}"${snip}"${R}`);
224
+ if (failed.length > 0) {
225
+ lines.push(
226
+ `${Y} ┃${R} ${BR}⚠️ ${failed.length} failed approach(es)${R}`
227
+ );
228
+ }
229
+ lines.push(
230
+ `${Y} ┃${R} → Resume: ${C}npx sticky-note resume ${threadId}${R}`
231
+ );
232
+ }
233
+
234
+ if (stuck.length > 5) {
235
+ lines.push(`${Y} ┃${R} ... and ${stuck.length - 5} more`);
236
+ }
237
+
238
+ lines.push(`${Y}${bar}${R}`, "");
239
+ return lines.join("\n") + "\n";
240
+ }
241
+
124
242
  function formatThreadsForInjection(threads, maxThreads, maxTokens) {
125
243
  maxThreads = maxThreads || 10;
126
244
  maxTokens = maxTokens || 500;
@@ -251,7 +369,7 @@ function formatPresence(presenceData, threads) {
251
369
  try {
252
370
  const ts = new Date(lastSeen).getTime();
253
371
  if (isNaN(ts)) continue;
254
- if (now - ts < 15 * 60 * 1000) {
372
+ if (now - ts < 60 * 60 * 1000) {
255
373
  const files = (info.active_files || []).slice(0, 3).join(", ");
256
374
  const thread = userThreads[user];
257
375
  let line = `- **${user}**`;
@@ -911,6 +1029,7 @@ function main() {
911
1029
  const overlapContext = formatOverlapWarnings(
912
1030
  (memory.threads || []).filter(Boolean), getUser(), memory
913
1031
  );
1032
+ const versionWarning = formatVersionWarning(config);
914
1033
 
915
1034
  // V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject.
916
1035
  // Skip for Copilot CLI — its sessionStart output is not surfaced to the AI,
@@ -930,6 +1049,41 @@ function main() {
930
1049
 
931
1050
  saveMemoryMerged(memoryPath, memory);
932
1051
 
1052
+ // ── Stuck threads startup banner ────────────────────────
1053
+ // Write a visible banner to stderr so the user sees stuck threads
1054
+ // immediately when the session starts, before any AI interaction.
1055
+ const stuckThreads = (memory.threads || [])
1056
+ .filter((t) => t && t.status === "stuck");
1057
+ if (stuckThreads.length > 0) {
1058
+ const fs = require("fs");
1059
+ const bannerShownPath = path.join(
1060
+ path.dirname(memoryPath),
1061
+ ".sticky-banner-shown"
1062
+ );
1063
+ let showBanner = true;
1064
+
1065
+ // Copilot CLI fires sessionStart per-turn — only show once per session.
1066
+ if (isCopilotCli) {
1067
+ try {
1068
+ const shown = fs.readFileSync(bannerShownPath, "utf-8").trim();
1069
+ if (shown === sessionId) showBanner = false;
1070
+ } catch (_) {
1071
+ /* file doesn't exist yet */
1072
+ }
1073
+ }
1074
+
1075
+ if (showBanner) {
1076
+ process.stderr.write(formatStuckBanner(stuckThreads));
1077
+ if (isCopilotCli) {
1078
+ try {
1079
+ fs.writeFileSync(bannerShownPath, sessionId, "utf-8");
1080
+ } catch (_) {
1081
+ /* non-fatal */
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+
933
1087
  // ── Assemble output ───────────────────────────────────
934
1088
  const parts = [];
935
1089
 
@@ -1013,6 +1167,7 @@ function main() {
1013
1167
  }
1014
1168
 
1015
1169
  // Overlap warning comes first — most urgent signal
1170
+ if (versionWarning) parts.push(versionWarning);
1016
1171
  if (overlapContext) parts.push(overlapContext);
1017
1172
 
1018
1173
  if (threadContext) parts.push(threadContext);
@@ -1028,6 +1183,8 @@ function main() {
1028
1183
  try {
1029
1184
  main();
1030
1185
  } catch (err) {
1031
- try { utils.logHookError("session-start", err); } catch (_) {}
1186
+ try { utils.reportHookError("session-start", err); } catch (_) {
1187
+ try { utils.logHookError("session-start", err); } catch (_) {}
1188
+ }
1032
1189
  _safeExit();
1033
1190
  }
@@ -57,6 +57,94 @@ function logHookError(hookName, error) {
57
57
  }
58
58
  }
59
59
 
60
+ // Issue #14: Surface hook failures to stderr so the host tool's generic
61
+ // "<HookName> hook error" message has actionable detail next to it. Earlier
62
+ // versions silently swallowed `require()` failures and top-level exceptions,
63
+ // which made stale-path issues (#13) and other crashes impossible to diagnose
64
+ // — particularly under UserPromptSubmit, which Claude Code reports without
65
+ // a stack trace. This helper prefixes every line with `[STICKY-NOTE]` so the
66
+ // user can spot it in mixed hook output.
67
+ function reportHookError(hookName, error, hint) {
68
+ const msg = error instanceof Error ? (error.stack || error.message) : String(error);
69
+ const lines = [`[STICKY-NOTE] ${hookName} hook error: ${msg}`];
70
+ if (hint) lines.push(`[STICKY-NOTE] hint: ${hint}`);
71
+ try { process.stderr.write(lines.join("\n") + "\n"); } catch (_) { /* ignore */ }
72
+ try { logHookError(hookName, error); } catch (_) { /* ignore */ }
73
+ }
74
+
75
+ // Issue #13: When a teammate copies `.claude/settings.json` from another
76
+ // machine (or runs an older sticky-note-cli that wrote absolute paths), the
77
+ // hook commands point at non-existent absolute paths and every hook fails
78
+ // with MODULE_NOT_FOUND. Re-running `npx sticky-note init` fixes it, but
79
+ // users typically don't know that — they just see "<Hook> hook error" each
80
+ // session. This helper detects and rewrites those stale commands in-place
81
+ // the first time any hook (e.g. session-start, inject-context) successfully
82
+ // loads, so the next session starts clean.
83
+ //
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.
87
+ //
88
+ // Returns the list of (hookEvent, oldCommand, newCommand) tuples that were
89
+ // rewritten, or [] if nothing was stale.
90
+ function selfHealHookPaths(settingsPath) {
91
+ const result = [];
92
+ try {
93
+ if (!settingsPath) {
94
+ settingsPath = path.join(process.cwd(), ".claude", "settings.json");
95
+ }
96
+ if (!fs.existsSync(settingsPath)) return result;
97
+
98
+ const raw = fs.readFileSync(settingsPath, "utf-8");
99
+ let settings;
100
+ try { settings = JSON.parse(raw); } catch (_) { return result; }
101
+ if (!settings || !settings.hooks) return result;
102
+
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/;
107
+ // Match the script basename in either quoted or bare form.
108
+ const BASENAME = /([A-Za-z0-9_.-]+\.(?:js|sh|cjs|mjs))(?:["'`\s]|$)/;
109
+
110
+ let changed = false;
111
+ for (const [event, entries] of Object.entries(settings.hooks)) {
112
+ if (!Array.isArray(entries)) continue;
113
+ for (const entry of entries) {
114
+ for (const h of (entry && entry.hooks) || []) {
115
+ const cmd = h && h.command;
116
+ if (typeof cmd !== "string" || !STALE.test(cmd)) continue;
117
+ const m = cmd.match(BASENAME);
118
+ if (!m) continue;
119
+ const basename = m[1];
120
+ const newCmd = `node ".claude/hooks/${basename}"`;
121
+ result.push({ event, oldCommand: cmd, newCommand: newCmd });
122
+ h.command = newCmd;
123
+ changed = true;
124
+ }
125
+ }
126
+ }
127
+
128
+ if (!changed) return result;
129
+
130
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
131
+ try {
132
+ const summary =
133
+ `[STICKY-NOTE] ⚠️ Detected ${result.length} stale absolute hook path(s) in ` +
134
+ `${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.`;
137
+ process.stderr.write(summary + "\n");
138
+ for (const { event, oldCommand, newCommand } of result.slice(0, 5)) {
139
+ process.stderr.write(`[STICKY-NOTE] ${event}: ${oldCommand} → ${newCommand}\n`);
140
+ }
141
+ } catch (_) { /* non-fatal */ }
142
+ } catch (err) {
143
+ try { logHookError("selfHealHookPaths", err); } catch (_) { /* ignore */ }
144
+ }
145
+ return result;
146
+ }
147
+
60
148
  const _paths = {
61
149
  memory: "sticky-note.json",
62
150
  config: "sticky-note-config.json",
@@ -947,6 +1035,8 @@ module.exports = {
947
1035
  RETRY_PATTERNS,
948
1036
  FILE_PATH_PATTERN,
949
1037
  logHookError,
1038
+ reportHookError,
1039
+ selfHealHookPaths,
950
1040
  getMemoryPath,
951
1041
  getConfigPath,
952
1042
  getAuditPath,
@@ -1,47 +1,47 @@
1
- {
2
- "version": 1,
3
- "hooks": {
4
- "sessionStart": [
5
- {
6
- "type": "command",
7
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/session-start.js\" --copilot-cli",
8
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.claude/hooks/session-start.js\" --copilot-cli"
9
- }
10
- ],
11
- "sessionEnd": [
12
- {
13
- "type": "command",
14
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/session-end.js\" --copilot-cli",
15
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.claude/hooks/session-end.js\" --copilot-cli"
16
- }
17
- ],
18
- "userPromptSubmitted": [
19
- {
20
- "type": "command",
21
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/inject-context.js\" --copilot-cli",
22
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.claude/hooks/inject-context.js\" --copilot-cli"
23
- }
24
- ],
25
- "preToolUse": [
26
- {
27
- "type": "command",
28
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/pre-tool-use.js\" --copilot-cli",
29
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.claude/hooks/pre-tool-use.js\" --copilot-cli"
30
- }
31
- ],
32
- "postToolUse": [
33
- {
34
- "type": "command",
35
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/track-work.js\" --copilot-cli",
36
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.claude/hooks/track-work.js\" --copilot-cli"
37
- }
38
- ],
39
- "errorOccurred": [
40
- {
41
- "type": "command",
42
- "bash": "COPILOT_CLI=1 node \"$(git rev-parse --show-toplevel)/.claude/hooks/on-error.js\" --copilot-cli",
43
- "powershell": "$env:COPILOT_CLI='1'; node \"$(git rev-parse --show-toplevel)/.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 \"$(git rev-parse --show-toplevel)/.claude/hooks/session-start.js\""
10
- }
11
- ]
12
- }
13
- ],
14
- "SessionEnd": [
15
- {
16
- "matcher": "",
17
- "hooks": [
18
- {
19
- "type": "command",
20
- "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/session-end.js\""
21
- }
22
- ]
23
- }
24
- ],
25
- "UserPromptSubmit": [
26
- {
27
- "matcher": "",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/inject-context.js\""
32
- }
33
- ]
34
- }
35
- ],
36
- "PreToolUse": [
37
- {
38
- "matcher": "",
39
- "hooks": [
40
- {
41
- "type": "command",
42
- "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/pre-tool-use.js\""
43
- }
44
- ]
45
- }
46
- ],
47
- "PostToolUse": [
48
- {
49
- "matcher": "",
50
- "hooks": [
51
- {
52
- "type": "command",
53
- "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/track-work.js\""
54
- }
55
- ]
56
- }
57
- ],
58
- "Stop": [
59
- {
60
- "matcher": "",
61
- "hooks": [
62
- {
63
- "type": "command",
64
- "command": "node \"$(git rev-parse --show-toplevel)/.claude/hooks/on-stop.js\""
65
- }
66
- ]
67
- }
68
- ],
69
- "PostToolUseFailure": [
70
- {
71
- "matcher": "",
72
- "hooks": [
73
- {
74
- "type": "command",
75
- "command": "node \"$(git rev-parse --show-toplevel)/.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": "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
+ }
@@ -7,5 +7,6 @@
7
7
  "mcp_servers": [],
8
8
  "skills": [],
9
9
  "conventions": [],
10
- "hook_version": "2.5.0"
10
+ "hook_version": "2.5.0",
11
+ "min_version": "2.9.1"
11
12
  }