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,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
|
+
}
|