sticky-note-cli 2.6.12 → 2.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,7 +42,14 @@ their next session by relevance.
42
42
 
43
43
  ---
44
44
 
45
- ## What's New in V2.5
45
+ ## What's New in V2.6
46
+
47
+ - **Overlap detection**: Warns when you're working on files another teammate has open/stuck threads on. Three delivery channels: injected context, stderr banner, and preToolUse deny (Copilot CLI)
48
+ - **File claiming**: `npx sticky-note-cli claim src/auth.ts "refactoring auth flow"` — declare intent to work on files
49
+ - **Auto-close Copilot CLI threads**: Threads from Copilot CLI sessions auto-close after configurable inactivity (default 24h), since Copilot CLI has no session-end signal
50
+ - **PID-keyed session isolation**: Concurrent Copilot CLI sessions get independent overlap dedup via `COPILOT_LOADER_PID`
51
+
52
+ ### What's New in V2.5
46
53
 
47
54
  - **Smart injection (two-tier)**: Stuck threads injected eagerly at session start; all other threads injected lazily when you first touch a file they authored — via built-in git blame attribution
48
55
  - **Thread resume (local)**: `npx sticky-note-cli resume-thread --query "auth fix" --user alice` — natural language thread discovery with text similarity + file attribution ranking
@@ -148,7 +155,7 @@ injects the top 3–5 (under token budget) as additional context.
148
155
 
149
156
  | Signal | Weight | Description |
150
157
  |--------|--------|-------------|
151
- | File overlap | 3 | Thread files match your recent git changes |
158
+ | File overlap | 3 per file (max 5) | Thread files match your recent git changes |
152
159
  | Branch match | 2 | Thread is on your current branch |
153
160
  | Recency | 2 | Decays 0.2 per day from last activity |
154
161
  | Stuck status | +2 | Boost for threads marked stuck |
@@ -321,6 +328,8 @@ npx sticky-note-cli resume --clear # Cancel active resume
321
328
  npx sticky-note-cli resume-thread # Smart resume: --query, --user, --file (V2.5)
322
329
  npx sticky-note-cli audit # Query merged audit trail (all users)
323
330
  npx sticky-note-cli who # Show active and recent team members
331
+ npx sticky-note-cli overlap # Detect file overlaps with teammates (V2.6)
332
+ npx sticky-note-cli claim <files> # Declare intent to work on files (V2.6)
324
333
  npx sticky-note-cli switch <branch> # Safe branch switch (auto-stashes data)
325
334
  npx sticky-note-cli gc # Tombstone expired threads
326
335
  npx sticky-note-cli reset # Wipe all threads (--force, --keep-audit)
@@ -349,16 +358,20 @@ Edit `.sticky-note/sticky-note-config.json`:
349
358
  ```json
350
359
  {
351
360
  "stale_days": 14,
361
+ "copilot_cli_auto_close_hours": 24,
362
+ "inject_token_budget": 1000,
352
363
  "mcp_servers": [],
353
364
  "skills": [],
354
365
  "conventions": ["Use TypeScript strict mode", "Test before commit"],
355
- "hook_version": "2.5.0"
366
+ "hook_version": "2.6.12"
356
367
  }
357
368
  ```
358
369
 
359
370
  | Key | Description | Default |
360
371
  |----------------|------------------------------------------|---------|
361
372
  | `stale_days` | Days before threads expire + gc cleanup | `14` |
373
+ | `copilot_cli_auto_close_hours` | Hours before idle Copilot CLI threads auto-close | `24` |
374
+ | `inject_token_budget` | Max tokens for per-prompt thread injection | `1000` |
362
375
  | `mcp_servers` | Shared MCP server references | `[]` |
363
376
  | `skills` | Team skill definitions | `[]` |
364
377
  | `conventions` | Team coding conventions (injected) | `[]` |
package/bin/cli.js CHANGED
@@ -526,12 +526,29 @@ async function cmdInit() {
526
526
  gitignoreContent = fs.readFileSync(gitignorePath, "utf-8");
527
527
  }
