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,525 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* inject-context.js — Relevance-Scored Thread Injection (V2)
|
|
5
|
+
*
|
|
6
|
+
* Hook: UserPromptSubmit (Claude Code) / userPromptSubmitted (Copilot CLI)
|
|
7
|
+
* Scores threads by file overlap, branch, recency, stuck status, same dev.
|
|
8
|
+
* Injects top 3-5 most relevant threads under a configurable token budget.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync } = require("child_process");
|
|
12
|
+
|
|
13
|
+
// ── Output helpers ────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function _isCopilotCli() {
|
|
16
|
+
return process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _emit(text) {
|
|
20
|
+
if (text === undefined) text = "";
|
|
21
|
+
if (_isCopilotCli()) {
|
|
22
|
+
process.stdout.write(JSON.stringify({ additionalContext: text }) + "\n");
|
|
23
|
+
} else {
|
|
24
|
+
process.stdout.write(JSON.stringify({ output: text }) + "\n");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _safeExit() {
|
|
29
|
+
try {
|
|
30
|
+
_emit("");
|
|
31
|
+
} catch (_) {
|
|
32
|
+
process.stdout.write('{"output": ""}\n');
|
|
33
|
+
}
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Import sticky-utils ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
let utils;
|
|
40
|
+
try {
|
|
41
|
+
utils = require("./sticky-utils.js");
|
|
42
|
+
} catch (_) {
|
|
43
|
+
_safeExit();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let gitNotes;
|
|
47
|
+
try {
|
|
48
|
+
gitNotes = require("./sticky-git-notes.js");
|
|
49
|
+
} catch (_) {
|
|
50
|
+
gitNotes = null;
|
|
51
|
+
}
|
|
52
|
+
const {
|
|
53
|
+
getMemoryPath, loadJson, saveJson, getUser, getBranch,
|
|
54
|
+
getResumeThreadId, findThreadById, getSessionId,
|
|
55
|
+
appendAuditLine, detectTool, getConfigPath,
|
|
56
|
+
isThreadInjected, markThreadInjected, normalizeSep,
|
|
57
|
+
} = utils;
|
|
58
|
+
|
|
59
|
+
// ── Git helpers ───────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function getRecentlyModifiedFiles() {
|
|
62
|
+
const files = new Set();
|
|
63
|
+
// Try HEAD~5 first; fall back to HEAD~1 for shallow repos
|
|
64
|
+
const diffTargets = ["HEAD~5", "HEAD~1"];
|
|
65
|
+
for (const target of diffTargets) {
|
|
66
|
+
try {
|
|
67
|
+
const result = execSync(`git diff --name-only ${target}`, {
|
|
68
|
+
encoding: "utf-8",
|
|
69
|
+
timeout: 5000,
|
|
70
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
73
|
+
const trimmed = f.trim();
|
|
74
|
+
if (trimmed) files.add(trimmed);
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
} catch (_) {
|
|
78
|
+
// target doesn't exist, try next
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const result = execSync("git diff --name-only", {
|
|
83
|
+
encoding: "utf-8",
|
|
84
|
+
timeout: 5000,
|
|
85
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
86
|
+
});
|
|
87
|
+
for (const f of result.trim().split(/\r?\n/)) {
|
|
88
|
+
const trimmed = f.trim();
|
|
89
|
+
if (trimmed) files.add(trimmed);
|
|
90
|
+
}
|
|
91
|
+
} catch (_) {
|
|
92
|
+
// ignore
|
|
93
|
+
}
|
|
94
|
+
return files;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Keyword extraction ────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function extractKeywords(prompt) {
|
|
100
|
+
const keywords = new Set();
|
|
101
|
+
const words = prompt.toLowerCase().replace(/[/\\.]/g, " ").split(/\s+/);
|
|
102
|
+
for (const word of words) {
|
|
103
|
+
const cleaned = word.replace(/[()[\]{}"'`,;:]/g, "");
|
|
104
|
+
if (cleaned.length >= 2) {
|
|
105
|
+
keywords.add(cleaned);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (let token of prompt.split(/\s+/)) {
|
|
109
|
+
token = token.replace(/^[()[\]{}"'`,;:]+/, "").replace(/[()[\]{}"'`,;:]+$/, "");
|
|
110
|
+
if (token.includes("/") || token.includes("\\") || token.includes(".")) {
|
|
111
|
+
keywords.add(token.toLowerCase());
|
|
112
|
+
const parts = normalizeSep(token).split("/");
|
|
113
|
+
for (const part of parts) {
|
|
114
|
+
if (part.length >= 2) {
|
|
115
|
+
keywords.add(part.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return keywords;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Scoring ───────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function scoreThread(thread, recentlyModified, currentBranch, currentUser, promptKeywords) {
|
|
126
|
+
if (thread.status === "expired" || thread.status === "stale") {
|
|
127
|
+
return -1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let score = 0.0;
|
|
131
|
+
const now = new Date();
|
|
132
|
+
|
|
133
|
+
// File overlap (weight 3)
|
|
134
|
+
const threadFiles = new Set(thread.files_touched || []);
|
|
135
|
+
let overlapCount = 0;
|
|
136
|
+
for (const f of threadFiles) {
|
|
137
|
+
if (recentlyModified.has(f)) overlapCount++;
|
|
138
|
+
}
|
|
139
|
+
if (overlapCount > 0) {
|
|
140
|
+
score += 3 * Math.min(overlapCount, 5);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Prompt keyword match against thread files
|
|
144
|
+
for (const filePath of threadFiles) {
|
|
145
|
+
const pathLower = filePath.toLowerCase();
|
|
146
|
+
const pathParts = new Set(normalizeSep(pathLower).replace(/\./g, "/").split("/"));
|
|
147
|
+
for (const kw of promptKeywords) {
|
|
148
|
+
if (pathLower.includes(kw) || pathParts.has(kw)) {
|
|
149
|
+
score += 1;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Branch match (weight 2)
|
|
156
|
+
if (currentBranch && thread.branch === currentBranch) {
|
|
157
|
+
score += 2;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Recency (weight 2) — decay over days
|
|
161
|
+
const tsField = thread.last_activity_at || thread.updated_at || thread.created_at || "";
|
|
162
|
+
if (tsField) {
|
|
163
|
+
try {
|
|
164
|
+
const ts = new Date(tsField.replace("Z", "+00:00"));
|
|
165
|
+
if (!isNaN(ts.getTime())) {
|
|
166
|
+
const daysAgo = Math.max((now - ts) / 86400000, 0);
|
|
167
|
+
const recency = Math.max(2 - daysAgo * 0.2, 0);
|
|
168
|
+
score += recency;
|
|
169
|
+
}
|
|
170
|
+
} catch (_) {
|
|
171
|
+
// ignore
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Stuck boost (+2)
|
|
176
|
+
if (thread.status === "stuck") {
|
|
177
|
+
score += 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Same developer (weight 1)
|
|
181
|
+
const threadUser = thread.user || thread.author || "";
|
|
182
|
+
if (threadUser === currentUser) {
|
|
183
|
+
score += 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return score;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Formatting ────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function _relativeTime(tsStr) {
|
|
192
|
+
try {
|
|
193
|
+
const ts = new Date(tsStr);
|
|
194
|
+
if (isNaN(ts.getTime())) return "";
|
|
195
|
+
const now = new Date();
|
|
196
|
+
const deltaMs = now - ts;
|
|
197
|
+
const hours = deltaMs / 3600000;
|
|
198
|
+
if (hours < 1) {
|
|
199
|
+
return Math.floor(deltaMs / 60000) + "min ago";
|
|
200
|
+
} else if (hours < 24) {
|
|
201
|
+
return Math.floor(hours) + "hrs ago";
|
|
202
|
+
} else {
|
|
203
|
+
return Math.floor(hours / 24) + "d ago";
|
|
204
|
+
}
|
|
205
|
+
} catch (_) {
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function formatThread(thread, detailed) {
|
|
211
|
+
const user = thread.user || thread.author || "unknown";
|
|
212
|
+
const status = thread.status || "open";
|
|
213
|
+
const statusTag = status === "stuck" ? "[STUCK]" : status === "open" ? "[OPEN]" : "[CLOSED]";
|
|
214
|
+
const fileLimit = detailed ? 3 : 2;
|
|
215
|
+
const files = (thread.files_touched || []).slice(0, fileLimit).join(", ");
|
|
216
|
+
|
|
217
|
+
let line = statusTag + " " + files + " . " + user;
|
|
218
|
+
|
|
219
|
+
if (!detailed) {
|
|
220
|
+
const note = (thread.last_note || "").substring(0, 60);
|
|
221
|
+
if (note) line += " -- " + note;
|
|
222
|
+
return line;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const branch = thread.branch || "";
|
|
226
|
+
if (branch) line += " . " + branch;
|
|
227
|
+
const tsField = thread.last_activity_at || thread.updated_at || "";
|
|
228
|
+
if (tsField) line += " . " + _relativeTime(tsField);
|
|
229
|
+
line += "\n";
|
|
230
|
+
|
|
231
|
+
if (thread.narrative) {
|
|
232
|
+
line += thread.narrative.substring(0, 200) + "\n";
|
|
233
|
+
} else if (thread.last_note) {
|
|
234
|
+
line += thread.last_note.substring(0, 150) + "\n";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const prompts = thread.prompts || [];
|
|
238
|
+
if (prompts.length > 0) {
|
|
239
|
+
line += "Conversation (" + prompts.length + " prompt(s)):\n";
|
|
240
|
+
for (let i = 0; i < Math.min(prompts.length, 5); i++) {
|
|
241
|
+
line += " " + (i + 1) + ". " + prompts[i].substring(0, 120) + "\n";
|
|
242
|
+
}
|
|
243
|
+
if (prompts.length > 5) line += " ... and " + (prompts.length - 5) + " more\n";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const failed = thread.failed_approaches || [];
|
|
247
|
+
if (failed.length > 0) {
|
|
248
|
+
line += failed.length + " failed approach(es).";
|
|
249
|
+
line += ' Full context: ask get_session_context("' + (thread.id || "") + '")\n';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return line.trim();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Main ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
function main() {
|
|
258
|
+
let hookInput = {};
|
|
259
|
+
try {
|
|
260
|
+
const raw = require("fs").readFileSync(0, "utf-8");
|
|
261
|
+
if (raw.trim()) {
|
|
262
|
+
hookInput = JSON.parse(raw);
|
|
263
|
+
}
|
|
264
|
+
} catch (_) {
|
|
265
|
+
hookInput = {};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const prompt = hookInput.prompt || hookInput.user_prompt || "";
|
|
269
|
+
if (!prompt) {
|
|
270
|
+
_emit("");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const sessionId = getSessionId(hookInput);
|
|
275
|
+
|
|
276
|
+
function _auditInject(result, threadsScored, threadsInjected, topScores, error) {
|
|
277
|
+
threadsScored = threadsScored || 0;
|
|
278
|
+
threadsInjected = threadsInjected || 0;
|
|
279
|
+
try {
|
|
280
|
+
const entry = {
|
|
281
|
+
type: "inject_result",
|
|
282
|
+
user: getUser(),
|
|
283
|
+
ts: new Date().toISOString(),
|
|
284
|
+
session_id: sessionId,
|
|
285
|
+
prompt: prompt.substring(0, 120),
|
|
286
|
+
result: result,
|
|
287
|
+
threads_scored: threadsScored,
|
|
288
|
+
threads_injected: threadsInjected,
|
|
289
|
+
};
|
|
290
|
+
if (topScores) entry.top_scores = topScores;
|
|
291
|
+
if (error) entry.error = String(error).substring(0, 200);
|
|
292
|
+
appendAuditLine(entry);
|
|
293
|
+
} catch (_) {
|
|
294
|
+
// ignore
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Audit the user prompt
|
|
299
|
+
try {
|
|
300
|
+
appendAuditLine({
|
|
301
|
+
type: "user_prompt",
|
|
302
|
+
user: getUser(),
|
|
303
|
+
ts: new Date().toISOString(),
|
|
304
|
+
session_id: sessionId,
|
|
305
|
+
prompt: prompt.substring(0, 500),
|
|
306
|
+
});
|
|
307
|
+
} catch (_) {
|
|
308
|
+
// ignore
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Auto-checkpoint: tag subsequent edits with what the user asked for
|
|
312
|
+
if (gitNotes) {
|
|
313
|
+
try {
|
|
314
|
+
gitNotes.saveCheckpoint({
|
|
315
|
+
topic: prompt.substring(0, 200),
|
|
316
|
+
user: getUser(),
|
|
317
|
+
ts: new Date().toISOString(),
|
|
318
|
+
session_id: sessionId,
|
|
319
|
+
auto: true,
|
|
320
|
+
});
|
|
321
|
+
} catch (_) {
|
|
322
|
+
// ignore — checkpoint is best-effort
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const memory = loadJson(getMemoryPath(), { version: "2", threads: [] });
|
|
327
|
+
const threads = memory.threads || [];
|
|
328
|
+
|
|
329
|
+
const live = threads.filter(
|
|
330
|
+
(t) => t.status === "open" || t.status === "stuck" || t.status === "closed"
|
|
331
|
+
);
|
|
332
|
+
if (live.length === 0) {
|
|
333
|
+
_auditInject("no_live_threads");
|
|
334
|
+
_emit("");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const keywords = extractKeywords(prompt);
|
|
339
|
+
const currentBranch = getBranch();
|
|
340
|
+
const currentUser = getUser();
|
|
341
|
+
const recentlyModified = getRecentlyModifiedFiles();
|
|
342
|
+
|
|
343
|
+
const resumeThreadId = getResumeThreadId();
|
|
344
|
+
let memoryDirty = false;
|
|
345
|
+
|
|
346
|
+
if (resumeThreadId) {
|
|
347
|
+
const resumed = findThreadById(threads, resumeThreadId);
|
|
348
|
+
if (resumed && resumed.status !== "open") {
|
|
349
|
+
resumed.status = "open";
|
|
350
|
+
resumed.last_activity_at = new Date().toISOString();
|
|
351
|
+
const related = resumed.related_session_ids || [];
|
|
352
|
+
if (!related.includes(sessionId)) {
|
|
353
|
+
related.push(sessionId);
|
|
354
|
+
}
|
|
355
|
+
resumed.related_session_ids = related;
|
|
356
|
+
const chain = resumed.resume_chain || [];
|
|
357
|
+
const alreadyInChain = chain.some((e) => e.session_id === sessionId);
|
|
358
|
+
if (!alreadyInChain) {
|
|
359
|
+
const aiTool = detectTool(hookInput);
|
|
360
|
+
const prevSession =
|
|
361
|
+
chain.length > 0
|
|
362
|
+
? chain[chain.length - 1].session_id
|
|
363
|
+
: resumed.session_id || "";
|
|
364
|
+
chain.push({
|
|
365
|
+
session_id: sessionId,
|
|
366
|
+
tool: aiTool,
|
|
367
|
+
started_at: new Date().toISOString(),
|
|
368
|
+
ended_at: null,
|
|
369
|
+
resumed_from: prevSession,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
resumed.resume_chain = chain;
|
|
373
|
+
memoryDirty = true;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Score threads
|
|
378
|
+
const scored = [];
|
|
379
|
+
for (const t of live) {
|
|
380
|
+
// V2.5: Skip threads already injected this session by PreToolUse or session-start
|
|
381
|
+
if (isThreadInjected(t.id)) continue;
|
|
382
|
+
|
|
383
|
+
let s = scoreThread(t, recentlyModified, currentBranch, currentUser, keywords);
|
|
384
|
+
if (resumeThreadId && t.id === resumeThreadId) {
|
|
385
|
+
s = Math.max(s, 0) + 10;
|
|
386
|
+
}
|
|
387
|
+
if (s > 0) {
|
|
388
|
+
scored.push([s, t]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (memoryDirty) {
|
|
393
|
+
try {
|
|
394
|
+
saveJson(getMemoryPath(), memory);
|
|
395
|
+
} catch (_) {
|
|
396
|
+
// ignore
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
scored.sort((a, b) => b[0] - a[0]);
|
|
401
|
+
|
|
402
|
+
// Debug / scoring block
|
|
403
|
+
const debugLines = [
|
|
404
|
+
"\n---\n[STICKY-NOTE] [scoring] prompt: " + prompt.substring(0, 80),
|
|
405
|
+
];
|
|
406
|
+
debugLines.push(
|
|
407
|
+
" keywords: " +
|
|
408
|
+
Array.from(keywords)
|
|
409
|
+
.sort()
|
|
410
|
+
.slice(0, 15)
|
|
411
|
+
.join(", ")
|
|
412
|
+
);
|
|
413
|
+
debugLines.push(" branch: " + currentBranch + " user: " + currentUser);
|
|
414
|
+
debugLines.push(
|
|
415
|
+
" recently_modified: " +
|
|
416
|
+
Array.from(recentlyModified)
|
|
417
|
+
.sort()
|
|
418
|
+
.slice(0, 10)
|
|
419
|
+
.join(", ")
|
|
420
|
+
);
|
|
421
|
+
debugLines.push(
|
|
422
|
+
" live threads: " + live.length + " scored>0: " + scored.length
|
|
423
|
+
);
|
|
424
|
+
for (const [s, t] of scored.slice(0, 10)) {
|
|
425
|
+
const tid = (t.id || "").substring(0, 8);
|
|
426
|
+
const st = t.status || "?";
|
|
427
|
+
const files = (t.files_touched || []).slice(0, 3);
|
|
428
|
+
const tool = t.tool || "?";
|
|
429
|
+
debugLines.push(
|
|
430
|
+
" " +
|
|
431
|
+
s.toFixed(1).padStart(5) +
|
|
432
|
+
" " +
|
|
433
|
+
tid +
|
|
434
|
+
" [" +
|
|
435
|
+
st +
|
|
436
|
+
"] " +
|
|
437
|
+
tool +
|
|
438
|
+
" files=" +
|
|
439
|
+
JSON.stringify(files)
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
const scoringBlock = debugLines.join("\n");
|
|
443
|
+
|
|
444
|
+
if (scored.length === 0) {
|
|
445
|
+
_auditInject("no_scored_threads", 0);
|
|
446
|
+
_emit("");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const config = loadJson(getConfigPath(), {});
|
|
451
|
+
const MAX_TOKENS = config.inject_token_budget || 1000;
|
|
452
|
+
const _estimateTokens = (str) => Math.floor(str.length / 4);
|
|
453
|
+
|
|
454
|
+
// Reserve budget for the scoring debug block and header overhead
|
|
455
|
+
const scoringTokens = _estimateTokens(scoringBlock);
|
|
456
|
+
const HEADER_RESERVE = 15; // "[STICKY NOTE -- N relevant thread(s)]"
|
|
457
|
+
let tokenCount = HEADER_RESERVE + scoringTokens;
|
|
458
|
+
|
|
459
|
+
const outputLines = [];
|
|
460
|
+
let threadsShown = 0;
|
|
461
|
+
|
|
462
|
+
for (let i = 0; i < Math.min(scored.length, 5); i++) {
|
|
463
|
+
const [score, thread] = scored[i];
|
|
464
|
+
const block =
|
|
465
|
+
i === 0 ? formatThread(thread, true) : formatThread(thread, false);
|
|
466
|
+
const blockTokens = _estimateTokens(block);
|
|
467
|
+
if (tokenCount + blockTokens > MAX_TOKENS) {
|
|
468
|
+
const remaining = scored.length - i;
|
|
469
|
+
if (remaining > 0) {
|
|
470
|
+
const overflowMsg = "... and " + remaining + " more relevant threads";
|
|
471
|
+
tokenCount += _estimateTokens(overflowMsg);
|
|
472
|
+
outputLines.push(overflowMsg);
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
tokenCount += blockTokens;
|
|
477
|
+
outputLines.push(block);
|
|
478
|
+
threadsShown++;
|
|
479
|
+
|
|
480
|
+
// V2.5: Mark as injected so PreToolUse won't re-inject
|
|
481
|
+
try { markThreadInjected(thread.id, sessionId); } catch (_) {}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const header =
|
|
485
|
+
"[STICKY-NOTE] " +
|
|
486
|
+
threadsShown +
|
|
487
|
+
" relevant thread" +
|
|
488
|
+
(threadsShown !== 1 ? "s" : "") +
|
|
489
|
+
" injected\n";
|
|
490
|
+
outputLines.unshift(header);
|
|
491
|
+
|
|
492
|
+
let output = outputLines.join("\n").trim();
|
|
493
|
+
output += scoringBlock;
|
|
494
|
+
|
|
495
|
+
_auditInject(
|
|
496
|
+
"injected",
|
|
497
|
+
scored.length,
|
|
498
|
+
threadsShown,
|
|
499
|
+
scored.slice(0, 5).map(([s, t]) => ({
|
|
500
|
+
id: (t.id || "").substring(0, 8),
|
|
501
|
+
score: Math.round(s * 10) / 10,
|
|
502
|
+
}))
|
|
503
|
+
);
|
|
504
|
+
_emit(output);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── Entry point ───────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
if (require.main === module) {
|
|
510
|
+
try {
|
|
511
|
+
main();
|
|
512
|
+
} catch (exc) {
|
|
513
|
+
try {
|
|
514
|
+
appendAuditLine({
|
|
515
|
+
type: "inject_result",
|
|
516
|
+
ts: new Date().toISOString(),
|
|
517
|
+
result: "error",
|
|
518
|
+
error: String(exc).substring(0, 200),
|
|
519
|
+
});
|
|
520
|
+
} catch (_) {
|
|
521
|
+
// ignore
|
|
522
|
+
}
|
|
523
|
+
_safeExit();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
/**
|
|
4
|
+
* on-error.js — Stuck Thread (V2)
|
|
5
|
+
*
|
|
6
|
+
* Hook: errorOccurred (Copilot CLI) / PostToolUseFailure (Claude Code)
|
|
7
|
+
* Writes a thread with status="stuck" and captures the error message.
|
|
8
|
+
* Appends JSONL audit line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
function _safeExit() {
|
|
14
|
+
try {
|
|
15
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
16
|
+
} catch (_) {
|
|
17
|
+
process.stdout.write('{"output":""}\n');
|
|
18
|
+
}
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let getMemoryPath, loadJson, saveJson, appendAuditLine, getUser, detectTool, getSessionId;
|
|
23
|
+
try {
|
|
24
|
+
({
|
|
25
|
+
getMemoryPath,
|
|
26
|
+
loadJson,
|
|
27
|
+
saveJson,
|
|
28
|
+
appendAuditLine,
|
|
29
|
+
getUser,
|
|
30
|
+
detectTool,
|
|
31
|
+
getSessionId,
|
|
32
|
+
} = require("./sticky-utils.js"));
|
|
33
|
+
} catch (_) {
|
|
34
|
+
_safeExit();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function main() {
|
|
38
|
+
let hookInput = {};
|
|
39
|
+
try {
|
|
40
|
+
if (!process.stdin.isTTY) {
|
|
41
|
+
const raw = require("fs").readFileSync(0, "utf-8").trim();
|
|
42
|
+
if (raw) {
|
|
43
|
+
hookInput = JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (_) {
|
|
47
|
+
hookInput = {};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sessionId = getSessionId(hookInput);
|
|
51
|
+
let toolName = hookInput.tool_name || process.env.TOOL_NAME || "unknown";
|
|
52
|
+
if (toolName === "unknown") {
|
|
53
|
+
toolName = detectTool(hookInput);
|
|
54
|
+
}
|
|
55
|
+
const errorMsg = (hookInput.error || hookInput.message || "Unknown error").substring(0, 200);
|
|
56
|
+
const user = getUser();
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
|
|
59
|
+
const memoryPath = getMemoryPath();
|
|
60
|
+
const memory = loadJson(memoryPath, { version: "2", project: "", threads: [] });
|
|
61
|
+
|
|
62
|
+
if (!Array.isArray(memory.threads)) {
|
|
63
|
+
memory.threads = [];
|
|
64
|
+
}
|
|
65
|
+
const threads = memory.threads;
|
|
66
|
+
|
|
67
|
+
let existing = null;
|
|
68
|
+
for (const thread of threads) {
|
|
69
|
+
if (thread.session_id === sessionId) {
|
|
70
|
+
existing = thread;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (existing) {
|
|
76
|
+
existing.status = "stuck";
|
|
77
|
+
existing.last_note = errorMsg;
|
|
78
|
+
existing.last_activity_at = now;
|
|
79
|
+
if (!Array.isArray(existing.failed_approaches)) {
|
|
80
|
+
existing.failed_approaches = [];
|
|
81
|
+
}
|
|
82
|
+
existing.failed_approaches.push({
|
|
83
|
+
description: errorMsg.substring(0, 150),
|
|
84
|
+
error: errorMsg.substring(0, 100),
|
|
85
|
+
});
|
|
86
|
+
// Cap at 5 entries
|
|
87
|
+
if (existing.failed_approaches.length > 5) {
|
|
88
|
+
existing.failed_approaches = existing.failed_approaches.slice(-5);
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
const thread = {
|
|
92
|
+
id: crypto.randomUUID(),
|
|
93
|
+
user: user,
|
|
94
|
+
project: memory.project || "",
|
|
95
|
+
status: "stuck",
|
|
96
|
+
branch: "",
|
|
97
|
+
created_at: now,
|
|
98
|
+
closed_at: null,
|
|
99
|
+
last_activity_at: now,
|
|
100
|
+
files_touched: [],
|
|
101
|
+
last_note: errorMsg,
|
|
102
|
+
narrative: "",
|
|
103
|
+
failed_approaches: [{ description: errorMsg.substring(0, 150), error: errorMsg.substring(0, 100) }],
|
|
104
|
+
handoff_summary: "",
|
|
105
|
+
related_session_ids: [],
|
|
106
|
+
tool: toolName,
|
|
107
|
+
session_id: sessionId,
|
|
108
|
+
};
|
|
109
|
+
threads.push(thread);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
appendAuditLine({
|
|
113
|
+
type: "error",
|
|
114
|
+
user: user,
|
|
115
|
+
ts: now,
|
|
116
|
+
session_id: sessionId,
|
|
117
|
+
error: errorMsg,
|
|
118
|
+
tool: toolName,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
saveJson(memoryPath, memory);
|
|
122
|
+
const statusMsg = `[STICKY-NOTE] Marked thread as STUCK - ${errorMsg.substring(0, 80)}`;
|
|
123
|
+
try {
|
|
124
|
+
process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
|
|
125
|
+
} catch (_) {
|
|
126
|
+
process.stdout.write('{"output":""}\n');
|
|
127
|
+
}
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
main();
|
|
133
|
+
} catch (_) {
|
|
134
|
+
_safeExit();
|
|
135
|
+
}
|