sticky-note-cli 2.5.0
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/LICENSE +21 -0
- package/README.md +456 -0
- package/bin/cli.js +1941 -0
- package/package.json +34 -0
- package/templates/CLAUDE.md +208 -0
- package/templates/copilot-instructions.md +185 -0
- package/templates/gitignore-additions.txt +18 -0
- package/templates/hooks/inject-context.js +525 -0
- package/templates/hooks/on-error.js +135 -0
- package/templates/hooks/on-stop.js +150 -0
- package/templates/hooks/parse-transcript.js +67 -0
- package/templates/hooks/post-rewrite.js +63 -0
- package/templates/hooks/pre-tool-use.js +219 -0
- package/templates/hooks/session-end.js +953 -0
- package/templates/hooks/session-start.js +394 -0
- package/templates/hooks/sticky-attribution.js +534 -0
- package/templates/hooks/sticky-codex.ps1 +98 -0
- package/templates/hooks/sticky-codex.sh +71 -0
- package/templates/hooks/sticky-git-notes.js +337 -0
- package/templates/hooks/sticky-utils.js +647 -0
- package/templates/hooks/track-work.js +321 -0
- package/templates/hooks.json +47 -0
- package/templates/settings.json +81 -0
- package/templates/sticky-note-config.json +8 -0
- package/templates/sticky-note.json +5 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* session-start.js -- Session Start Hook (V2)
|
|
5
|
+
*
|
|
6
|
+
* Hook: SessionStart (Claude Code) / sessionStart (Copilot CLI)
|
|
7
|
+
* Loads open+stuck threads, ages stale threads, injects context.
|
|
8
|
+
* Reads presence file if recent. Writes audit line to JSONL.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
function _safeExit() {
|
|
15
|
+
try {
|
|
16
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
17
|
+
} catch (_) {
|
|
18
|
+
process.stdout.write('{"output":""}\n');
|
|
19
|
+
}
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let utils;
|
|
24
|
+
try {
|
|
25
|
+
utils = require("./sticky-utils.js");
|
|
26
|
+
} catch (_) {
|
|
27
|
+
_safeExit();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
getMemoryPath,
|
|
32
|
+
getConfigPath,
|
|
33
|
+
getAllPresencePaths,
|
|
34
|
+
migrateAuditAndPresence,
|
|
35
|
+
loadJson,
|
|
36
|
+
saveJson,
|
|
37
|
+
appendAuditLine,
|
|
38
|
+
getUser,
|
|
39
|
+
getSessionId,
|
|
40
|
+
getResumeThreadId,
|
|
41
|
+
findThreadById,
|
|
42
|
+
saveSessionId,
|
|
43
|
+
saveHeadSha,
|
|
44
|
+
detectTool,
|
|
45
|
+
clearInjectedSet,
|
|
46
|
+
markThreadInjected,
|
|
47
|
+
clearActiveResumeThreadId,
|
|
48
|
+
getActiveResumeThreadId,
|
|
49
|
+
} = utils;
|
|
50
|
+
|
|
51
|
+
// ── Stale-thread ageing ───────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function ageStaleThreads(memory, staleDays) {
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
let changed = false;
|
|
56
|
+
for (const thread of memory.threads || []) {
|
|
57
|
+
if (thread.status !== "open" && thread.status !== "stuck") continue;
|
|
58
|
+
const tsField =
|
|
59
|
+
thread.last_activity_at || thread.updated_at || thread.created_at || "";
|
|
60
|
+
if (!tsField) continue;
|
|
61
|
+
try {
|
|
62
|
+
const ts = new Date(tsField).getTime();
|
|
63
|
+
if (isNaN(ts)) continue;
|
|
64
|
+
const diffDays = (now - ts) / (1000 * 60 * 60 * 24);
|
|
65
|
+
if (diffDays >= staleDays && thread.status === "open") {
|
|
66
|
+
thread.status = "stale";
|
|
67
|
+
thread.last_activity_at = new Date().toISOString();
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return changed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Formatting helpers ────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
function formatThreadsForInjection(threads, maxThreads, maxTokens) {
|
|
80
|
+
maxThreads = maxThreads || 10;
|
|
81
|
+
maxTokens = maxTokens || 500;
|
|
82
|
+
|
|
83
|
+
// V2.5: Eager injection only for stuck threads.
|
|
84
|
+
// Non-stuck threads are handled by lazy injection (PreToolUse hook).
|
|
85
|
+
const active = (threads || []).filter(
|
|
86
|
+
(t) => t.status === "stuck"
|
|
87
|
+
);
|
|
88
|
+
active.sort((a, b) => {
|
|
89
|
+
const aTime = a.last_activity_at || a.updated_at || "";
|
|
90
|
+
const bTime = b.last_activity_at || b.updated_at || "";
|
|
91
|
+
return bTime.localeCompare(aTime);
|
|
92
|
+
});
|
|
93
|
+
const capped = active.slice(0, maxThreads);
|
|
94
|
+
if (capped.length === 0) return { text: "", threadIds: [] };
|
|
95
|
+
|
|
96
|
+
const lines = ["## [STICKY-NOTE] [!] Stuck Threads (eager injection)\n"];
|
|
97
|
+
let tokenEstimate = 10;
|
|
98
|
+
const injectedIds = [];
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < capped.length; i++) {
|
|
101
|
+
const t = capped[i];
|
|
102
|
+
const workType = t.work_type || "";
|
|
103
|
+
const typeLabel =
|
|
104
|
+
workType && workType !== "general" ? `[${workType}] ` : "";
|
|
105
|
+
const author = t.user || t.author || "unknown";
|
|
106
|
+
const files = (t.files_touched || []).slice(0, 5).join(", ");
|
|
107
|
+
const note = (t.last_note || "").substring(0, 100);
|
|
108
|
+
const narrative = t.narrative || "";
|
|
109
|
+
const branch = t.branch || "";
|
|
110
|
+
|
|
111
|
+
let line = `- [STUCK] ${typeLabel}**${author}**`;
|
|
112
|
+
if (branch) line += ` (${branch})`;
|
|
113
|
+
line += `: ${files}`;
|
|
114
|
+
if (narrative) {
|
|
115
|
+
line += ` -- _${narrative.substring(0, 100)}_`;
|
|
116
|
+
} else if (note) {
|
|
117
|
+
line += ` -- _${note}_`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const failed = t.failed_approaches || [];
|
|
121
|
+
if (failed.length > 0) {
|
|
122
|
+
line += `\n [!] ${failed.length} failed approach(es)`;
|
|
123
|
+
for (const fa of failed.slice(0, 2)) {
|
|
124
|
+
const desc = (fa.description || "").substring(0, 80);
|
|
125
|
+
line += `\n - ${desc}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
tokenEstimate += Math.floor(line.length / 4);
|
|
130
|
+
if (tokenEstimate > maxTokens) {
|
|
131
|
+
lines.push(
|
|
132
|
+
`- ... and ${capped.length - lines.length + 1} more stuck threads`
|
|
133
|
+
);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
lines.push(line);
|
|
137
|
+
injectedIds.push(t.id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lines.push("\n_Other threads are injected lazily when you touch their files._");
|
|
141
|
+
return { text: lines.join("\n"), threadIds: injectedIds };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatConfigForInjection(config) {
|
|
145
|
+
const lines = [];
|
|
146
|
+
const conventions = config.conventions || [];
|
|
147
|
+
if (conventions.length > 0) {
|
|
148
|
+
lines.push("\n## Team Conventions");
|
|
149
|
+
for (const c of conventions.slice(0, 10)) {
|
|
150
|
+
lines.push(`- ${c}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const mcpServers = config.mcp_servers || [];
|
|
154
|
+
if (mcpServers.length > 0) {
|
|
155
|
+
lines.push("\n## MCP Servers");
|
|
156
|
+
for (const s of mcpServers.slice(0, 5)) {
|
|
157
|
+
if (s && typeof s === "object") {
|
|
158
|
+
lines.push(`- ${s.name || "unnamed"}: ${s.url || ""}`);
|
|
159
|
+
} else {
|
|
160
|
+
lines.push(`- ${s}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return lines.length ? lines.join("\n") : "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function loadAllPresence() {
|
|
168
|
+
const data = {};
|
|
169
|
+
for (const filePath of getAllPresencePaths()) {
|
|
170
|
+
try {
|
|
171
|
+
const basename = path.basename(filePath, ".json");
|
|
172
|
+
const info = loadJson(filePath, null);
|
|
173
|
+
if (info && info.last_seen) {
|
|
174
|
+
data[basename] = info;
|
|
175
|
+
}
|
|
176
|
+
} catch (_) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return data;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function formatPresence(presenceData) {
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
const active = [];
|
|
186
|
+
for (const [user, info] of Object.entries(presenceData || {})) {
|
|
187
|
+
const lastSeen = info.last_seen || "";
|
|
188
|
+
if (!lastSeen) continue;
|
|
189
|
+
try {
|
|
190
|
+
const ts = new Date(lastSeen).getTime();
|
|
191
|
+
if (isNaN(ts)) continue;
|
|
192
|
+
if (now - ts < 15 * 60 * 1000) {
|
|
193
|
+
const files = (info.active_files || []).slice(0, 3).join(", ");
|
|
194
|
+
active.push(`- **${user}** active on: ${files}`);
|
|
195
|
+
}
|
|
196
|
+
} catch (_) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (active.length === 0) return "";
|
|
201
|
+
return "\n## Active Now\n" + active.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Main ──────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
function main() {
|
|
207
|
+
let hookInput = {};
|
|
208
|
+
try {
|
|
209
|
+
if (!process.stdin.isTTY) {
|
|
210
|
+
const raw = require("fs").readFileSync(0, "utf-8").trim();
|
|
211
|
+
if (raw) hookInput = JSON.parse(raw);
|
|
212
|
+
}
|
|
213
|
+
} catch (_) {
|
|
214
|
+
hookInput = {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let sessionId = getSessionId(hookInput);
|
|
218
|
+
const aiTool = detectTool(hookInput);
|
|
219
|
+
const isCopilotCli = aiTool === "copilot-cli" || process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
|
|
220
|
+
|
|
221
|
+
if (sessionId === "unknown") {
|
|
222
|
+
sessionId = crypto.randomUUID();
|
|
223
|
+
}
|
|
224
|
+
saveSessionId(sessionId);
|
|
225
|
+
saveHeadSha();
|
|
226
|
+
|
|
227
|
+
// V2.5: Only clear injected-this-session tracking for truly new sessions.
|
|
228
|
+
// Copilot CLI fires SessionStart per-turn; clearing would lose dedup state.
|
|
229
|
+
if (!isCopilotCli) {
|
|
230
|
+
clearInjectedSet();
|
|
231
|
+
clearActiveResumeThreadId();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Migrate legacy single-file audit/presence to per-user dirs
|
|
235
|
+
migrateAuditAndPresence();
|
|
236
|
+
|
|
237
|
+
const memoryPath = getMemoryPath();
|
|
238
|
+
const memory = loadJson(memoryPath, {
|
|
239
|
+
version: "2",
|
|
240
|
+
project: "",
|
|
241
|
+
threads: [],
|
|
242
|
+
});
|
|
243
|
+
const config = loadJson(getConfigPath(), { stale_days: 14 });
|
|
244
|
+
const staleDays = config.stale_days != null ? config.stale_days : 14;
|
|
245
|
+
|
|
246
|
+
ageStaleThreads(memory, staleDays);
|
|
247
|
+
|
|
248
|
+
// ── Resume handling ───────────────────────────────────
|
|
249
|
+
const resumeThreadId = getResumeThreadId();
|
|
250
|
+
let resumedThread = null;
|
|
251
|
+
if (resumeThreadId) {
|
|
252
|
+
const threads = memory.threads || [];
|
|
253
|
+
resumedThread = findThreadById(threads, resumeThreadId);
|
|
254
|
+
if (resumedThread) {
|
|
255
|
+
resumedThread.status = "open";
|
|
256
|
+
resumedThread.related_session_ids = resumedThread.related_session_ids || [];
|
|
257
|
+
resumedThread.related_session_ids.push(sessionId);
|
|
258
|
+
resumedThread.last_activity_at = new Date().toISOString();
|
|
259
|
+
|
|
260
|
+
const aiTool = detectTool(hookInput);
|
|
261
|
+
const chain = resumedThread.resume_chain || [];
|
|
262
|
+
const prevSession = chain.length
|
|
263
|
+
? chain[chain.length - 1].session_id
|
|
264
|
+
: resumedThread.session_id || "";
|
|
265
|
+
chain.push({
|
|
266
|
+
session_id: sessionId,
|
|
267
|
+
tool: aiTool,
|
|
268
|
+
started_at: new Date().toISOString(),
|
|
269
|
+
ended_at: null,
|
|
270
|
+
resumed_from: prevSession,
|
|
271
|
+
});
|
|
272
|
+
resumedThread.resume_chain = chain;
|
|
273
|
+
saveJson(memoryPath, memory);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Build context pieces ──────────────────────────────
|
|
278
|
+
const threadResult = formatThreadsForInjection(memory.threads || []);
|
|
279
|
+
const threadContext = threadResult.text;
|
|
280
|
+
const configContext = formatConfigForInjection(config);
|
|
281
|
+
const presenceData = loadAllPresence();
|
|
282
|
+
const presenceContext = formatPresence(presenceData);
|
|
283
|
+
|
|
284
|
+
// V2.5: Mark eagerly-injected stuck threads so PreToolUse won't re-inject
|
|
285
|
+
for (const threadId of threadResult.threadIds) {
|
|
286
|
+
markThreadInjected(threadId, sessionId);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
appendAuditLine({
|
|
290
|
+
type: "session_start",
|
|
291
|
+
user: getUser(),
|
|
292
|
+
ts: new Date().toISOString(),
|
|
293
|
+
session_id: sessionId,
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
saveJson(memoryPath, memory);
|
|
297
|
+
|
|
298
|
+
// ── Assemble output ───────────────────────────────────
|
|
299
|
+
const parts = [];
|
|
300
|
+
|
|
301
|
+
if (resumedThread) {
|
|
302
|
+
const resumeLines = [
|
|
303
|
+
`## [STICKY-NOTE] Resumed Thread -- ${(resumedThread.id || "").substring(0, 8)}\n`,
|
|
304
|
+
];
|
|
305
|
+
const files = (resumedThread.files_touched || []).slice(0, 10).join(", ");
|
|
306
|
+
if (files) resumeLines.push(`**Files:** ${files}`);
|
|
307
|
+
if (resumedThread.branch)
|
|
308
|
+
resumeLines.push(`**Branch:** ${resumedThread.branch}`);
|
|
309
|
+
const workType = resumedThread.work_type || "";
|
|
310
|
+
if (workType && workType !== "general")
|
|
311
|
+
resumeLines.push(`**Work type:** ${workType}`);
|
|
312
|
+
const activities = resumedThread.activities || [];
|
|
313
|
+
if (activities.length)
|
|
314
|
+
resumeLines.push(`**Activities:** ${activities.slice(0, 6).join(", ")}`);
|
|
315
|
+
const toolCalls = resumedThread.tool_calls || {};
|
|
316
|
+
const toolEntries = Object.entries(toolCalls);
|
|
317
|
+
if (toolEntries.length) {
|
|
318
|
+
toolEntries.sort((a, b) => b[1] - a[1]);
|
|
319
|
+
const callsStr = toolEntries
|
|
320
|
+
.slice(0, 10)
|
|
321
|
+
.map(([name, count]) => `${name}: ${count}`)
|
|
322
|
+
.join(", ");
|
|
323
|
+
resumeLines.push(`**Tool calls:** ${callsStr}`);
|
|
324
|
+
}
|
|
325
|
+
if (resumedThread.narrative) {
|
|
326
|
+
resumeLines.push(`**Context:** ${resumedThread.narrative}`);
|
|
327
|
+
} else if (resumedThread.last_note) {
|
|
328
|
+
resumeLines.push(`**Context:** ${resumedThread.last_note}`);
|
|
329
|
+
}
|
|
330
|
+
const failed = resumedThread.failed_approaches || [];
|
|
331
|
+
if (failed.length) {
|
|
332
|
+
resumeLines.push(`**WARNING: ${failed.length} failed approach(es):**`);
|
|
333
|
+
for (const fa of failed.slice(0, 3)) {
|
|
334
|
+
const desc = (fa.description || "").substring(0, 100);
|
|
335
|
+
resumeLines.push(` - ${desc}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (resumedThread.handoff_summary)
|
|
339
|
+
resumeLines.push(`**Handoff:** ${resumedThread.handoff_summary}`);
|
|
340
|
+
const prompts = resumedThread.prompts || [];
|
|
341
|
+
if (prompts.length) {
|
|
342
|
+
resumeLines.push(`\n**Conversation log (${prompts.length} prompt(s)):**`);
|
|
343
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
344
|
+
resumeLines.push(` ${i + 1}. User: ${prompts[i].substring(0, 200)}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const chain = resumedThread.resume_chain || [];
|
|
348
|
+
const related = resumedThread.related_session_ids || [];
|
|
349
|
+
if (chain.length) {
|
|
350
|
+
resumeLines.push(`\n**Resume chain (${chain.length} session(s)):**`);
|
|
351
|
+
for (let i = 0; i < chain.length; i++) {
|
|
352
|
+
const entry = chain[i];
|
|
353
|
+
const tool = entry.tool || "unknown";
|
|
354
|
+
const sid = (entry.session_id || "?").substring(0, 8);
|
|
355
|
+
const started = entry.started_at || "?";
|
|
356
|
+
const ended = entry.ended_at || "in-progress";
|
|
357
|
+
const fromSid = entry.resumed_from || "";
|
|
358
|
+
const fromLabel = fromSid
|
|
359
|
+
? ` <- ${fromSid.substring(0, 8)}`
|
|
360
|
+
: " (original)";
|
|
361
|
+
resumeLines.push(
|
|
362
|
+
` ${i + 1}. [${tool}] session ${sid}.. ${started}${fromLabel}`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} else if (related.length > 1) {
|
|
366
|
+
resumeLines.push(
|
|
367
|
+
`**Sessions:** this is session #${related.length} on this thread`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
resumeLines.push("");
|
|
371
|
+
resumeLines.push(
|
|
372
|
+
"**-> Start by giving the user a brief recap of what happened in " +
|
|
373
|
+
"the previous session(s) on this thread -- what was worked on, " +
|
|
374
|
+
"what was accomplished, what problems were hit, and what's left to do. " +
|
|
375
|
+
"Then ask how they'd like to proceed.**"
|
|
376
|
+
);
|
|
377
|
+
parts.push(resumeLines.join("\n"));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (threadContext) parts.push(threadContext);
|
|
381
|
+
if (configContext) parts.push(configContext);
|
|
382
|
+
if (presenceContext) parts.push(presenceContext);
|
|
383
|
+
|
|
384
|
+
const output = parts.join("\n").trim();
|
|
385
|
+
process.stdout.write(JSON.stringify({ output }) + "\n");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Entry point ───────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
main();
|
|
392
|
+
} catch (_) {
|
|
393
|
+
_safeExit();
|
|
394
|
+
}
|