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 +16 -3
- package/bin/cli.js +75 -11
- package/package.json +1 -1
- package/templates/hooks/inject-context.js +1 -1
- package/templates/hooks/on-stop.js +1 -1
- package/templates/hooks/pre-tool-use.js +1 -1
- package/templates/hooks/session-end.js +1 -1
- package/templates/hooks/session-start.js +6 -6
- package/templates/hooks/sticky-attribution.js +2 -2
- package/templates/hooks/sticky-utils.js +2 -2
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.
|
|
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.
|
|
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
|
@@ -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) {
|
|
@@ -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) {
|