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,647 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // sticky-utils.js — Shared utilities for Sticky Note hooks.
4
+ // Copied into .sticky-note/hooks/ during `npx sticky-note init` and required by all hook scripts.
5
+ // Exports helpers for path resolution, JSON I/O, git operations, thread management, audit logging, and pattern matching.
6
+ // Do not edit this file directly in .sticky-note/hooks/ — edit the template at templates/hooks/sticky-utils.js instead.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const { execFileSync } = require("child_process");
11
+
12
+ // ── Cross-platform path normalization ──────────────────────
13
+
14
+ // Normalize path separators to forward slashes for consistent storage/comparison.
15
+ function normalizeSep(p) {
16
+ return typeof p === "string" ? p.replace(/\\/g, "/") : p;
17
+ }
18
+
19
+ // ── Shared regex patterns ─────────────────────────────────
20
+
21
+ const ERROR_PATTERNS = new RegExp(
22
+ "(error|exception|traceback|failed|fatal|panic|ENOENT|EACCES|" +
23
+ "segfault|undefined|TypeError|SyntaxError|ReferenceError|" +
24
+ "ImportError|ModuleNotFoundError|KeyError|ValueError|" +
25
+ "RuntimeError|ConnectionError|TimeoutError|PermissionError)",
26
+ "i"
27
+ );
28
+
29
+ const RETRY_PATTERNS = new RegExp(
30
+ "(try again|let me try|didn't work|doesn't work|failed|let's try|" +
31
+ "another approach|instead|alternatively|that approach|this doesn't|" +
32
+ "that didn't|won't work|can't|cannot)",
33
+ "i"
34
+ );
35
+
36
+ const FILE_PATH_PATTERN = /[\w./\\-]+\.\w{1,10}/g;
37
+
38
+ // ── Path helpers ──────────────────────────────────────────
39
+
40
+ function _stickyDir() {
41
+ const scriptDir = path.dirname(path.resolve(__filename));
42
+ return path.join(scriptDir, "..", "..", ".sticky-note");
43
+ }
44
+
45
+ const _paths = {
46
+ memory: "sticky-note.json",
47
+ config: "sticky-note-config.json",
48
+ auditDir: "audit",
49
+ presenceDir: "presence",
50
+ // Legacy single-file paths (for migration only)
51
+ legacyAudit: "sticky-note-audit.jsonl",
52
+ legacyPresence: ".sticky-presence.json",
53
+ resume: ".sticky-resume",
54
+ session: ".sticky-session",
55
+ head: ".sticky-head",
56
+ };
57
+ const _p = (key) => path.join(_stickyDir(), _paths[key]);
58
+
59
+ function getMemoryPath() { return _p("memory"); }
60
+ function getConfigPath() { return _p("config"); }
61
+ function getAuditDir() { return _p("auditDir"); }
62
+ function getPresenceDir() { return _p("presenceDir"); }
63
+ function getLegacyAuditPath() { return _p("legacyAudit"); }
64
+ function getLegacyPresencePath() { return _p("legacyPresence"); }
65
+
66
+ function getUserAuditPath(user) {
67
+ if (!user) user = getUser();
68
+ return path.join(getAuditDir(), user + ".jsonl");
69
+ }
70
+
71
+ function getUserPresencePath(user) {
72
+ if (!user) user = getUser();
73
+ return path.join(getPresenceDir(), user + ".json");
74
+ }
75
+
76
+ function getAllAuditPaths() {
77
+ const dir = getAuditDir();
78
+ try {
79
+ return fs.readdirSync(dir)
80
+ .filter((f) => f.endsWith(".jsonl"))
81
+ .map((f) => path.join(dir, f));
82
+ } catch (_) {
83
+ return [];
84
+ }
85
+ }
86
+
87
+ function getAllPresencePaths() {
88
+ const dir = getPresenceDir();
89
+ try {
90
+ return fs.readdirSync(dir)
91
+ .filter((f) => f.endsWith(".json"))
92
+ .map((f) => path.join(dir, f));
93
+ } catch (_) {
94
+ return [];
95
+ }
96
+ }
97
+
98
+ // Backwards-compatible aliases (read from all per-user files)
99
+ function getAuditPath() { return getUserAuditPath(); }
100
+ function getPresencePath() { return getUserPresencePath(); }
101
+ function getResumePath() { return _p("resume"); }
102
+ function getSessionFilePath() { return _p("session"); }
103
+ function getHeadFilePath() { return _p("head"); }
104
+
105
+ function saveSessionId(sessionId) {
106
+ const filePath = getSessionFilePath();
107
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
108
+ fs.writeFileSync(filePath, sessionId + "\n", "utf-8");
109
+ }
110
+
111
+ function readSessionIdFromFile() {
112
+ try {
113
+ const sid = fs.readFileSync(getSessionFilePath(), "utf-8").trim();
114
+ return sid || null;
115
+ } catch (_) {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function clearSessionFile() {
121
+ try {
122
+ fs.unlinkSync(getSessionFilePath());
123
+ } catch (_) {
124
+ // ignore
125
+ }
126
+ }
127
+
128
+ // ── Git HEAD snapshot (for files_touched fallback) ────────
129
+
130
+ function saveHeadSha() {
131
+ try {
132
+ const sha = execFileSync("git", ["rev-parse", "HEAD"], {
133
+ encoding: "utf-8",
134
+ timeout: 5000,
135
+ stdio: ["pipe", "pipe", "pipe"],
136
+ }).trim();
137
+ if (sha) {
138
+ const filePath = getHeadFilePath();
139
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
140
+ fs.writeFileSync(filePath, sha + "\n", "utf-8");
141
+ }
142
+ } catch (_) {
143
+ // ignore
144
+ }
145
+ }
146
+
147
+ function readHeadSha() {
148
+ try {
149
+ const sha = fs.readFileSync(getHeadFilePath(), "utf-8").trim();
150
+ return sha || null;
151
+ } catch (_) {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function clearHeadFile() {
157
+ try {
158
+ fs.unlinkSync(getHeadFilePath());
159
+ } catch (_) {
160
+ // ignore
161
+ }
162
+ }
163
+
164
+ // ── Thread resume helpers ─────────────────────────────────
165
+
166
+ function getResumeThreadId() {
167
+ try {
168
+ const threadId = fs.readFileSync(getResumePath(), "utf-8").trim();
169
+ return threadId || null;
170
+ } catch (_) {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ function findThreadById(threads, threadId) {
176
+ for (const thread of threads) {
177
+ if (thread.id === threadId) {
178
+ return thread;
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function clearResumeSignal() {
185
+ try {
186
+ fs.unlinkSync(getResumePath());
187
+ } catch (_) {
188
+ // ignore
189
+ }
190
+ }
191
+
192
+ // ── JSON I/O ─────────────────────────────────────────────
193
+
194
+ function loadJson(filePath, defaultVal) {
195
+ try {
196
+ const raw = fs.readFileSync(filePath, "utf-8");
197
+ return JSON.parse(raw);
198
+ } catch (_) {
199
+ return defaultVal !== undefined ? defaultVal : {};
200
+ }
201
+ }
202
+
203
+ function saveJson(filePath, data) {
204
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
205
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8");
206
+ }
207
+
208
+ function appendAuditLine(entry) {
209
+ const filePath = getUserAuditPath();
210
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
211
+ fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
212
+ }
213
+
214
+ // ── Environment helpers ───────────────────────────────────
215
+
216
+ function getUser() {
217
+ return process.env.USER || process.env.USERNAME || "unknown";
218
+ }
219
+
220
+ function detectTool(hookInput) {
221
+ if (process.argv.includes("--copilot-cli")) {
222
+ return "copilot-cli";
223
+ }
224
+ if (process.env.COPILOT_CLI) {
225
+ return "copilot-cli";
226
+ }
227
+ if (hookInput) {
228
+ const event = hookInput.hook_event_name || "";
229
+ if (event && event[0] === event[0].toUpperCase() && event[0] !== event[0].toLowerCase()) {
230
+ return "claude-code";
231
+ }
232
+ if (event && event[0] === event[0].toLowerCase() && event[0] !== event[0].toUpperCase()) {
233
+ return "copilot-cli";
234
+ }
235
+ if ("transcript_path" in hookInput) {
236
+ return "claude-code";
237
+ }
238
+ }
239
+ return "unknown";
240
+ }
241
+
242
+ function getSessionId(hookInput) {
243
+ if (hookInput) {
244
+ for (const key of ["session_id", "sessionId", "session"]) {
245
+ const val = hookInput[key];
246
+ if (val && val !== "unknown") {
247
+ return val;
248
+ }
249
+ }
250
+ }
251
+ for (const envKey of ["SESSION_ID", "GITHUB_COPILOT_SESSION_ID", "WT_SESSION"]) {
252
+ const val = process.env[envKey];
253
+ if (val) {
254
+ return val;
255
+ }
256
+ }
257
+ const fileSid = readSessionIdFromFile();
258
+ if (fileSid) {
259
+ return fileSid;
260
+ }
261
+ return "unknown";
262
+ }
263
+
264
+ function getBranch() {
265
+ try {
266
+ const result = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
267
+ encoding: "utf-8",
268
+ timeout: 5000,
269
+ stdio: ["pipe", "pipe", "pipe"],
270
+ }).trim();
271
+ return result;
272
+ } catch (_) {
273
+ return "";
274
+ }
275
+ }
276
+
277
+ // ── Transcript parsing helpers ────────────────────────────
278
+
279
+ function parseJsonlFile(filePath) {
280
+ const entries = [];
281
+ try {
282
+ const raw = fs.readFileSync(filePath, "utf-8");
283
+ for (const line of raw.split(/\r?\n/)) {
284
+ const trimmed = line.trim();
285
+ if (!trimmed) continue;
286
+ try {
287
+ entries.push(JSON.parse(trimmed));
288
+ } catch (_) {
289
+ continue;
290
+ }
291
+ }
292
+ } catch (_) {
293
+ // ignore
294
+ }
295
+ return entries;
296
+ }
297
+
298
+ function extractNarrativeFromEntries(entries) {
299
+ const lastTexts = [];
300
+ for (const entry of entries) {
301
+ const message = entry.message;
302
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
303
+ continue;
304
+ }
305
+ const role = message.role || entry.role || "";
306
+ if (role !== "assistant") continue;
307
+ const content = message.content || entry.content || [];
308
+ if (Array.isArray(content)) {
309
+ for (const block of content) {
310
+ if (block && typeof block === "object" && block.type === "text") {
311
+ const text = (block.text || "").trim();
312
+ if (text) {
313
+ lastTexts.push(text);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ if (lastTexts.length === 0) return "";
320
+ return lastTexts[lastTexts.length - 1].substring(0, 300).trim();
321
+ }
322
+
323
+ function extractNarrativeFromText(lines) {
324
+ const textBlocks = [];
325
+ let currentBlock = [];
326
+
327
+ for (const line of lines) {
328
+ const stripped = line.trim();
329
+ if (stripped) {
330
+ currentBlock.push(stripped);
331
+ } else if (currentBlock.length) {
332
+ textBlocks.push(currentBlock.join(" "));
333
+ currentBlock = [];
334
+ }
335
+ }
336
+ if (currentBlock.length) {
337
+ textBlocks.push(currentBlock.join(" "));
338
+ }
339
+
340
+ if (textBlocks.length === 0) return "";
341
+
342
+ for (let i = textBlocks.length - 1; i >= 0; i--) {
343
+ if (textBlocks[i].length > 20) {
344
+ return textBlocks[i].substring(0, 300).trim();
345
+ }
346
+ }
347
+ return "";
348
+ }
349
+
350
+ function extractFailedFromEntries(entries) {
351
+ const approaches = [];
352
+
353
+ for (const entry of entries) {
354
+ const message = entry.message;
355
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
356
+ continue;
357
+ }
358
+ const content = message.content || entry.content || [];
359
+ if (!Array.isArray(content)) continue;
360
+
361
+ for (const block of content) {
362
+ if (!block || typeof block !== "object" || block.type !== "text") {
363
+ continue;
364
+ }
365
+ const text = block.text || "";
366
+ if (RETRY_PATTERNS.test(text) && ERROR_PATTERNS.test(text)) {
367
+ const errorMatch = ERROR_PATTERNS.exec(text);
368
+ let errorCtx = "";
369
+ if (errorMatch) {
370
+ const start = Math.max(0, errorMatch.index - 40);
371
+ errorCtx = text.substring(start, errorMatch.index + errorMatch[0].length + 60).trim();
372
+ }
373
+ // Reset lastIndex for the global regex before using it
374
+ FILE_PATH_PATTERN.lastIndex = 0;
375
+ const filesTried = [];
376
+ let m;
377
+ while ((m = FILE_PATH_PATTERN.exec(text)) !== null && filesTried.length < 5) {
378
+ filesTried.push(m[0]);
379
+ }
380
+ approaches.push({
381
+ description: text.substring(0, 150).trim(),
382
+ error: errorCtx.substring(0, 100),
383
+ files_tried: filesTried,
384
+ });
385
+ }
386
+ }
387
+ }
388
+
389
+ return approaches.slice(0, 5);
390
+ }
391
+
392
+ function extractFailedFromText(lines) {
393
+ const approaches = [];
394
+ const fullText = lines.join("\n");
395
+ const paragraphs = fullText.split("\n\n");
396
+
397
+ for (const para of paragraphs) {
398
+ if (RETRY_PATTERNS.test(para) && ERROR_PATTERNS.test(para)) {
399
+ const errorMatch = ERROR_PATTERNS.exec(para);
400
+ let errorCtx = "";
401
+ if (errorMatch) {
402
+ const start = Math.max(0, errorMatch.index - 40);
403
+ errorCtx = para.substring(start, errorMatch.index + errorMatch[0].length + 60).trim();
404
+ }
405
+ FILE_PATH_PATTERN.lastIndex = 0;
406
+ const filesTried = [];
407
+ let m;
408
+ while ((m = FILE_PATH_PATTERN.exec(para)) !== null && filesTried.length < 5) {
409
+ filesTried.push(m[0]);
410
+ }
411
+ approaches.push({
412
+ description: para.substring(0, 150).trim(),
413
+ error: errorCtx.substring(0, 100),
414
+ files_tried: filesTried,
415
+ });
416
+ }
417
+ }
418
+
419
+ return approaches.slice(0, 5);
420
+ }
421
+
422
+ // ── Audit trail extraction ────────────────────────────────
423
+
424
+ function extractSessionFromAudit(sessionId) {
425
+ const result = { prompts: [], tools: {}, files: [], first_prompt: null };
426
+ if (!sessionId || sessionId === "unknown") return result;
427
+
428
+ const auditPaths = getAllAuditPaths();
429
+ if (auditPaths.length === 0) return result;
430
+
431
+ for (const auditPath of auditPaths) {
432
+ try {
433
+ const raw = fs.readFileSync(auditPath, "utf-8");
434
+ for (const line of raw.split(/\r?\n/)) {
435
+ const trimmed = line.trim();
436
+ if (!trimmed) continue;
437
+ let entry;
438
+ try {
439
+ entry = JSON.parse(trimmed);
440
+ } catch (_) {
441
+ continue;
442
+ }
443
+ if (entry.session_id !== sessionId) continue;
444
+
445
+ const entryType = entry.type || "";
446
+ if (entryType === "user_prompt") {
447
+ const prompt = (entry.prompt || "").trim();
448
+ if (prompt) {
449
+ result.prompts.push(prompt);
450
+ if (result.first_prompt === null) {
451
+ result.first_prompt = prompt;
452
+ }
453
+ }
454
+ } else if (entryType === "tool_use") {
455
+ const toolName = entry.tool || "";
456
+ if (toolName) {
457
+ result.tools[toolName] = (result.tools[toolName] || 0) + 1;
458
+ }
459
+ const filePath = entry.file || "";
460
+ if (filePath && !result.files.includes(filePath)) {
461
+ result.files.push(filePath);
462
+ }
463
+ }
464
+ }
465
+ } catch (_) {
466
+ // ignore individual file failures
467
+ }
468
+ }
469
+
470
+ return result;
471
+ }
472
+
473
+ // ── Migration: single-file → per-user ─────────────────────
474
+
475
+ function migrateAuditAndPresence() {
476
+ const user = getUser();
477
+
478
+ // Migrate legacy audit file
479
+ const legacyAudit = getLegacyAuditPath();
480
+ try {
481
+ if (fs.existsSync(legacyAudit)) {
482
+ const dest = getUserAuditPath(user);
483
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
484
+ if (fs.existsSync(dest)) {
485
+ // Append legacy content to existing per-user file
486
+ const content = fs.readFileSync(legacyAudit, "utf-8");
487
+ fs.appendFileSync(dest, content);
488
+ } else {
489
+ fs.renameSync(legacyAudit, dest);
490
+ }
491
+ // Clean up if rename didn't delete it
492
+ try { if (fs.existsSync(legacyAudit)) fs.unlinkSync(legacyAudit); } catch (_) {}
493
+ }
494
+ } catch (_) {
495
+ // ignore migration failures
496
+ }
497
+
498
+ // Migrate legacy presence file
499
+ const legacyPresence = getLegacyPresencePath();
500
+ try {
501
+ if (fs.existsSync(legacyPresence)) {
502
+ const data = loadJson(legacyPresence, {});
503
+ const presenceDir = getPresenceDir();
504
+ fs.mkdirSync(presenceDir, { recursive: true });
505
+ // Split presence data: each key is a username
506
+ for (const [u, info] of Object.entries(data)) {
507
+ const dest = getUserPresencePath(u);
508
+ saveJson(dest, info);
509
+ }
510
+ fs.unlinkSync(legacyPresence);
511
+ }
512
+ } catch (_) {
513
+ // ignore migration failures
514
+ }
515
+ }
516
+
517
+ // ── Injected-this-session tracking (V2.5) ─────────────────
518
+
519
+ const _injectedPaths = {
520
+ injected: ".sticky-injected",
521
+ };
522
+ const _injectedP = (key) => path.join(_stickyDir(), _injectedPaths[key]);
523
+
524
+ function getInjectedPath() { return _injectedP("injected"); }
525
+
526
+ function loadInjectedSet() {
527
+ try {
528
+ const data = loadJson(getInjectedPath(), { thread_ids: [], session_id: null });
529
+ return data;
530
+ } catch (_) {
531
+ return { thread_ids: [], session_id: null };
532
+ }
533
+ }
534
+
535
+ function saveInjectedSet(data) {
536
+ saveJson(getInjectedPath(), data);
537
+ }
538
+
539
+ function clearInjectedSet() {
540
+ try {
541
+ fs.unlinkSync(getInjectedPath());
542
+ } catch (_) {
543
+ // ignore
544
+ }
545
+ }
546
+
547
+ function isThreadInjected(threadId) {
548
+ const data = loadInjectedSet();
549
+ return (data.thread_ids || []).includes(threadId);
550
+ }
551
+
552
+ function markThreadInjected(threadId, sessionId) {
553
+ const data = loadInjectedSet();
554
+ data.session_id = sessionId || data.session_id;
555
+ if (!(data.thread_ids || []).includes(threadId)) {
556
+ data.thread_ids = data.thread_ids || [];
557
+ data.thread_ids.push(threadId);
558
+ }
559
+ saveInjectedSet(data);
560
+ }
561
+
562
+ // ── Active resumed thread tracking (V2.5) ─────────────────
563
+
564
+ const _activeResumePath = () => path.join(_stickyDir(), ".sticky-active-resume");
565
+
566
+ function getActiveResumeThreadId() {
567
+ try {
568
+ const raw = fs.readFileSync(_activeResumePath(), "utf-8").trim();
569
+ return raw || null;
570
+ } catch (_) {
571
+ return null;
572
+ }
573
+ }
574
+
575
+ function setActiveResumeThreadId(threadId) {
576
+ const filePath = _activeResumePath();
577
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
578
+ fs.writeFileSync(filePath, threadId + "\n", "utf-8");
579
+ }
580
+
581
+ function clearActiveResumeThreadId() {
582
+ try {
583
+ fs.unlinkSync(_activeResumePath());
584
+ } catch (_) {
585
+ // ignore
586
+ }
587
+ }
588
+
589
+ // ── Exports ───────────────────────────────────────────────
590
+
591
+ module.exports = {
592
+ ERROR_PATTERNS,
593
+ RETRY_PATTERNS,
594
+ FILE_PATH_PATTERN,
595
+ getMemoryPath,
596
+ getConfigPath,
597
+ getAuditPath,
598
+ getAuditDir,
599
+ getUserAuditPath,
600
+ getAllAuditPaths,
601
+ getPresencePath,
602
+ getPresenceDir,
603
+ getUserPresencePath,
604
+ getAllPresencePaths,
605
+ getLegacyAuditPath,
606
+ getLegacyPresencePath,
607
+ getResumePath,
608
+ getSessionFilePath,
609
+ saveSessionId,
610
+ readSessionIdFromFile,
611
+ clearSessionFile,
612
+ getHeadFilePath,
613
+ saveHeadSha,
614
+ readHeadSha,
615
+ clearHeadFile,
616
+ getResumeThreadId,
617
+ findThreadById,
618
+ clearResumeSignal,
619
+ loadJson,
620
+ saveJson,
621
+ appendAuditLine,
622
+ getUser,
623
+ detectTool,
624
+ getSessionId,
625
+ getBranch,
626
+ parseJsonlFile,
627
+ extractNarrativeFromEntries,
628
+ extractNarrativeFromText,
629
+ extractFailedFromEntries,
630
+ extractFailedFromText,
631
+ extractSessionFromAudit,
632
+ migrateAuditAndPresence,
633
+ // V2.5: injected-this-session tracking
634
+ getInjectedPath,
635
+ loadInjectedSet,
636
+ saveInjectedSet,
637
+ clearInjectedSet,
638
+ isThreadInjected,
639
+ markThreadInjected,
640
+ // V2.5: active resumed thread
641
+ getActiveResumeThreadId,
642
+ setActiveResumeThreadId,
643
+ clearActiveResumeThreadId,
644
+ // V2.5: cross-platform path helper
645
+ normalizeSep,
646
+ };
647
+ // tier1 test