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.
@@ -0,0 +1,953 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * session-end.js — Session Thread Capture (V2)
5
+ *
6
+ * Fires on SessionEnd (Claude Code) / sessionEnd (Copilot CLI).
7
+ * Parses session transcript, classifies work type, updates/creates
8
+ * thread records, runs tombstone sweep, and cleans up.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const crypto = require("crypto");
14
+ const { execFileSync } = require("child_process");
15
+
16
+ function _safeExit() {
17
+ try {
18
+ process.stdout.write(JSON.stringify({ output: "" }) + "\n");
19
+ } catch (_) {
20
+ process.stdout.write('{"output":""}\n');
21
+ }
22
+ process.exit(0);
23
+ }
24
+
25
+ let utils;
26
+ try {
27
+ utils = require("./sticky-utils.js");
28
+ } catch (_) {
29
+ _safeExit();
30
+ }
31
+
32
+ const {
33
+ getMemoryPath,
34
+ getConfigPath,
35
+ getUserPresencePath,
36
+ getAllAuditPaths,
37
+ loadJson,
38
+ saveJson,
39
+ appendAuditLine,
40
+ getUser,
41
+ getBranch,
42
+ detectTool,
43
+ getSessionId,
44
+ getResumeThreadId,
45
+ findThreadById,
46
+ clearResumeSignal,
47
+ parseJsonlFile,
48
+ extractNarrativeFromEntries,
49
+ extractFailedFromEntries,
50
+ ERROR_PATTERNS,
51
+ RETRY_PATTERNS,
52
+ extractSessionFromAudit,
53
+ clearSessionFile,
54
+ readHeadSha,
55
+ clearHeadFile,
56
+ getActiveResumeThreadId,
57
+ clearActiveResumeThreadId,
58
+ clearInjectedSet,
59
+ normalizeSep,
60
+ } = utils;
61
+
62
+ // ── Constants ─────────────────────────────────────────────
63
+
64
+ const WRITE_TOOLS = new Set([
65
+ "Write", "Edit", "MultiEdit",
66
+ "write", "edit", "multi_edit",
67
+ ]);
68
+
69
+ const STICKY_FILES_PREFIX = [
70
+ ".sticky-note/sticky-note.json",
71
+ ".sticky-note/sticky-note-audit.jsonl",
72
+ ".sticky-note/audit/",
73
+ ".sticky-note/presence/",
74
+ ".sticky-note/.sticky-session",
75
+ ".sticky-note/.sticky-head",
76
+ ".sticky-note/.sticky-resume",
77
+ ".sticky-note/.sticky-debug.jsonl",
78
+ ".sticky-note/sticky-note-config.json",
79
+ ".sticky-note/.sticky-presence.json",
80
+ ".sticky-note/.sticky-injected",
81
+ ".sticky-note/.sticky-active-resume",
82
+ ];
83
+
84
+ // ── Transcript helpers ────────────────────────────────────
85
+
86
+ function _getContentBlocks(entry) {
87
+ const message = entry.message;
88
+ if (message && typeof message === "object" && !Array.isArray(message) && "content" in message) {
89
+ return message.content || [];
90
+ }
91
+ return entry.content || [];
92
+ }
93
+
94
+ function _normalizePath(filePath, cwd) {
95
+ if (cwd && path.isAbsolute(filePath)) {
96
+ try {
97
+ return path.relative(cwd, filePath);
98
+ } catch (_) {
99
+ // ignore
100
+ }
101
+ }
102
+ return filePath;
103
+ }
104
+
105
+ function _extractFilesFromEntry(entry, files, cwd) {
106
+ if (!entry || typeof entry !== "object") return;
107
+
108
+ const contentBlocks = _getContentBlocks(entry);
109
+ for (const content of contentBlocks) {
110
+ if (!content || typeof content !== "object") continue;
111
+ if (content.type === "tool_use") {
112
+ const toolName = content.name || "";
113
+ if (!WRITE_TOOLS.has(toolName)) continue;
114
+ const toolInput = content.input;
115
+ if (toolInput && typeof toolInput === "object") {
116
+ for (const key of ["file_path", "filePath", "path", "file"]) {
117
+ if (key in toolInput) {
118
+ files.add(_normalizePath(toolInput[key], cwd));
119
+ break;
120
+ }
121
+ }
122
+ const edits = toolInput.edits;
123
+ if (Array.isArray(edits)) {
124
+ for (const edit of edits) {
125
+ if (edit && typeof edit === "object") {
126
+ for (const key of ["file_path", "path"]) {
127
+ if (key in edit) {
128
+ files.add(_normalizePath(edit[key], cwd));
129
+ break;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Hook-style tool entries (Copilot CLI format)
140
+ const hookTool = entry.tool_name || "";
141
+ if (WRITE_TOOLS.has(hookTool)) {
142
+ const toolInput = entry.tool_input;
143
+ if (toolInput && typeof toolInput === "object") {
144
+ for (const key of ["file_path", "filePath", "path", "file"]) {
145
+ if (key in toolInput) {
146
+ files.add(_normalizePath(toolInput[key], cwd));
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ function extractFilesTouched(hookInput) {
155
+ const files = new Set();
156
+ const cwd = hookInput.cwd || "";
157
+
158
+ if (Array.isArray(hookInput.files_touched)) {
159
+ for (const f of hookInput.files_touched) files.add(f);
160
+ }
161
+
162
+ // Parse transcript for tool_use writes
163
+ const transcriptPath = hookInput.transcript_path || "";
164
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
165
+ try {
166
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
167
+ for (const line of raw.split(/\r?\n/)) {
168
+ const trimmed = line.trim();
169
+ if (!trimmed) continue;
170
+ let entry;
171
+ try { entry = JSON.parse(trimmed); } catch (_) { continue; }
172
+ _extractFilesFromEntry(entry, files, cwd);
173
+ }
174
+ } catch (_) {
175
+ // ignore
176
+ }
177
+ }
178
+
179
+ // Check audit trail for this session (search all per-user audit files)
180
+ const sessionId = hookInput.session_id;
181
+ if (sessionId) {
182
+ for (const auditPath of getAllAuditPaths()) {
183
+ try {
184
+ const raw = fs.readFileSync(auditPath, "utf-8");
185
+ for (const line of raw.split(/\r?\n/)) {
186
+ const trimmed = line.trim();
187
+ if (!trimmed) continue;
188
+ let entry;
189
+ try { entry = JSON.parse(trimmed); } catch (_) { continue; }
190
+ if (
191
+ entry.session_id === sessionId &&
192
+ entry.type === "tool_use" &&
193
+ entry.file
194
+ ) {
195
+ files.add(entry.file);
196
+ }
197
+ }
198
+ } catch (_) {
199
+ // ignore
200
+ }
201
+ }
202
+ }
203
+
204
+ return Array.from(files);
205
+ }
206
+
207
+ // ── Git diff helpers ──────────────────────────────────────
208
+
209
+ /**
210
+ * V2.5: Collect commit SHAs created between session start HEAD and current HEAD.
211
+ * These are the commits made during this session — the join key for attribution engine.
212
+ */
213
+ function getSessionCommitShas() {
214
+ const savedSha = readHeadSha();
215
+ if (!savedSha || !/^[0-9a-f]+$/i.test(savedSha)) return [];
216
+
217
+ try {
218
+ const result = execFileSync("git", ["log", "--format=%H", savedSha + "..HEAD"], {
219
+ encoding: "utf-8",
220
+ timeout: 5000,
221
+ stdio: ["pipe", "pipe", "pipe"],
222
+ });
223
+ return result.trim().split(/\r?\n/).filter((s) => s.trim()).map((s) => s.trim());
224
+ } catch (_) {
225
+ // Fallback: just return current HEAD
226
+ try {
227
+ const head = execFileSync("git", ["rev-parse", "HEAD"], {
228
+ encoding: "utf-8",
229
+ timeout: 5000,
230
+ stdio: ["pipe", "pipe", "pipe"],
231
+ }).trim();
232
+ return head !== savedSha ? [head] : [];
233
+ } catch (_) {
234
+ return [];
235
+ }
236
+ }
237
+ }
238
+
239
+ function getGitFilesTouched() {
240
+ const files = new Set();
241
+ const savedSha = readHeadSha();
242
+
243
+ try {
244
+ if (savedSha && /^[0-9a-f]+$/i.test(savedSha)) {
245
+ const result = execFileSync("git", ["diff", "--name-only", savedSha, "HEAD"], {
246
+ encoding: "utf-8",
247
+ timeout: 5000,
248
+ stdio: ["pipe", "pipe", "pipe"],
249
+ });
250
+ for (const f of result.trim().split(/\r?\n/)) {
251
+ const trimmed = f.trim();
252
+ if (trimmed) files.add(trimmed);
253
+ }
254
+ }
255
+
256
+ const unstaged = execFileSync("git", ["diff", "--name-only"], {
257
+ encoding: "utf-8",
258
+ timeout: 5000,
259
+ stdio: ["pipe", "pipe", "pipe"],
260
+ });
261
+ for (const f of unstaged.trim().split(/\r?\n/)) {
262
+ const trimmed = f.trim();
263
+ if (trimmed) files.add(trimmed);
264
+ }
265
+
266
+ const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
267
+ encoding: "utf-8",
268
+ timeout: 5000,
269
+ stdio: ["pipe", "pipe", "pipe"],
270
+ });
271
+ for (const f of staged.trim().split(/\r?\n/)) {
272
+ const trimmed = f.trim();
273
+ if (trimmed) files.add(trimmed);
274
+ }
275
+ } catch (_) {
276
+ // ignore
277
+ }
278
+
279
+ return Array.from(files).filter((f) => {
280
+ const normalized = normalizeSep(f);
281
+ return !STICKY_FILES_PREFIX.some((p) => normalized === p || normalized.startsWith(p));
282
+ });
283
+ }
284
+
285
+ // ── User prompt extraction ────────────────────────────────
286
+
287
+ function _extractUserPrompt(hookInput) {
288
+ const transcriptPath = hookInput.transcript_path || "";
289
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
290
+
291
+ try {
292
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
293
+ for (const line of raw.split(/\r?\n/)) {
294
+ const trimmed = line.trim();
295
+ if (!trimmed) continue;
296
+ let entry;
297
+ try { entry = JSON.parse(trimmed); } catch (_) { continue; }
298
+
299
+ const message = entry.message;
300
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
301
+ const role = message.role || entry.role || "";
302
+ if (role !== "user") continue;
303
+
304
+ const contentBlocks = _getContentBlocks(entry);
305
+ if (!contentBlocks || !contentBlocks.length) continue;
306
+
307
+ // If all blocks are short strings, join them
308
+ if (contentBlocks.every((c) => typeof c === "string" && c.length <= 1)) {
309
+ const text = contentBlocks.join("").trim();
310
+ if (text) return text.substring(0, 200);
311
+ continue;
312
+ }
313
+
314
+ for (const content of contentBlocks) {
315
+ if (content && typeof content === "object" && content.type === "text") {
316
+ const text = (content.text || "").trim();
317
+ if (text) return text.substring(0, 200);
318
+ } else if (typeof content === "string" && content.length > 1) {
319
+ return content.trim().substring(0, 200);
320
+ }
321
+ }
322
+ }
323
+ } catch (_) {
324
+ // ignore
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ // ── Narrative / failed approaches ─────────────────────────
331
+
332
+ function extractNarrative(hookInput) {
333
+ const transcriptPath = hookInput.transcript_path || "";
334
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
335
+ const entries = parseJsonlFile(transcriptPath);
336
+ const narrative = extractNarrativeFromEntries(entries);
337
+ if (narrative) return narrative;
338
+ }
339
+
340
+ const sessionId = getSessionId(hookInput);
341
+ const auditData = extractSessionFromAudit(sessionId);
342
+ const prompts = auditData.prompts || [];
343
+ if (prompts.length) {
344
+ if (prompts.length === 1) return prompts[0].substring(0, 300).trim();
345
+ let summary = prompts[0].substring(0, 150).trim();
346
+ if (prompts.length > 2) {
347
+ summary += ` ... (${prompts.length} prompts total) ... ${prompts[prompts.length - 1].substring(0, 100).trim()}`;
348
+ } else {
349
+ summary += ` -> ${prompts[prompts.length - 1].substring(0, 100).trim()}`;
350
+ }
351
+ return summary.substring(0, 300);
352
+ }
353
+
354
+ return "";
355
+ }
356
+
357
+ function extractFailedApproaches(hookInput) {
358
+ const transcriptPath = hookInput.transcript_path || "";
359
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return [];
360
+ const entries = parseJsonlFile(transcriptPath);
361
+ return extractFailedFromEntries(entries);
362
+ }
363
+
364
+ // ── Last note extraction ──────────────────────────────────
365
+
366
+ function extractLastNote(hookInput, narrative, sessionAnalysis, auditData) {
367
+ for (const key of ["summary", "last_message", "description"]) {
368
+ const note = hookInput[key];
369
+ if (note && typeof note === "string") return note.substring(0, 200);
370
+ }
371
+
372
+ let workType = "";
373
+ let activities = [];
374
+ if (sessionAnalysis) {
375
+ workType = sessionAnalysis.work_type || "";
376
+ activities = sessionAnalysis.activities || [];
377
+ }
378
+
379
+ if (narrative && narrative !== "Session completed" && narrative !== "") {
380
+ const prefix = workType && workType !== "general" ? `[${workType}] ` : "";
381
+ return (prefix + narrative).substring(0, 200);
382
+ }
383
+
384
+ if (workType && workType !== "general" && activities.length) {
385
+ return `[${workType}] ${activities.slice(0, 4).join(", ")}`.substring(0, 200);
386
+ }
387
+
388
+ if (activities.length) {
389
+ return activities.slice(0, 4).join(", ").substring(0, 200);
390
+ }
391
+
392
+ const reason = hookInput.reason || "";
393
+ const reasonLabels = {
394
+ prompt_input_exit: "User exited session",
395
+ stop: "Session stopped",
396
+ error: "Session ended with error",
397
+ };
398
+ if (reason in reasonLabels) {
399
+ const label = reasonLabels[reason];
400
+ if (workType && workType !== "general") {
401
+ return `[${workType}] ${label}`.substring(0, 200);
402
+ }
403
+ return label;
404
+ }
405
+
406
+ const files = extractFilesTouched(hookInput);
407
+ if (files.length) {
408
+ const prefix = workType && workType !== "general" ? `[${workType}] ` : "";
409
+ const suffix = files.length > 3 ? "..." : "";
410
+ return (prefix + "Worked on " + files.slice(0, 3).join(", ") + suffix).substring(0, 200);
411
+ }
412
+
413
+ let resolvedAudit = auditData;
414
+ if (resolvedAudit == null) {
415
+ const sessionId = getSessionId(hookInput);
416
+ resolvedAudit = extractSessionFromAudit(sessionId);
417
+ }
418
+ const firstPrompt = resolvedAudit.first_prompt || "";
419
+ if (firstPrompt) return firstPrompt.substring(0, 200);
420
+
421
+ return "Session completed";
422
+ }
423
+
424
+ // ── Activity analysis ─────────────────────────────────────
425
+
426
+ function _looksLikeError(text) {
427
+ if (!text || text.length < 10) return false;
428
+ return ERROR_PATTERNS.test(text);
429
+ }
430
+
431
+ function _summarizeError(text) {
432
+ if (!text) return "";
433
+ for (const line of text.split(/\r?\n/)) {
434
+ const trimmed = line.trim();
435
+ if (trimmed && ERROR_PATTERNS.test(trimmed)) {
436
+ return trimmed.substring(0, 100);
437
+ }
438
+ }
439
+ return text.trim().split(/\r?\n/)[0].substring(0, 100);
440
+ }
441
+
442
+ function _isTestCommand(cmd) {
443
+ const patterns = [
444
+ "test", "jest", "pytest", "mocha", "vitest", "rspec",
445
+ "cargo test", "go test", "npm test", "yarn test", "pnpm test",
446
+ ];
447
+ const lower = cmd.toLowerCase();
448
+ return patterns.some((p) => lower.includes(p));
449
+ }
450
+
451
+ function _isDebugCommand(cmd) {
452
+ const patterns = [
453
+ "log", "cat ", "tail ", "head ", "grep ", "less ",
454
+ "journalctl", "docker logs", "kubectl logs",
455
+ "console", "debug", "strace", "ltrace", "dmesg",
456
+ ];
457
+ const lower = cmd.toLowerCase();
458
+ return patterns.some((p) => lower.includes(p));
459
+ }
460
+
461
+ function _isInstallCommand(cmd) {
462
+ const patterns = [
463
+ "npm install", "npm i ", "yarn add", "pip install",
464
+ "cargo add", "go get", "brew install", "apt install", "pnpm add",
465
+ ];
466
+ const lower = cmd.toLowerCase();
467
+ return patterns.some((p) => lower.includes(p));
468
+ }
469
+
470
+ function _analyzeEntry(entry, toolCounts, errorsSeen, commandsRun) {
471
+ if (!entry || typeof entry !== "object") return;
472
+
473
+ for (const content of _getContentBlocks(entry)) {
474
+ if (!content || typeof content !== "object") continue;
475
+
476
+ if (content.type === "tool_use") {
477
+ const name = content.name || "";
478
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
479
+ const toolInput = content.input;
480
+ if (toolInput && typeof toolInput === "object" && toolInput.command) {
481
+ commandsRun.push(toolInput.command);
482
+ }
483
+ }
484
+
485
+ if (content.type === "tool_result" && content.is_error) {
486
+ let text = "";
487
+ const resultContent = content.content || "";
488
+ if (typeof resultContent === "string") {
489
+ text = resultContent;
490
+ } else if (Array.isArray(resultContent)) {
491
+ text = resultContent
492
+ .filter((c) => c && typeof c === "object")
493
+ .map((c) => c.text || "")
494
+ .join(" ");
495
+ }
496
+ const summary = _summarizeError(text);
497
+ if (summary) errorsSeen.push(summary);
498
+ }
499
+ }
500
+
501
+ // Hook-style entries (Copilot CLI format)
502
+ if (entry.tool_name) {
503
+ const name = entry.tool_name;
504
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
505
+ const toolInput = entry.tool_input;
506
+ if (toolInput && typeof toolInput === "object" && toolInput.command) {
507
+ commandsRun.push(toolInput.command);
508
+ }
509
+ const resp = entry.tool_response;
510
+ if (resp && typeof resp === "object") {
511
+ const stderr = resp.stderr || "";
512
+ if (stderr && _looksLikeError(stderr)) {
513
+ errorsSeen.push(_summarizeError(stderr));
514
+ }
515
+ }
516
+ }
517
+ }
518
+
519
+ function _collectEditedFiles(hookInput) {
520
+ const files = new Set();
521
+ const transcriptPath = hookInput.transcript_path || "";
522
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return files;
523
+
524
+ try {
525
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
526
+ for (const line of raw.split(/\r?\n/)) {
527
+ const trimmed = line.trim();
528
+ if (!trimmed) continue;
529
+ let entry;
530
+ try { entry = JSON.parse(trimmed); } catch (_) { continue; }
531
+
532
+ for (const content of _getContentBlocks(entry)) {
533
+ if (!content || typeof content !== "object") continue;
534
+ if (content.type === "tool_use" && WRITE_TOOLS.has(content.name)) {
535
+ const inp = content.input;
536
+ if (inp && typeof inp === "object") {
537
+ for (const key of ["file_path", "path", "file"]) {
538
+ if (key in inp) {
539
+ files.add(path.basename(inp[key]));
540
+ break;
541
+ }
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ } catch (_) {
548
+ // ignore
549
+ }
550
+
551
+ return files;
552
+ }
553
+
554
+ function analyzeSessionActivities(hookInput, auditData) {
555
+ let activities = [];
556
+ let workType = "general";
557
+ const toolCounts = {};
558
+ const errorsSeen = [];
559
+ const commandsRun = [];
560
+
561
+ const transcriptPath = hookInput.transcript_path || "";
562
+ let hasTranscript = !!(transcriptPath && fs.existsSync(transcriptPath));
563
+
564
+ if (hasTranscript) {
565
+ try {
566
+ const raw = fs.readFileSync(transcriptPath, "utf-8");
567
+ for (const line of raw.split(/\r?\n/)) {
568
+ const trimmed = line.trim();
569
+ if (!trimmed) continue;
570
+ let entry;
571
+ try { entry = JSON.parse(trimmed); } catch (_) { continue; }
572
+ _analyzeEntry(entry, toolCounts, errorsSeen, commandsRun);
573
+ }
574
+ } catch (_) {
575
+ hasTranscript = false;
576
+ }
577
+ }
578
+
579
+ // Fallback to audit data when transcript is unavailable
580
+ if (!hasTranscript) {
581
+ let resolvedAudit = auditData;
582
+ if (resolvedAudit == null) {
583
+ const sessionId = getSessionId(hookInput);
584
+ resolvedAudit = extractSessionFromAudit(sessionId);
585
+ }
586
+ const auditTools = resolvedAudit.tools || {};
587
+ const auditFiles = resolvedAudit.files || [];
588
+
589
+ if (Object.keys(auditTools).length || auditFiles.length) {
590
+ Object.assign(toolCounts, auditTools);
591
+
592
+ if (auditFiles.length) {
593
+ const editLike = new Set([
594
+ "edit", "create", "write", "Edit", "Write", "MultiEdit",
595
+ "multi_edit", "editedExistingFile", "createdNewFile",
596
+ ]);
597
+ let auditEditCount = 0;
598
+ for (const [t, c] of Object.entries(auditTools)) {
599
+ if (editLike.has(t)) auditEditCount += c;
600
+ }
601
+ if (auditEditCount > 0) activities.push(`edited ${auditEditCount} file(s)`);
602
+ activities.push(`touched ${auditFiles.length} file(s)`);
603
+ }
604
+
605
+ const numPrompts = (resolvedAudit.prompts || []).length;
606
+ if (numPrompts > 0) activities.push(`${numPrompts} prompt(s)`);
607
+
608
+ if (!activities.length && Object.keys(auditTools).length) {
609
+ const totalCalls = Object.values(auditTools).reduce((a, b) => a + b, 0);
610
+ activities.push(`used ${totalCalls} tool(s)`);
611
+ }
612
+
613
+ return { work_type: workType, activities: activities.slice(0, 8), tool_calls: toolCounts };
614
+ }
615
+ }
616
+
617
+ if (!Object.keys(toolCounts).length && !hasTranscript) {
618
+ return { work_type: workType, activities, tool_calls: {} };
619
+ }
620
+
621
+ const hasEdits = ["Write", "Edit", "MultiEdit"].some((t) => t in toolCounts);
622
+ const hasErrors = errorsSeen.length > 0;
623
+ const hasTestRuns = commandsRun.some(_isTestCommand);
624
+ const hasLogInspection = commandsRun.some(_isDebugCommand);
625
+ const hasGit = commandsRun.some((cmd) => cmd.toLowerCase().includes("git"));
626
+ const hasInstall = commandsRun.some(_isInstallCommand);
627
+
628
+ const readCount = toolCounts["Read"] || 0;
629
+ let editCount = 0;
630
+ for (const t of ["Write", "Edit", "MultiEdit"]) {
631
+ editCount += toolCounts[t] || 0;
632
+ }
633
+
634
+ const filesTouched = _collectEditedFiles(hookInput);
635
+ const isDocs =
636
+ filesTouched.size > 0 &&
637
+ Array.from(filesTouched).every((f) => /\.(md|txt|rst|adoc)$/.test(f));
638
+
639
+ // Work type classification (priority order)
640
+ if (hasErrors && (hasLogInspection || hasTestRuns)) workType = "debugging";
641
+ else if (hasErrors && hasEdits) workType = "bug-fix";
642
+ else if (hasTestRuns && !hasEdits) workType = "testing";
643
+ else if (hasTestRuns && hasEdits) workType = "test-writing";
644
+ else if (isDocs && hasEdits) workType = "documentation";
645
+ else if (hasEdits && !hasErrors) workType = "feature-development";
646
+ else if (hasInstall) workType = "setup";
647
+ else if (readCount > 0 && !hasEdits) workType = "code-review";
648
+ else if (hasGit && !hasEdits) workType = "investigation";
649
+ else if (!hasEdits && (hasLogInspection || hasTestRuns)) workType = "investigation";
650
+
651
+ // Build activities list
652
+ if (hasErrors) {
653
+ const unique = [...new Set(errorsSeen)].slice(0, 3);
654
+ for (const err of unique) activities.push(`error: ${err}`);
655
+ }
656
+ if (hasTestRuns) activities.push("ran tests");
657
+ if (hasLogInspection) activities.push("inspected logs/output");
658
+ if (hasGit) {
659
+ const gitCmds = commandsRun.filter((cmd) => cmd.toLowerCase().includes("git"));
660
+ if (gitCmds.some((c) => c.includes("log"))) activities.push("reviewed git history");
661
+ if (gitCmds.some((c) => c.includes("diff"))) activities.push("reviewed diffs");
662
+ if (gitCmds.some((c) => c.includes("checkout") || c.includes("branch"))) {
663
+ activities.push("switched branches");
664
+ }
665
+ }
666
+ if (hasInstall) activities.push("installed dependencies");
667
+ if (editCount > 0) activities.push(`edited ${editCount} file(s)`);
668
+
669
+ return { work_type: workType, activities: activities.slice(0, 8), tool_calls: toolCounts };
670
+ }
671
+
672
+ // ── Tombstone sweep ───────────────────────────────────────
673
+
674
+ function tombstoneSweep(threads, staleDays) {
675
+ const now = Date.now();
676
+ let count = 0;
677
+
678
+ for (const thread of threads) {
679
+ if (thread.status !== "closed") continue;
680
+ const tsField =
681
+ thread.last_activity_at || thread.closed_at || thread.updated_at || "";
682
+ if (!tsField) continue;
683
+
684
+ try {
685
+ const ts = new Date(tsField).getTime();
686
+ if (isNaN(ts)) continue;
687
+ const daysDiff = (now - ts) / (1000 * 60 * 60 * 24);
688
+ if (daysDiff >= staleDays) {
689
+ const closedAt = thread.closed_at || tsField;
690
+ const user = thread.user || thread.author || "unknown";
691
+ const threadId = thread.id || "";
692
+
693
+ // Replace thread data with minimal tombstone
694
+ const tombstone = {
695
+ id: threadId,
696
+ status: "expired",
697
+ user,
698
+ closed_at: closedAt,
699
+ };
700
+ for (const key of Object.keys(thread)) delete thread[key];
701
+ Object.assign(thread, tombstone);
702
+ count++;
703
+ }
704
+ } catch (_) {
705
+ continue;
706
+ }
707
+ }
708
+
709
+ return count;
710
+ }
711
+
712
+ // ── Presence cleanup ──────────────────────────────────────
713
+
714
+ function clearPresence(user) {
715
+ const presencePath = getUserPresencePath(user);
716
+ try {
717
+ if (fs.existsSync(presencePath)) {
718
+ fs.unlinkSync(presencePath);
719
+ }
720
+ } catch (_) {
721
+ // ignore
722
+ }
723
+ }
724
+
725
+ // ── Main ──────────────────────────────────────────────────
726
+
727
+ function main() {
728
+ let hookInput = {};
729
+ try {
730
+ const raw = fs.readFileSync(0, "utf-8");
731
+ hookInput = JSON.parse(raw);
732
+ } catch (_) {
733
+ hookInput = {};
734
+ }
735
+
736
+ const sessionId = getSessionId(hookInput);
737
+ const aiTool = detectTool(hookInput);
738
+ const isCopilotCli = aiTool === "copilot-cli" || process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
739
+ const user = getUser();
740
+ const now = new Date().toISOString();
741
+ const branch = getBranch();
742
+
743
+ const memoryPath = getMemoryPath();
744
+ const memory = loadJson(memoryPath, { version: "2", project: "", threads: [] });
745
+ const config = loadJson(getConfigPath(), { stale_days: 14 });
746
+ const staleDays = config.stale_days || 14;
747
+
748
+ let filesTouched = extractFilesTouched(hookInput);
749
+ const auditData = extractSessionFromAudit(sessionId);
750
+ const sessionAnalysis = analyzeSessionActivities(hookInput, auditData);
751
+ const narrative = extractNarrative(hookInput);
752
+ const failed = extractFailedApproaches(hookInput);
753
+ const lastNote = extractLastNote(hookInput, narrative, sessionAnalysis, auditData);
754
+
755
+ // Merge audit-discovered files
756
+ const auditFiles = auditData.files || [];
757
+ if (auditFiles.length) {
758
+ filesTouched = [...new Set([...filesTouched, ...auditFiles])];
759
+ }
760
+
761
+ // Merge git-discovered files
762
+ const gitFiles = getGitFilesTouched();
763
+ if (gitFiles.length) {
764
+ filesTouched = [...new Set([...filesTouched, ...gitFiles])];
765
+ }
766
+
767
+ const threads = memory.threads || [];
768
+ memory.threads = threads;
769
+ let existing = null;
770
+
771
+ // V2.5: Check for active resumed thread (set by resume-thread command)
772
+ const activeResumeId = getActiveResumeThreadId();
773
+
774
+ // Check for resumed thread (V2 resume signal or V2.5 active resume)
775
+ const resumeThreadId = getResumeThreadId() || activeResumeId;
776
+ if (resumeThreadId) {
777
+ existing = findThreadById(threads, resumeThreadId);
778
+ if (existing) {
779
+ const related = existing.related_session_ids || [];
780
+ existing.related_session_ids = related;
781
+ if (!related.includes(sessionId)) related.push(sessionId);
782
+
783
+ const chain = existing.resume_chain || [];
784
+ existing.resume_chain = chain;
785
+ const prevSession = chain.length
786
+ ? chain[chain.length - 1].session_id
787
+ : existing.session_id || "";
788
+
789
+ const currentEntry = chain.find((e) => e.session_id === sessionId);
790
+ if (currentEntry) {
791
+ currentEntry.ended_at = now;
792
+ currentEntry.tool = aiTool;
793
+ } else {
794
+ chain.push({
795
+ session_id: sessionId,
796
+ tool: aiTool,
797
+ started_at: now,
798
+ ended_at: now,
799
+ resumed_from: prevSession,
800
+ });
801
+ }
802
+
803
+ // V2.5: Update contributors and resume metadata
804
+ const contributors = existing.contributors || [];
805
+ if (!contributors.includes(user)) {
806
+ contributors.push(user);
807
+ }
808
+ existing.contributors = contributors;
809
+ existing.resumed_by = user;
810
+ existing.resumed_at = now;
811
+
812
+ const resumeHistory = existing.resume_history || [];
813
+ resumeHistory.push({ user, at: now, session_id: sessionId });
814
+ existing.resume_history = resumeHistory;
815
+ }
816
+ clearResumeSignal();
817
+ clearActiveResumeThreadId();
818
+ }
819
+
820
+ // Fallback: find by session_id
821
+ if (!existing) {
822
+ for (const thread of threads) {
823
+ if (thread.session_id === sessionId) {
824
+ existing = thread;
825
+ break;
826
+ }
827
+ }
828
+ }
829
+
830
+ // Prompts from audit
831
+ const prompts = auditData.prompts || [];
832
+ const storedPrompts = prompts.length
833
+ ? prompts.slice(0, 20).map((p) => p.substring(0, 300))
834
+ : [];
835
+
836
+ if (existing) {
837
+ // Update existing thread
838
+ existing.files_touched = [
839
+ ...new Set([...(existing.files_touched || []), ...filesTouched]),
840
+ ];
841
+ existing.last_note = lastNote;
842
+ existing.tool = aiTool;
843
+ existing.work_type = sessionAnalysis.work_type;
844
+ existing.activities = sessionAnalysis.activities;
845
+ existing.last_activity_at = now;
846
+ // Copilot CLI fires SessionEnd per-turn, not per-session.
847
+ // Keep thread open so it isn't prematurely closed between turns.
848
+ if (!isCopilotCli) {
849
+ existing.status = "closed";
850
+ existing.closed_at = now;
851
+ }
852
+ existing.branch = branch || existing.branch || "";
853
+
854
+ // Merge tool call counts
855
+ const prevCalls = existing.tool_calls || {};
856
+ for (const [tn, cnt] of Object.entries(sessionAnalysis.tool_calls || {})) {
857
+ prevCalls[tn] = (prevCalls[tn] || 0) + cnt;
858
+ }
859
+ existing.tool_calls = prevCalls;
860
+
861
+ if (narrative) existing.narrative = narrative;
862
+ if (failed.length) existing.failed_approaches = failed;
863
+ if (storedPrompts.length) {
864
+ const prev = existing.prompts || [];
865
+ existing.prompts = [...prev, ...storedPrompts].slice(-20);
866
+ }
867
+ } else {
868
+ // Create new thread
869
+ threads.push({
870
+ id: crypto.randomUUID(),
871
+ user,
872
+ project: memory.project || "",
873
+ status: isCopilotCli ? "open" : "closed",
874
+ branch,
875
+ created_at: now,
876
+ closed_at: isCopilotCli ? null : now,
877
+ last_activity_at: now,
878
+ files_touched: filesTouched,
879
+ last_note: lastNote,
880
+ narrative,
881
+ failed_approaches: failed,
882
+ handoff_summary: "",
883
+ related_session_ids: [],
884
+ resume_chain: [],
885
+ tool: aiTool,
886
+ session_id: sessionId,
887
+ work_type: sessionAnalysis.work_type,
888
+ activities: sessionAnalysis.activities,
889
+ tool_calls: sessionAnalysis.tool_calls || {},
890
+ prompts: storedPrompts,
891
+ // V2.5 fields
892
+ contributors: [user],
893
+ resume_history: [],
894
+ });
895
+ }
896
+
897
+ // V2.5: Capture commit SHAs for attribution engine
898
+ const commitShas = getSessionCommitShas();
899
+
900
+ // Housekeeping
901
+ const threadStatus = isCopilotCli ? "open" : "closed";
902
+ tombstoneSweep(threads, staleDays);
903
+ const auditEntry = {
904
+ type: "session_end",
905
+ user,
906
+ ts: now,
907
+ session_id: sessionId,
908
+ status: threadStatus,
909
+ files_touched: filesTouched,
910
+ work_type: sessionAnalysis.work_type,
911
+ };
912
+ if (commitShas.length > 0) {
913
+ auditEntry.commit_shas = commitShas;
914
+ }
915
+ appendAuditLine(auditEntry);
916
+
917
+ // V2.5: Also write individual commit_sha audit entries for bridge lookup
918
+ for (const sha of commitShas) {
919
+ appendAuditLine({
920
+ type: "commit",
921
+ user,
922
+ ts: now,
923
+ session_id: sessionId,
924
+ commit_sha: sha,
925
+ });
926
+ }
927
+ // Only clear transient files for true session end (Claude Code).
928
+ // Copilot CLI fires per-turn; clearing would break state across turns.
929
+ if (!isCopilotCli) {
930
+ clearPresence(user);
931
+ clearSessionFile();
932
+ clearHeadFile();
933
+ clearInjectedSet();
934
+ }
935
+ saveJson(memoryPath, memory);
936
+
937
+ const fileCount = filesTouched.length;
938
+ const commitCount = commitShas.length;
939
+ const statusLabel = isCopilotCli ? "updated" : "closed";
940
+ const statusMsg = `[STICKY-NOTE] Session ${statusLabel} - thread ${isCopilotCli ? "updated" : "created"} (${fileCount} file${fileCount !== 1 ? "s" : ""}${commitCount > 0 ? ", " + commitCount + " commit" + (commitCount !== 1 ? "s" : "") : ""})`;
941
+ try {
942
+ process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
943
+ } catch (_) {
944
+ process.stdout.write('{"output":""}\n');
945
+ }
946
+ process.exit(0);
947
+ }
948
+
949
+ try {
950
+ main();
951
+ } catch (_) {
952
+ _safeExit();
953
+ }