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 +195 -10
- package/package.json +1 -1
- package/templates/hooks/inject-context.js +1 -1
- package/templates/hooks/on-error.js +5 -21
- package/templates/hooks/on-stop.js +9 -32
- package/templates/hooks/post-commit +36 -0
- package/templates/hooks/pre-tool-use.js +1 -1
- package/templates/hooks/session-end.js +14 -2
- package/templates/hooks/session-start.js +49 -7
- package/templates/hooks/sticky-attribution.js +2 -2
- package/templates/hooks/sticky-utils.js +87 -2
- package/templates/hooks/track-work.js +35 -1
- package/templates/sticky-note-config.json +2 -0
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
|
@@ -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
|
|
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
|
-
|
|
255
|
-
|
|
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(() =>
|
|
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 ─────────────────────────────────────────────
|
|
@@ -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
|
}
|