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/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": "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": {
@@ -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**
@@ -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
 
@@ -8,6 +11,7 @@
8
11
  .sticky-note/.sticky-injected
9
12
  .sticky-note/.sticky-active-resume
10
13
  .sticky-note/.sticky-checkpoint
14
+ .sticky-note/.sticky-banner-shown
11
15
 
12
16
  # Sticky Note - debug log
13
17
  .sticky-note/hook-debug.log
@@ -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
+ };
@@ -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
  });
@@ -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";