528
528
 
529
+ // Fix overly broad .claude/ ignore that blocks hook sharing
530
+ let gitignoreChanged = false;
531
+ if (gitignoreContent) {
532
+ const lines = gitignoreContent.split(/\r?\n/);
533
+ const filtered = lines.filter((line) => {
534
+ const trimmed = line.trim();
535
+ // Remove lines that ignore all of .claude/ (but keep targeted entries)
536
+ return trimmed !== ".claude/" && trimmed !== ".claude";
537
+ });
538
+ if (filtered.length !== lines.length) {
539
+ gitignoreContent = filtered.join("\n");
540
+ fs.writeFileSync(gitignorePath, gitignoreContent);
541
+ gitignoreChanged = true;
542
+ print(" [OK] .gitignore: removed broad .claude/ ignore (hooks will be shared via git)");
543
+ }
544
+ }
545
+
529
546
  const missingEntries = ignoreAdditions.filter((e) => !gitignoreContent.includes(e.trim()));
530
547
  if (missingEntries.length > 0) {
531
548
  const block = "\n# Sticky Note\n" + missingEntries.join("\n") + "\n";
532
549
  fs.appendFileSync(gitignorePath, block);
533
550
  print(" [OK] .gitignore updated");
534
- } else {
551
+ } else if (!gitignoreChanged) {
535
552
  print(" ⏭️ .gitignore already configured");
536
553
  }
537
554
 
@@ -623,6 +640,17 @@ async function cmdInit() {
623
640
  print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
624
641
  }
625
642
 
643
+ // Force-add .claude/hooks/ and .claude/settings.json so git tracks them
644
+ // (needed if .claude/ was previously in .gitignore)
645
+ try {
646
+ execFileSync("git", ["add", "--force", ".claude/hooks/", ".claude/settings.json"], {
647
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
648
+ });
649
+ print(" [OK] .claude/hooks/ and .claude/settings.json staged for git");
650
+ } catch (err) {
651
+ debugLog("git add --force .claude/ failed: " + (err.message || err));
652
+ }
653
+
626
654
  // Done!
627
655
  print("\n ✨ Sticky Note V2 initialized!\n");
628
656
  print(" Next steps:");
