sticky-note-cli 2.6.14 → 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");
@@ -2246,6 +2372,63 @@ function cmdGc() {
2246
2372
  print("");
2247
2373
  }
2248
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
+
2249
2432
  // ──────────────────────────────────────────────
2250
2433
  // Main
2251
2434
  // ──────────────────────────────────────────────
@@ -2300,6 +2483,9 @@ async function main() {
2300
2483
  case "claim":
2301
2484
  cmdClaim();
2302
2485
  break;
2486
+ case "sync":
2487
+ cmdSync();
2488
+ break;
2303
2489
  case "--version":
2304
2490
  case "-v":
2305
2491
  print(`sticky-note v${VERSION}`);
@@ -2325,6 +2511,7 @@ async function main() {
2325
2511
  print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
2326
2512
  print(" overlap Detect file overlaps with open/stuck threads");
2327
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)");
2328
2515
  print("");
2329
2516
  print(" Options:");
2330
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.14",
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": {
@@ -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": [],