sticky-note-cli 2.6.13 → 2.6.15

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
@@ -91,6 +91,116 @@ function makeExecutable(filePath) {
91
91
  }
92
92
  }
93
93
 
94
+ const STICKY_PRE_COMMIT_MARKER = "# [sticky-note:auto-stage]";
95
+ const STICKY_PRE_COMMIT_END = "# [/sticky-note:auto-stage]";
96
+
97
+ /**
98
+ * Install a pre-commit git hook that auto-stages .sticky-note/ tracked files.
99
+ * If an existing pre-commit hook is present, our block is prepended (or updated)
100
+ * so it chains cleanly with linters, husky, etc.
101
+ */
102
+ function installPreCommitHook() {
103
+ try {
104
+ const gitDir = path.join(process.cwd(), ".git");
105
+ if (!fs.existsSync(gitDir)) return false;
106
+ const hooksDir = path.join(gitDir, "hooks");
107
+ mkdirSafe(hooksDir);
108
+ const dest = path.join(hooksDir, "pre-commit");
109
+
110
+ const stickyBlock = [
111
+ STICKY_PRE_COMMIT_MARKER,
112
+ '# Auto-stage .sticky-note/ files so they ride along with every commit.',
113
+ '_sn_root="$(git rev-parse --show-toplevel 2>/dev/null)"',
114
+ 'if [ -d "$_sn_root/.sticky-note" ]; then',
115
+ ' [ -f "$_sn_root/.sticky-note/sticky-note.json" ] && git add "$_sn_root/.sticky-note/sticky-note.json" 2>/dev/null || true',
116
+ ' [ -d "$_sn_root/.sticky-note/audit" ] && git add "$_sn_root/.sticky-note/audit/" 2>/dev/null || true',
117
+ ' [ -d "$_sn_root/.sticky-note/presence" ] && git add "$_sn_root/.sticky-note/presence/" 2>/dev/null || true',
118
+ 'fi',
119
+ STICKY_PRE_COMMIT_END,
120
+ ].join("\n");
121
+
122
+ if (fs.existsSync(dest)) {
123
+ let existing = fs.readFileSync(dest, "utf-8");
124
+ const startIdx = existing.indexOf(STICKY_PRE_COMMIT_MARKER);
125
+ const endIdx = existing.indexOf(STICKY_PRE_COMMIT_END);
126
+
127
+ if (startIdx !== -1 && endIdx !== -1) {
128
+ // Update our existing block in-place
129
+ existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_PRE_COMMIT_END.length);
130
+ } else {
131
+ // Prepend our block after the shebang (or at the top)
132
+ const shebangMatch = existing.match(/^#!.*\n/);
133
+ if (shebangMatch) {
134
+ existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
135
+ } else {
136
+ existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
137
+ }
138
+ }
139
+ fs.writeFileSync(dest, existing, "utf-8");
140
+ } else {
141
+ // No existing hook — create a fresh one
142
+ fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
143
+ }
144
+ makeExecutable(dest);
145
+ return true;
146
+ } catch (err) {
147
+ debugLog("installPreCommitHook failed: " + (err.message || err));
148
+ return false;
149
+ }
150
+ }
151
+
152
+ const STICKY_POST_COMMIT_MARKER = "# [sticky-note:auto-sync]";
153
+ const STICKY_POST_COMMIT_END = "# [/sticky-note:auto-sync]";
154
+
155
+ /**
156
+ * Install a post-commit git hook that auto-commits leftover .sticky-note/ changes.
157
+ * Uses the same prepend/update strategy as installPreCommitHook.
158
+ */
159
+ function installPostCommitHook() {
160
+ try {
161
+ const gitDir = path.join(process.cwd(), ".git");
162
+ if (!fs.existsSync(gitDir)) return false;
163
+ const hooksDir = path.join(gitDir, "hooks");
164
+ mkdirSafe(hooksDir);
165
+ const dest = path.join(hooksDir, "post-commit");
166
+ const src = path.join(TEMPLATES_DIR, "hooks", "post-commit");
167
+ const template = fs.existsSync(src) ? fs.readFileSync(src, "utf-8") : "";
168
+
169
+ // Extract just the body (skip shebang + first comment line from template)
170
+ const bodyLines = template.split("\n").filter(l => !l.startsWith("#!/"));
171
+ const stickyBlock = [
172
+ STICKY_POST_COMMIT_MARKER,
173
+ ...bodyLines,
174
+ STICKY_POST_COMMIT_END,
175
+ ].join("\n");
176
+
177
+ if (fs.existsSync(dest)) {
178
+ let existing = fs.readFileSync(dest, "utf-8");
179
+ const startIdx = existing.indexOf(STICKY_POST_COMMIT_MARKER);
180
+ const endIdx = existing.indexOf(STICKY_POST_COMMIT_END);
181
+
182
+ if (startIdx !== -1 && endIdx !== -1) {
183
+ existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_POST_COMMIT_END.length);
184
+ } else {
185
+ const shebangMatch = existing.match(/^#!.*\n/);
186
+ if (shebangMatch) {
187
+ existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
188
+ } else {
189
+ existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
190
+ }
191
+ }
192
+ fs.writeFileSync(dest, existing, "utf-8");
193
+ } else {
194
+ fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
195
+ }
196
+ makeExecutable(dest);
197
+ return true;
198
+ } catch (err) {
199
+ debugLog("installPostCommitHook failed: " + (err.message || err));
200
+ return false;
201
+ }
202
+ }
203
+
94
204
  function installGitHook(hookName) {
95
205
  try {
96
206
  const gitDir = path.join(process.cwd(), ".git");
@@ -640,6 +750,16 @@ async function cmdInit() {
640
750
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
641
751
  }
642
752
 
753
+ // Install pre-commit hook (auto-stage .sticky-note/ files)
754
+ if (installPreCommitHook()) {
755
+ print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
756
+ }
757
+
758
+ // Install post-commit hook (auto-commit leftover .sticky-note/ changes)
759
+ if (installPostCommitHook()) {
760
+ print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
761
+ }
762
+
643
763
  // Force-add .claude/hooks/ and .claude/settings.json so git tracks them
644
764
  // (needed if .claude/ was previously in .gitignore)
645
765
  try {
@@ -710,6 +830,12 @@ function cmdUpdate() {
710
830
  if (installGitHook("post-rewrite")) {
711
831
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
712
832
  }
833
+ if (installPreCommitHook()) {
834
+ print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
835
+ }
836
+ if (installPostCommitHook()) {
837
+ print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
838
+ }
713
839
 
714
840
  // Update hook_version in config
715
841
  const configPath = path.join(stickyDir, "sticky-note-config.json");
@@ -854,7 +980,7 @@ function cmdStatus() {
854
980
  if (fs.existsSync(memoryPath)) {
855
981
  try {
856
982
  const memory = JSON.parse(fs.readFileSync(memoryPath, "utf-8"));
857
- const threads = memory.threads || [];
983
+ const threads = (memory.threads || []).filter(Boolean);
858
984
  const schemaVersion = memory.version || "2";
859
985
 
860
986
  const openThreads = threads.filter((t) => t.status === "open").length;
@@ -999,7 +1125,7 @@ function cmdThreads() {
999
1125
  }
1000
1126
 
1001
1127
  const memory = readJsonSafe(memoryPath, { threads: [] });
1002
- const threads = memory.threads || [];
1128
+ const threads = (memory.threads || []).filter(Boolean);
1003
1129
 
1004
1130
  // Filter: show open, stuck, closed (not expired)
1005
1131
  const live = threads.filter((t) => ["open", "stuck", "closed", "stale"].includes(t.status));
@@ -1057,7 +1183,7 @@ function cmdResume() {
1057
1183
  // resume --list: show resumable threads
1058
1184
  if (args.includes("--list") || args.length === 0) {
1059
1185
  const memory = readJsonSafe(memoryPath, { threads: [] });
1060
- const threads = memory.threads || [];
1186
+ const threads = (memory.threads || []).filter(Boolean);
1061
1187
  const resumable = threads.filter((t) =>
1062
1188
  ["closed", "stuck", "open", "stale"].includes(t.status)
1063
1189
  );
@@ -1098,9 +1224,7 @@ function cmdResume() {
1098
1224
  // resume <thread-id>: set the resume signal
1099
1225
  const threadId = args[0];
1100
1226
  const memory = readJsonSafe(memoryPath, { threads: [] });
1101
- const threads = memory.threads || [];
1102
-
1103
- // Support partial ID matching (first 8 chars)
1227
+ const threads = (memory.threads || []).filter(Boolean);
1104
1228
  const match = threads.find((t) =>
1105
1229
  t.id === threadId || t.id.startsWith(threadId)
1106
1230
  );
@@ -1196,7 +1320,7 @@ function cmdReset() {
1196
1320
  const force = args.includes("--force");
1197
1321
 
1198
1322
  const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
1199
- const threadCount = (memory.threads || []).length;
1323
+ const threadCount = (memory.threads || []).filter(Boolean).length;
1200
1324
 
1201
1325
  if (threadCount === 0) {
1202
1326
  print(" Nothing to reset -- 0 threads.");
@@ -1503,7 +1627,7 @@ function cmdOverlap() {
1503
1627
  const isMe = (user) => currentUserNames.has(user || "");
1504
1628
  const memory = readJsonSafe(memoryPath, { threads: [] });
1505
1629
  const threads = (memory.threads || []).filter(
1506
- (t) => t.status === "open" || t.status === "stuck"
1630
+ (t) => t && (t.status === "open" || t.status === "stuck")
1507
1631
  );
1508
1632
 
1509
1633
  // Find overlaps
@@ -1852,7 +1976,7 @@ function cmdResumeThread() {
1852
1976
  }
1853
1977
 
1854
1978
  const memory = readJsonSafe(memoryPath, { threads: [] });
1855
- const threads = (memory.threads || []).filter((t) => t.status !== "expired");
1979
+ const threads = (memory.threads || []).filter((t) => t && t.status !== "expired");
1856
1980
 
1857
1981
  // Load attribution engine for search
1858
1982
  let attribution;
@@ -2212,7 +2336,7 @@ function cmdGc() {
2212
2336
  const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
2213
2337
  const config = readJsonSafe(configPath, { stale_days: 14 });
2214
2338
  const staleDays = config.stale_days ?? 14;
2215
- const threads = memory.threads || [];
2339
+ const threads = (memory.threads || []).filter(Boolean);
2216
2340
  const now = new Date();
2217
2341
 
2218
2342
  let tombstoned = 0;
@@ -2248,6 +2372,63 @@ function cmdGc() {
2248
2372
  print("");
2249
2373
  }
2250
2374
 
2375
+ // ──────────────────────────────────────────────
2376
+ // sync — commit and optionally push .sticky-note/ files
2377
+ // ──────────────────────────────────────────────
2378
+
2379
+ function cmdSync() {
2380
+ const args = process.argv.slice(3);
2381
+ const doPush = args.includes("--push");
2382
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
2383
+ if (!fs.existsSync(stickyDir)) {
2384
+ print(" [ERR] No .sticky-note/ directory found. Run 'npx sticky-note init' first.");
2385
+ process.exit(1);
2386
+ }
2387
+
2388
+ printBanner();
2389
+ print(" Syncing .sticky-note/ files...\n");
2390
+
2391
+ try {
2392
+ const status = execSync(
2393
+ 'git status --porcelain -- ".sticky-note/sticky-note.json" ".sticky-note/audit/" ".sticky-note/presence/"',
2394
+ { encoding: "utf-8", cwd: process.cwd() }
2395
+ ).trim();
2396
+ if (!status) {
2397
+ print(" [OK] Nothing to sync — .sticky-note/ files are clean.");
2398
+ print("");
2399
+ return;
2400
+ }
2401
+
2402
+ // Write recursion guard
2403
+ const guardPath = path.join(stickyDir, ".sticky-syncing");
2404
+ fs.writeFileSync(guardPath, process.pid.toString(), "utf-8");
2405
+
2406
+ try {
2407
+ execFileSync("git", [
2408
+ "add", "--", ".sticky-note/sticky-note.json",
2409
+ ".sticky-note/audit/", ".sticky-note/presence/"
2410
+ ], { cwd: process.cwd(), timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
2411
+
2412
+ execFileSync("git", [
2413
+ "commit", "--no-verify", "-m", "chore(sticky-note): sync thread data"
2414
+ ], { cwd: process.cwd(), timeout: 15000, stdio: ["pipe", "pipe", "pipe"] });
2415
+ print(" ✅ Committed .sticky-note/ changes.");
2416
+
2417
+ if (doPush) {
2418
+ execFileSync("git", ["push"], {
2419
+ cwd: process.cwd(), timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
2420
+ });
2421
+ print(" ✅ Pushed to remote.");
2422
+ }
2423
+ } finally {
2424
+ try { fs.unlinkSync(guardPath); } catch (_) {}
2425
+ }
2426
+ } catch (err) {
2427
+ print(" ⚠️ Sync failed: " + (err.message || err));
2428
+ }
2429
+ print("");
2430
+ }
2431
+
2251
2432
  // ──────────────────────────────────────────────
2252
2433
  // Main
2253
2434
  // ──────────────────────────────────────────────
@@ -2302,6 +2483,9 @@ async function main() {
2302
2483
  case "claim":
2303
2484
  cmdClaim();
2304
2485
  break;
2486
+ case "sync":
2487
+ cmdSync();
2488
+ break;
2305
2489
  case "--version":
2306
2490
  case "-v":
2307
2491
  print(`sticky-note v${VERSION}`);
@@ -2327,6 +2511,7 @@ async function main() {
2327
2511
  print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
2328
2512
  print(" overlap Detect file overlaps with open/stuck threads");
2329
2513
  print(" claim Claim ownership of files you're working on (--list, --clear)");
2514
+ print(" sync Commit .sticky-note/ changes to git (--push to also push)");
2330
2515
  print("");
2331
2516
  print(" Options:");
2332
2517
  print(" --version Show version");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.6.13",
3
+ "version": "2.6.15",
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": {
@@ -408,7 +408,7 @@ function main() {
408
408
  }
409
409
 
410
410
  const memory = loadJson(getMemoryPath(), { version: "2", threads: [] });
411
- const threads = memory.threads || [];
411
+ const threads = (memory.threads || []).filter(Boolean);
412
412
 
413
413
  const live = threads.filter(
414
414
  (t) => t.status === "open" || t.status === "stuck" || t.status === "closed"
@@ -88,27 +88,10 @@ function main() {
88
88
  if (existing.failed_approaches.length > 5) {
89
89
  existing.failed_approaches = existing.failed_approaches.slice(-5);
90
90
  }
91
- } else {
92
- const thread = {
93
- id: crypto.randomUUID(),
94
- user: user,
95
- project: memory.project || "",
96
- status: "stuck",
97
- branch: "",
98
- created_at: now,
99
- closed_at: null,
100
- last_activity_at: now,
101
- files_touched: [],
102
- last_note: errorMsg,
103
- narrative: "",
104
- failed_approaches: [{ description: errorMsg.substring(0, 150), error: errorMsg.substring(0, 100) }],
105
- handoff_summary: "",
106
- related_session_ids: [],
107
- tool: toolName,
108
- session_id: sessionId,
109
- };
110
- threads.push(thread);
111
91
  }
92
+ // If no thread exists for this session, skip — session-start.js creates the
93
+ // thread now. Creating standalone "stuck" threads from tool failures produced
94
+ // garbage entries (tool="Bash"/"Glob", no branch, no context).
112
95
 
113
96
  appendAuditLine({
114
97
  type: "error",
@@ -131,6 +114,7 @@ function main() {
131
114
 
132
115
  try {
133
116
  main();
134
- } catch (_) {
117
+ } catch (err) {
118
+ try { utils.logHookError("on-error", err); } catch (_) {}
135
119
  _safeExit();
136
120
  }
@@ -213,11 +213,11 @@ async function main() {
213
213
  const narrative = buildNarrativeFromAudit(auditData);
214
214
  const workType = classifyWorkType(auditData);
215
215
 
216
- const threads = memory.threads || [];
216
+ const threads = (memory.threads || []).filter(Boolean);
217
217
  let found = false;
218
218
 
219
219
  for (const thread of threads) {
220
- if (thread.session_id === sessionId && (thread.status === "open" || thread.status === "stuck")) {
220
+ if (thread.session_id === sessionId) {
221
221
  thread.last_activity_at = now;
222
222
  // Enrich with extracted data
223
223
  if (allFiles.length) {
@@ -250,35 +250,9 @@ async function main() {
250
250
  break;
251
251
  }
252
252
  }
253
-
254
- if (!found && sessionId !== "unknown") {
255
- const storedPrompts = (auditData.prompts || []).slice(0, 20).map((p) => p.substring(0, 300));
256
- const thread = {
257
- id: crypto.randomUUID(),
258
- user,
259
- project: memory.project || "",
260
- status: "closed",
261
- branch: getBranch(),
262
- created_at: now,
263
- closed_at: now,
264
- last_activity_at: now,
265
- files_touched: allFiles,
266
- last_note: reason ? reason.substring(0, 200) : (narrative || "Session stopped"),
267
- narrative,
268
- failed_approaches: [],
269
- handoff_summary: "",
270
- related_session_ids: [],
271
- tool: detectTool(hookInput),
272
- session_id: sessionId,
273
- work_type: workType,
274
- tool_calls: auditData.tools || {},
275
- prompts: storedPrompts,
276
- contributors: [user],
277
- resume_history: [],
278
- };
279
- thread.handoff_summary = buildHandoffSummary(thread, reason);
280
- threads.push(thread);
281
- }
253
+ // If no thread exists for this session, skip — session-start.js creates the
254
+ // thread now. Creating standalone threads from Stop events produced duplicates
255
+ // when session-end.js had already closed the thread (status filter mismatch).
282
256
 
283
257
  const auditEntry = {
284
258
  type: "stop",
@@ -310,4 +284,7 @@ async function main() {
310
284
  process.exit(0);
311
285
  }
312
286
 
313
- main().catch(() => safeExit());
287
+ main().catch((err) => {
288
+ try { utils.logHookError("on-stop", err); } catch (_) {}
289
+ safeExit();
290
+ });
@@ -0,0 +1,36 @@
1
+ #!/bin/sh
2
+ # Sticky Note — post-commit hook
3
+ # Auto-commits .sticky-note/ changes that weren't included in the user's commit.
4
+ # Installed by `npx sticky-note init`. Safe to remove if you prefer manual sync.
5
+
6
+ _sn_root="$(git rev-parse --show-toplevel 2>/dev/null)"
7
+ [ -z "$_sn_root" ] && exit 0
8
+
9
+ # Recursion guard — if we're already syncing, bail out
10
+ _sn_guard="$_sn_root/.sticky-note/.sticky-syncing"
11
+ [ -f "$_sn_guard" ] && exit 0
12
+
13
+ # Check for dirty .sticky-note/ files (unstaged changes that weren't in the commit)
14
+ _sn_dirty="$(git status --porcelain -- \
15
+ "$_sn_root/.sticky-note/sticky-note.json" \
16
+ "$_sn_root/.sticky-note/audit/" \
17
+ "$_sn_root/.sticky-note/presence/" 2>/dev/null)"
18
+ [ -z "$_sn_dirty" ] && exit 0
19
+
20
+ # Write guard file
21
+ echo $$ > "$_sn_guard"
22
+
23
+ # Stage and commit
24
+ git add -- \
25
+ "$_sn_root/.sticky-note/sticky-note.json" \
26
+ "$_sn_root/.sticky-note/audit/" \
27
+ "$_sn_root/.sticky-note/presence/" 2>/dev/null
28
+
29
+ git commit --no-verify -m "chore(sticky-note): sync thread data" -- \
30
+ "$_sn_root/.sticky-note/sticky-note.json" \
31
+ "$_sn_root/.sticky-note/audit/" \
32
+ "$_sn_root/.sticky-note/presence/" 2>/dev/null
33
+
34
+ # Clean up guard
35
+ rm -f "$_sn_guard" 2>/dev/null
36
+ exit 0
@@ -175,7 +175,7 @@ function _checkOverlapsAndDeny() {
175
175
  let threads;
176
176
  try {
177
177
  const data = loadJson(getMemoryPath());
178
- threads = data.threads || [];
178
+ threads = (data.threads || []).filter(Boolean);
179
179
  } catch (_) {
180
180
  return null;
181
181
  }
@@ -58,6 +58,7 @@ const {
58
58
  clearActiveResumeThreadId,
59
59
  clearInjectedSet,
60
60
  normalizeSep,
61
+ syncStickyNote,
61
62
  } = utils;
62
63
 
63
64
  // ── Constants ─────────────────────────────────────────────
@@ -765,7 +766,7 @@ function main() {
765
766
  filesTouched = [...new Set([...filesTouched, ...gitFiles])];
766
767
  }
767
768
 
768
- const threads = memory.threads || [];
769
+ const threads = (memory.threads || []).filter(Boolean);
769
770
  memory.threads = threads;
770
771
  let existing = null;
771
772
 
@@ -935,6 +936,16 @@ function main() {
935
936
  }
936
937
  saveMemoryMerged(memoryPath, memory);
937
938
 
939
+ // Auto-sync: commit (and optionally push) .sticky-note/ changes
940
+ const config = loadJson(getConfigPath(), {});
941
+ if (config.auto_sync !== false) {
942
+ try {
943
+ syncStickyNote({ push: config.auto_push === true });
944
+ } catch (_) {
945
+ // Non-fatal — sync failure shouldn't block session-end
946
+ }
947
+ }
948
+
938
949
  const fileCount = filesTouched.length;
939
950
  const commitCount = commitShas.length;
940
951
  const statusLabel = isCopilotCli ? "updated" : "closed";
@@ -949,6 +960,7 @@ function main() {
949
960
 
950
961
  try {
951
962
  main();
952
- } catch (_) {
963
+ } catch (err) {
964
+ try { utils.logHookError("session-end", err); } catch (_) {}
953
965
  _safeExit();
954
966
  }
@@ -50,6 +50,7 @@ const {
50
50
  saveMemoryMerged,
51
51
  appendAuditLine,
52
52
  getUser,
53
+ getBranch,
53
54
  getSessionId,
54
55
  getResumeThreadId,
55
56
  findThreadById,
@@ -68,7 +69,7 @@ const {
68
69
  function ageStaleThreads(memory, staleDays) {
69
70
  const now = Date.now();
70
71
  let changed = false;
71
- for (const thread of memory.threads || []) {
72
+ for (const thread of (memory.threads || []).filter(Boolean)) {
72
73
  if (thread.status !== "open" && thread.status !== "stuck") continue;
73
74
  const tsField =
74
75
  thread.last_activity_at || thread.updated_at || thread.created_at || "";
@@ -95,7 +96,7 @@ function ageStaleThreads(memory, staleDays) {
95
96
  function autoCloseCopilotCliThreads(memory, autoCloseHours) {
96
97
  const now = Date.now();
97
98
  let changed = false;
98
- for (const thread of memory.threads || []) {
99
+ for (const thread of (memory.threads || []).filter(Boolean)) {
99
100
  if (thread.status !== "open") continue;
100
101
  if (thread.tool !== "copilot-cli") continue;
101
102
  const tsField =
@@ -468,7 +469,7 @@ function main() {
468
469
  const resumeThreadId = getResumeThreadId();
469
470
  let resumedThread = null;
470
471
  if (resumeThreadId) {
471
- const threads = memory.threads || [];
472
+ const threads = (memory.threads || []).filter(Boolean);
472
473
  resumedThread = findThreadById(threads, resumeThreadId);
473
474
  if (resumedThread) {
474
475
  resumedThread.status = "open";
@@ -493,14 +494,54 @@ function main() {
493
494
  }
494
495
  }
495
496
 
497
+ // ── Create "open" thread for this session ──────────────
498
+ // Ensures a thread exists even if SessionEnd never fires (Ctrl+C, crash).
499
+ // session-end.js, on-stop.js, and on-error.js will find and update this
500
+ // thread by session_id. Copilot CLI fires SessionStart per-turn, so skip
501
+ // creation on subsequent turns (thread already exists from the first turn).
502
+ if (!resumedThread) {
503
+ const threads = (memory.threads || []).filter(Boolean);
504
+ memory.threads = threads;
505
+ const existingForSession = threads.find((t) => t.session_id === sessionId);
506
+ if (!existingForSession) {
507
+ const user = getUser();
508
+ const branch = getBranch();
509
+ threads.push({
510
+ id: crypto.randomUUID(),
511
+ user,
512
+ project: memory.project || "",
513
+ status: "open",
514
+ branch,
515
+ created_at: new Date().toISOString(),
516
+ closed_at: null,
517
+ last_activity_at: new Date().toISOString(),
518
+ files_touched: [],
519
+ last_note: "",
520
+ narrative: "",
521
+ failed_approaches: [],
522
+ handoff_summary: "",
523
+ related_session_ids: [],
524
+ resume_chain: [],
525
+ tool: aiTool,
526
+ session_id: sessionId,
527
+ work_type: "",
528
+ activities: [],
529
+ tool_calls: {},
530
+ prompts: [],
531
+ contributors: [user],
532
+ resume_history: [],
533
+ });
534
+ }
535
+ }
536
+
496
537
  // ── Build context pieces ──────────────────────────────
497
- const threadResult = formatThreadsForInjection(memory.threads || []);
538
+ const threadResult = formatThreadsForInjection((memory.threads || []).filter(Boolean));
498
539
  const threadContext = threadResult.text;
499
540
  const configContext = formatConfigForInjection(config);
500
541
  const presenceData = loadAllPresence();
501
- const presenceContext = formatPresence(presenceData, memory.threads || []);
542
+ const presenceContext = formatPresence(presenceData, (memory.threads || []).filter(Boolean));
502
543
  const overlapContext = formatOverlapWarnings(
503
- memory.threads || [], getUser(), memory
544
+ (memory.threads || []).filter(Boolean), getUser(), memory
504
545
  );
505
546
 
506
547
  // V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject.
@@ -618,6 +659,7 @@ function main() {
618
659
 
619
660
  try {
620
661
  main();
621
- } catch (_) {
662
+ } catch (err) {
663
+ try { utils.logHookError("session-start", err); } catch (_) {}
622
664
  _safeExit();
623
665
  }
@@ -165,7 +165,7 @@ function resolveViaHeuristic(sha, file) {
165
165
  const results = [];
166
166
  const normalizedFile = file ? normalizeSep(file) : null;
167
167
 
168
- for (const thread of memory.threads || []) {
168
+ for (const thread of (memory.threads || []).filter(Boolean)) {
169
169
  if (thread.status === "expired") continue;
170
170
  if (!normalizedFile) continue;
171
171
 
@@ -230,7 +230,7 @@ function resolveThreads(attributionEntries) {
230
230
  const memory = utils.loadJson(utils.getMemoryPath(), { version: "2", threads: [] });
231
231
  const threads = [];
232
232
 
233
- for (const thread of memory.threads || []) {
233
+ for (const thread of (memory.threads || []).filter(Boolean)) {
234
234
  if (thread.status === "expired") continue;
235
235
  if (sessionIds.has(thread.session_id) || sessionIds.has(thread.id)) {
236
236
  threads.push(thread);
@@ -42,6 +42,21 @@ function _stickyDir() {
42
42
  return path.join(scriptDir, "..", "..", ".sticky-note");
43
43
  }
44
44
 
45
+ // ── Error logging ─────────────────────────────────────────
46
+
47
+ function logHookError(hookName, error) {
48
+ try {
49
+ const logPath = path.join(_stickyDir(), ".sticky-errors.log");
50
+ const ts = new Date().toISOString();
51
+ const msg = error instanceof Error ? error.stack || error.message : String(error);
52
+ const line = `[${ts}] ${hookName}: ${msg}\n`;
53
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
54
+ fs.appendFileSync(logPath, line, "utf-8");
55
+ } catch (_) {
56
+ // Last resort — can't even log the error
57
+ }
58
+ }
59
+
45
60
  const _paths = {
46
61
  memory: "sticky-note.json",
47
62
  config: "sticky-note-config.json",
@@ -125,6 +140,73 @@ function clearSessionFile() {
125
140
  }
126
141
  }
127
142
 
143
+ // ── Git sync (auto-commit/push .sticky-note/ files) ──────
144
+
145
+ function _gitRepoRoot() {
146
+ try {
147
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
148
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
149
+ }).trim();
150
+ } catch (_) {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Stage, commit, and optionally push .sticky-note/ files.
157
+ * Returns { committed: bool, pushed: bool, skipped: bool, error?: string }
158
+ */
159
+ function syncStickyNote(opts) {
160
+ const push = opts && opts.push;
161
+ const result = { committed: false, pushed: false, skipped: false };
162
+ try {
163
+ const root = _gitRepoRoot();
164
+ if (!root) { result.skipped = true; return result; }
165
+
166
+ const syncGuard = path.join(_stickyDir(), ".sticky-syncing");
167
+ if (fs.existsSync(syncGuard)) { result.skipped = true; return result; }
168
+
169
+ // Check for dirty .sticky-note/ files (staged or unstaged)
170
+ const status = execFileSync("git", [
171
+ "status", "--porcelain", "--", ".sticky-note/sticky-note.json",
172
+ ".sticky-note/audit/", ".sticky-note/presence/"
173
+ ], { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
174
+
175
+ if (!status) { result.skipped = true; return result; }
176
+
177
+ // Write guard to prevent recursive post-commit triggers
178
+ fs.writeFileSync(syncGuard, process.pid.toString(), "utf-8");
179
+
180
+ try {
181
+ // Stage the relevant files
182
+ execFileSync("git", [
183
+ "add", "--", ".sticky-note/sticky-note.json",
184
+ ".sticky-note/audit/", ".sticky-note/presence/"
185
+ ], { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
186
+
187
+ // Commit only what was staged
188
+ execFileSync("git", [
189
+ "commit", "-m", "chore(sticky-note): sync thread data", "--no-verify"
190
+ ], { timeout: 15000, stdio: ["pipe", "pipe", "pipe"] });
191
+ result.committed = true;
192
+
193
+ // Push if requested
194
+ if (push) {
195
+ execFileSync("git", ["push"], {
196
+ timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
197
+ });
198
+ result.pushed = true;
199
+ }
200
+ } finally {
201
+ try { fs.unlinkSync(syncGuard); } catch (_) {}
202
+ }
203
+ } catch (err) {
204
+ result.error = err.message;
205
+ logHookError("syncStickyNote", err);
206
+ }
207
+ return result;
208
+ }
209
+
128
210
  // ── Git HEAD snapshot (for files_touched fallback) ────────
129
211
 
130
212
  function saveHeadSha() {
@@ -214,8 +296,8 @@ function saveJson(filePath, data) {
214
296
  function saveMemoryMerged(memoryPath, memory) {
215
297
  try {
216
298
  const onDisk = loadJson(memoryPath, { threads: [] });
217
- const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads : [];
218
- const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads : [];
299
+ const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads.filter(Boolean) : [];
300
+ const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads.filter(Boolean) : [];
219
301
  const inMemoryIds = new Set(inMemoryThreads.map((t) => t.id));
220
302
 
221
303
  for (const diskThread of onDiskThreads) {
@@ -671,6 +753,7 @@ module.exports = {
671
753
  ERROR_PATTERNS,
672
754
  RETRY_PATTERNS,
673
755
  FILE_PATH_PATTERN,
756
+ logHookError,
674
757
  getMemoryPath,
675
758
  getConfigPath,
676
759
  getAuditPath,
@@ -731,5 +814,7 @@ module.exports = {
731
814
  markOverlapDismissed,
732
815
  // V2.5: cross-platform path helper
733
816
  normalizeSep,
817
+ // V2.7: auto-sync
818
+ syncStickyNote,
734
819
  };
735
820
  // tier1 test
@@ -37,12 +37,16 @@ try {
37
37
 
38
38
  const {
39
39
  getConfigPath,
40
+ getMemoryPath,
40
41
  getUserPresencePath,
41
42
  loadJson,
42
43
  saveJson,
44
+ saveMemoryMerged,
43
45
  appendAuditLine,
44
46
  getUser,
47
+ getBranch,
45
48
  getSessionId,
49
+ normalizeSep,
46
50
  } = utils;
47
51
 
48
52
  const WRITE_TOOLS = new Set([
@@ -302,6 +306,35 @@ function main() {
302
306
  }
303
307
  }
304
308
 
309
+ // ── Progressive thread update ────────────────────────────
310
+ // Update the session's open thread with files_touched and branch changes
311
+ // so data survives even if SessionEnd never fires (Ctrl+C, crash).
312
+ if (filePath) {
313
+ try {
314
+ const memoryPath = getMemoryPath();
315
+ const memory = loadJson(memoryPath, { version: "2", project: "", threads: [] });
316
+ const threads = (memory.threads || []).filter(Boolean);
317
+ const sessionThread = threads.find((t) => t.session_id === sessionId);
318
+ if (sessionThread) {
319
+ const normalized = normalizeSep(filePath);
320
+ const touched = sessionThread.files_touched || [];
321
+ if (!touched.includes(normalized)) {
322
+ touched.push(normalized);
323
+ sessionThread.files_touched = touched;
324
+ }
325
+ // Update branch if it changed mid-session
326
+ const currentBranch = getBranch();
327
+ if (currentBranch && currentBranch !== sessionThread.branch) {
328
+ sessionThread.branch = currentBranch;
329
+ }
330
+ sessionThread.last_activity_at = now;
331
+ saveMemoryMerged(memoryPath, memory);
332
+ }
333
+ } catch (_) {
334
+ // Best-effort — don't break the hook if thread update fails
335
+ }
336
+ }
337
+
305
338
  // Build status message for transparency
306
339
  const linePart = lineRanges && lineRanges.length > 0 ? ` (lines ${lineRanges.map(r => `${r.start}-${r.end}`).join(", ")})` : "";
307
340
  const statusMsg = `[STICKY-NOTE] Tracked ${toolName}${filePath ? " on " + filePath : ""}${linePart}`;
@@ -316,6 +349,7 @@ function main() {
316
349
 
317
350
  try {
318
351
  main();
319
- } catch (_) {
352
+ } catch (err) {
353
+ try { utils.logHookError("track-work", err); } catch (_) {}
320
354
  safeExit();
321
355
  }
@@ -2,6 +2,8 @@
2
2
  "stale_days": 14,
3
3
  "copilot_cli_auto_close_hours": 24,
4
4
  "inject_token_budget": 1000,
5
+ "auto_sync": true,
6
+ "auto_push": false,
5
7
  "mcp_servers": [],
6
8
  "skills": [],
7
9
  "conventions": [],