sticky-note-cli 2.6.14 → 2.6.16

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,117 @@ 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
+ ' [ -f "$_sn_root/.sticky-note/merge-driver.js" ] && git add "$_sn_root/.sticky-note/merge-driver.js" 2>/dev/null || true',
117
+ ' [ -d "$_sn_root/.sticky-note/audit" ] && git add "$_sn_root/.sticky-note/audit/" 2>/dev/null || true',
118
+ ' [ -d "$_sn_root/.sticky-note/presence" ] && git add "$_sn_root/.sticky-note/presence/" 2>/dev/null || true',
119
+ 'fi',
120
+ STICKY_PRE_COMMIT_END,
121
+ ].join("\n");
122
+
123
+ if (fs.existsSync(dest)) {
124
+ let existing = fs.readFileSync(dest, "utf-8");
125
+ const startIdx = existing.indexOf(STICKY_PRE_COMMIT_MARKER);
126
+ const endIdx = existing.indexOf(STICKY_PRE_COMMIT_END);
127
+
128
+ if (startIdx !== -1 && endIdx !== -1) {
129
+ // Update our existing block in-place
130
+ existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_PRE_COMMIT_END.length);
131
+ } else {
132
+ // Prepend our block after the shebang (or at the top)
133
+ const shebangMatch = existing.match(/^#!.*\n/);
134
+ if (shebangMatch) {
135
+ existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
136
+ } else {
137
+ existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
138
+ }
139
+ }
140
+ fs.writeFileSync(dest, existing, "utf-8");
141
+ } else {
142
+ // No existing hook — create a fresh one
143
+ fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
144
+ }
145
+ makeExecutable(dest);
146
+ return true;
147
+ } catch (err) {
148
+ debugLog("installPreCommitHook failed: " + (err.message || err));
149
+ return false;
150
+ }
151
+ }
152
+
153
+ const STICKY_POST_COMMIT_MARKER = "# [sticky-note:auto-sync]";
154
+ const STICKY_POST_COMMIT_END = "# [/sticky-note:auto-sync]";
155
+
156
+ /**
157
+ * Install a post-commit git hook that auto-commits leftover .sticky-note/ changes.
158
+ * Uses the same prepend/update strategy as installPreCommitHook.
159
+ */
160
+ function installPostCommitHook() {
161
+ try {
162
+ const gitDir = path.join(process.cwd(), ".git");
163
+ if (!fs.existsSync(gitDir)) return false;
164
+ const hooksDir = path.join(gitDir, "hooks");
165
+ mkdirSafe(hooksDir);
166
+ const dest = path.join(hooksDir, "post-commit");
167
+ const src = path.join(TEMPLATES_DIR, "hooks", "post-commit");
168
+ const template = fs.existsSync(src) ? fs.readFileSync(src, "utf-8") : "";
169
+
170
+ // Extract just the body (skip shebang + first comment line from template)
171
+ const bodyLines = template.split("\n").filter(l => !l.startsWith("#!/"));
172
+ const stickyBlock = [
173
+ STICKY_POST_COMMIT_MARKER,
174
+ ...bodyLines,
175
+ STICKY_POST_COMMIT_END,
176
+ ].join("\n");
177
+
178
+ if (fs.existsSync(dest)) {
179
+ let existing = fs.readFileSync(dest, "utf-8");
180
+ const startIdx = existing.indexOf(STICKY_POST_COMMIT_MARKER);
181
+ const endIdx = existing.indexOf(STICKY_POST_COMMIT_END);
182
+
183
+ if (startIdx !== -1 && endIdx !== -1) {
184
+ existing = existing.slice(0, startIdx) + stickyBlock + existing.slice(endIdx + STICKY_POST_COMMIT_END.length);
185
+ } else {
186
+ const shebangMatch = existing.match(/^#!.*\n/);
187
+ if (shebangMatch) {
188
+ existing = shebangMatch[0] + stickyBlock + "\n" + existing.slice(shebangMatch[0].length);
189
+ } else {
190
+ existing = "#!/bin/sh\n" + stickyBlock + "\n" + existing;
191
+ }
192
+ }
193
+ fs.writeFileSync(dest, existing, "utf-8");
194
+ } else {
195
+ fs.writeFileSync(dest, "#!/bin/sh\n" + stickyBlock + "\n", "utf-8");
196
+ }
197
+ makeExecutable(dest);
198
+ return true;
199
+ } catch (err) {
200
+ debugLog("installPostCommitHook failed: " + (err.message || err));
201
+ return false;
202
+ }
203
+ }
204
+
94
205
  function installGitHook(hookName) {
95
206
  try {
96
207
  const gitDir = path.join(process.cwd(), ".git");
@@ -640,6 +751,16 @@ async function cmdInit() {
640
751
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
641
752
  }
642
753
 
754
+ // Install pre-commit hook (auto-stage .sticky-note/ files)
755
+ if (installPreCommitHook()) {
756
+ print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
757
+ }
758
+
759
+ // Install post-commit hook (auto-commit leftover .sticky-note/ changes)
760
+ if (installPostCommitHook()) {
761
+ print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
762
+ }
763
+
643
764
  // Force-add .claude/hooks/ and .claude/settings.json so git tracks them
644
765
  // (needed if .claude/ was previously in .gitignore)
645
766
  try {
@@ -710,6 +831,12 @@ function cmdUpdate() {
710
831
  if (installGitHook("post-rewrite")) {
711
832
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
712
833
  }
834
+ if (installPreCommitHook()) {
835
+ print(" [OK] .git/hooks/pre-commit (auto-stage .sticky-note/ files)");
836
+ }
837
+ if (installPostCommitHook()) {
838
+ print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
839
+ }
713
840
 
714
841
  // Update hook_version in config
715
842
  const configPath = path.join(stickyDir, "sticky-note-config.json");
@@ -2246,6 +2373,63 @@ function cmdGc() {
2246
2373
  print("");
2247
2374
  }
2248
2375
 
2376
+ // ──────────────────────────────────────────────
2377
+ // sync — commit and optionally push .sticky-note/ files
2378
+ // ──────────────────────────────────────────────
2379
+
2380
+ function cmdSync() {
2381
+ const args = process.argv.slice(3);
2382
+ const doPush = args.includes("--push");
2383
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
2384
+ if (!fs.existsSync(stickyDir)) {
2385
+ print(" [ERR] No .sticky-note/ directory found. Run 'npx sticky-note init' first.");
2386
+ process.exit(1);
2387
+ }
2388
+
2389
+ printBanner();
2390
+ print(" Syncing .sticky-note/ files...\n");
2391
+
2392
+ try {
2393
+ const status = execSync(
2394
+ 'git status --porcelain -- ".sticky-note/sticky-note.json" ".sticky-note/audit/" ".sticky-note/presence/"',
2395
+ { encoding: "utf-8", cwd: process.cwd() }
2396
+ ).trim();
2397
+ if (!status) {
2398
+ print(" [OK] Nothing to sync — .sticky-note/ files are clean.");
2399
+ print("");
2400
+ return;
2401
+ }
2402
+
2403
+ // Write recursion guard
2404
+ const guardPath = path.join(stickyDir, ".sticky-syncing");
2405
+ fs.writeFileSync(guardPath, process.pid.toString(), "utf-8");
2406
+
2407
+ try {
2408
+ execFileSync("git", [
2409
+ "add", "--", ".sticky-note/sticky-note.json",
2410
+ ".sticky-note/audit/", ".sticky-note/presence/"
2411
+ ], { cwd: process.cwd(), timeout: 10000, stdio: ["pipe", "pipe", "pipe"] });
2412
+
2413
+ execFileSync("git", [
2414
+ "commit", "--no-verify", "-m", "chore(sticky-note): sync thread data"
2415
+ ], { cwd: process.cwd(), timeout: 15000, stdio: ["pipe", "pipe", "pipe"] });
2416
+ print(" ✅ Committed .sticky-note/ changes.");
2417
+
2418
+ if (doPush) {
2419
+ execFileSync("git", ["push"], {
2420
+ cwd: process.cwd(), timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
2421
+ });
2422
+ print(" ✅ Pushed to remote.");
2423
+ }
2424
+ } finally {
2425
+ try { fs.unlinkSync(guardPath); } catch (_) {}
2426
+ }
2427
+ } catch (err) {
2428
+ print(" ⚠️ Sync failed: " + (err.message || err));
2429
+ }
2430
+ print("");
2431
+ }
2432
+
2249
2433
  // ──────────────────────────────────────────────
2250
2434
  // Main
2251
2435
  // ──────────────────────────────────────────────
@@ -2300,6 +2484,9 @@ async function main() {
2300
2484
  case "claim":
2301
2485
  cmdClaim();
2302
2486
  break;
2487
+ case "sync":
2488
+ cmdSync();
2489
+ break;
2303
2490
  case "--version":
2304
2491
  case "-v":
2305
2492
  print(`sticky-note v${VERSION}`);
@@ -2325,6 +2512,7 @@ async function main() {
2325
2512
  print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
2326
2513
  print(" overlap Detect file overlaps with open/stuck threads");
2327
2514
  print(" claim Claim ownership of files you're working on (--list, --clear)");
2515
+ print(" sync Commit .sticky-note/ changes to git (--push to also push)");
2328
2516
  print("");
2329
2517
  print(" Options:");
2330
2518
  print(" --version Show version");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.6.14",
3
+ "version": "2.6.16",
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": {
@@ -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
  }
@@ -217,7 +217,7 @@ async function main() {
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
@@ -58,6 +58,7 @@ const {
58
58
  clearActiveResumeThreadId,
59
59
  clearInjectedSet,
60
60
  normalizeSep,
61
+ syncStickyNote,
61
62
  } = utils;
62
63
 
63
64
  // ── Constants ─────────────────────────────────────────────
@@ -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,
@@ -493,6 +494,46 @@ 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
538
  const threadResult = formatThreadsForInjection((memory.threads || []).filter(Boolean));
498
539
  const threadContext = threadResult.text;
@@ -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
  }
@@ -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() {
@@ -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": [],