@@ -699,6 +727,44 @@ function cmdUpdate() {
699
727
 
700
728
  // Upgrade .gitattributes from merge=union to custom driver
701
729
  const gitattrsPath = path.join(process.cwd(), ".gitattributes");
730
+
731
+ // Fix overly broad .claude/ gitignore that blocks hook sharing
732
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
733
+ if (fs.existsSync(gitignorePath)) {
734
+ let gitignoreContent = fs.readFileSync(gitignorePath, "utf-8");
735
+ const lines = gitignoreContent.split(/\r?\n/);
736
+ const filtered = lines.filter((line) => {
737
+ const trimmed = line.trim();
738
+ return trimmed !== ".claude/" && trimmed !== ".claude";
739
+ });
740
+ if (filtered.length !== lines.length) {
741
+ fs.writeFileSync(gitignorePath, filtered.join("\n"));
742
+ print(" [OK] .gitignore: removed broad .claude/ ignore (hooks will be shared via git)");
743
+
744
+ // Ensure targeted ignores are present
745
+ const ignoreAdditions = fs
746
+ .readFileSync(path.join(TEMPLATES_DIR, "gitignore-additions.txt"), "utf-8")
747
+ .trim()
748
+ .split(/\r?\n/)
749
+ .filter((l) => l.trim() && !l.startsWith("#"));
750
+ const updatedContent = fs.readFileSync(gitignorePath, "utf-8");
751
+ const missing = ignoreAdditions.filter((e) => !updatedContent.includes(e.trim()));
752
+ if (missing.length > 0) {
753
+ fs.appendFileSync(gitignorePath, "\n# Sticky Note\n" + missing.join("\n") + "\n");
754
+ }
755
+
756
+ // Force-add previously ignored hook files so git starts tracking them
757
+ try {
758
+ execFileSync("git", ["add", "--force", ".claude/hooks/", ".claude/settings.json"], {
759
+ cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"],
760
+ });
761
+ print(" [OK] git add --force .claude/hooks/ .claude/settings.json");
762
+ } catch (err) {
763
+ debugLog("git add --force .claude/ failed: " + (err.message || err));
764
+ }
765
+ }
766
+ }
767
+
702
768
  if (fs.existsSync(gitattrsPath)) {
703
769
  let gitattrsContent = fs.readFileSync(gitattrsPath, "utf-8");
704
770
  const oldRule = ".sticky-note/sticky-note.json merge=union";
@@ -788,7 +854,7 @@ function cmdStatus() {
788
854
  if (fs.existsSync(memoryPath)) {
789
855
  try {
790
856
  const memory = JSON.parse(fs.readFileSync(memoryPath, "utf-8"));
791
- const threads = memory.threads || [];
857
+ const threads = (memory.threads || []).filter(Boolean);
792
858
  const schemaVersion = memory.version || "2";
793
859
 
794
860
  const openThreads = threads.filter((t) => t.status === "open").length;
@@ -933,7 +999,7 @@ function cmdThreads() {
933
999
  }
934
1000
 
935
1001
  const memory = readJsonSafe(memoryPath, { threads: [] });
936
- const threads = memory.threads || [];
1002
+ const threads = (memory.threads || []).filter(Boolean);
937
1003
 
938
1004
  // Filter: show open, stuck, closed (not expired)
939
1005
  const live = threads.filter((t) => ["open", "stuck", "closed", "stale"].includes(t.status));
@@ -991,7 +1057,7 @@ function cmdResume() {
991
1057
  // resume --list: show resumable threads
992
1058
  if (args.includes("--list") || args.length === 0) {
993
1059
  const memory = readJsonSafe(memoryPath, { threads: [] });
994
- const threads = memory.threads || [];
1060
+ const threads = (memory.threads || []).filter(Boolean);
995
1061
  const resumable = threads.filter((t) =>
996
1062
  ["closed", "stuck", "open", "stale"].includes(t.status)
997
1063
  );
@@ -1032,9 +1098,7 @@ function cmdResume() {
1032
1098
  // resume <thread-id>: set the resume signal
1033
1099
  const threadId = args[0];
1034
1100
  const memory = readJsonSafe(memoryPath, { threads: [] });
1035
- const threads = memory.threads || [];
1036
-
1037
- // Support partial ID matching (first 8 chars)
1101
+ const threads = (memory.threads || []).filter(Boolean);
1038
1102
  const match = threads.find((t) =>
1039
1103
  t.id === threadId || t.id.startsWith(threadId)
1040
1104
  );
@@ -1130,7 +1194,7 @@ function cmdReset() {
1130
1194
  const force = args.includes("--force");
1131
1195
 
1132
1196
  const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
1133
- const threadCount = (memory.threads || []).length;
1197
+ const threadCount = (memory.threads || []).filter(Boolean).length;
1134
1198
 
1135
1199
  if (threadCount === 0) {
1136
1200
  print(" Nothing to reset -- 0 threads.");
@@ -1437,7 +1501,7 @@ function cmdOverlap() {
1437
1501
  const isMe = (user) => currentUserNames.has(user || "");
1438
1502
  const memory = readJsonSafe(memoryPath, { threads: [] });
1439
1503
  const threads = (memory.threads || []).filter(
1440
- (t) => t.status === "open" || t.status === "stuck"
1504
+ (t) => t && (t.status === "open" || t.status === "stuck")
1441
1505
  );
1442
1506
 
1443
1507
  // Find overlaps
@@ -1786,7 +1850,7 @@ function cmdResumeThread() {
1786
1850
  }
1787
1851
 
1788
1852
  const memory = readJsonSafe(memoryPath, { threads: [] });
1789
- const threads = (memory.threads || []).filter((t) => t.status !== "expired");
1853
+ const threads = (memory.threads || []).filter((t) => t && t.status !== "expired");
1790
1854
 
1791
1855
  // Load attribution engine for search
1792
1856
  let attribution;
@@ -2146,7 +2210,7 @@ function cmdGc() {
2146
2210
  const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
2147
2211
  const config = readJsonSafe(configPath, { stale_days: 14 });
2148
2212
  const staleDays = config.stale_days ?? 14;
2149
- const threads = memory.threads || [];
2213
+ const threads = (memory.threads || []).filter(Boolean);
2150
2214
  const now = new Date();
2151
2215
 
2152
2216
  let tombstoned = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.6.12",
3
+ "version": "2.6.14",
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"
@@ -213,7 +213,7 @@ 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) {
@@ -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
  }
@@ -765,7 +765,7 @@ function main() {
765
765
  filesTouched = [...new Set([...filesTouched, ...gitFiles])];
766
766
  }
767
767
 
768
- const threads = memory.threads || [];
768
+ const threads = (memory.threads || []).filter(Boolean);
769
769
  memory.threads = threads;
770
770
  let existing = null;
771
771
 
@@ -68,7 +68,7 @@ const {
68
68
  function ageStaleThreads(memory, staleDays) {
69
69
  const now = Date.now();
70
70
  let changed = false;
71
- for (const thread of memory.threads || []) {
71
+ for (const thread of (memory.threads || []).filter(Boolean)) {
72
72
  if (thread.status !== "open" && thread.status !== "stuck") continue;
73
73
  const tsField =
74
74
  thread.last_activity_at || thread.updated_at || thread.created_at || "";
@@ -95,7 +95,7 @@ function ageStaleThreads(memory, staleDays) {
95
95
  function autoCloseCopilotCliThreads(memory, autoCloseHours) {
96
96
  const now = Date.now();
97
97
  let changed = false;
98
- for (const thread of memory.threads || []) {
98
+ for (const thread of (memory.threads || []).filter(Boolean)) {
99
99
  if (thread.status !== "open") continue;
100
100
  if (thread.tool !== "copilot-cli") continue;
101
101
  const tsField =
@@ -468,7 +468,7 @@ function main() {
468
468
  const resumeThreadId = getResumeThreadId();
469
469
  let resumedThread = null;
470
470
  if (resumeThreadId) {
471
- const threads = memory.threads || [];
471
+ const threads = (memory.threads || []).filter(Boolean);
472
472
  resumedThread = findThreadById(threads, resumeThreadId);
473
473
  if (resumedThread) {
474
474
  resumedThread.status = "open";
@@ -494,13 +494,13 @@ function main() {
494
494
  }
495
495
 
496
496
  // ── Build context pieces ──────────────────────────────
497
- const threadResult = formatThreadsForInjection(memory.threads || []);
497
+ const threadResult = formatThreadsForInjection((memory.threads || []).filter(Boolean));
498
498
  const threadContext = threadResult.text;
499
499
  const configContext = formatConfigForInjection(config);
500
500
  const presenceData = loadAllPresence();
501
- const presenceContext = formatPresence(presenceData, memory.threads || []);
501
+ const presenceContext = formatPresence(presenceData, (memory.threads || []).filter(Boolean));
502
502
  const overlapContext = formatOverlapWarnings(
503
- memory.threads || [], getUser(), memory
503
+ (memory.threads || []).filter(Boolean), getUser(), memory
504
504
  );
505
505
 
506
506
  // V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject.
@@ -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);
@@ -214,8 +214,8 @@ function saveJson(filePath, data) {
214
214
  function saveMemoryMerged(memoryPath, memory) {
215
215
  try {
216
216
  const onDisk = loadJson(memoryPath, { threads: [] });
217
- const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads : [];
218
- const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads : [];
217
+ const onDiskThreads = Array.isArray(onDisk.threads) ? onDisk.threads.filter(Boolean) : [];
218
+ const inMemoryThreads = Array.isArray(memory.threads) ? memory.threads.filter(Boolean) : [];
219
219
  const inMemoryIds = new Set(inMemoryThreads.map((t) => t.id));
220
220
 
221
221
  for (const diskThread of onDiskThreads) {