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/bin/cli.js ADDED
@@ -0,0 +1,1941 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * sticky-note CLI — V2
5
+ *
6
+ * Commands:
7
+ * npx sticky-note init Interactive setup, creates V2 hook files
8
+ * npx sticky-note update Update scripts only
9
+ * npx sticky-note status Diagnostic: threads, audit, hook health
10
+ * npx sticky-note threads List open/stuck threads
11
+ * npx sticky-note audit Query audit trail (JSONL)
12
+ * npx sticky-note gc Manual tombstone sweep
13
+ */
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const readline = require("readline");
18
+ const { execSync, execFileSync } = require("child_process");
19
+
20
+ // ──────────────────────────────────────────────
21
+ // Constants
22
+ // ──────────────────────────────────────────────
23
+
24
+ const VERSION = "2.5.0";
25
+
26
+ const HOOK_FILES = [
27
+ "sticky-utils.js",
28
+ "session-start.js",
29
+ "session-end.js",
30
+ "inject-context.js",
31
+ "track-work.js",
32
+ "on-stop.js",
33
+ "on-error.js",
34
+ "parse-transcript.js",
35
+ "pre-tool-use.js",
36
+ "sticky-git-notes.js",
37
+ "sticky-attribution.js",
38
+ ];
39
+
40
+ const TEMPLATES_DIR = path.join(__dirname, "..", "templates");
41
+
42
+ // ──────────────────────────────────────────────
43
+ // Helpers
44
+ // ──────────────────────────────────────────────
45
+
46
+ function print(msg) {
47
+ process.stdout.write(msg + "\n");
48
+ }
49
+
50
+ function debugLog(msg) {
51
+ if (process.env.STICKY_DEBUG) {
52
+ process.stderr.write(`[sticky-note] ${msg}\n`);
53
+ }
54
+ }
55
+
56
+ function printBanner() {
57
+ print("");
58
+ print(` 📌 sticky-note v${VERSION}`);
59
+ print(" Human-to-human handoff for AI coding assistants");
60
+ print("");
61
+ }
62
+
63
+ function isGitRepo() {
64
+ try {
65
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function mkdirSafe(dir) {
73
+ if (!fs.existsSync(dir)) {
74
+ fs.mkdirSync(dir, { recursive: true });
75
+ }
76
+ }
77
+
78
+ function copyFile(src, dest) {
79
+ fs.copyFileSync(src, dest);
80
+ }
81
+
82
+ function readTemplate(name) {
83
+ return fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf-8");
84
+ }
85
+
86
+ function makeExecutable(filePath) {
87
+ try {
88
+ fs.chmodSync(filePath, 0o755);
89
+ } catch {
90
+ // Windows doesn't support chmod — that's fine
91
+ }
92
+ }
93
+
94
+ function installGitHook(hookName) {
95
+ try {
96
+ const gitDir = path.join(process.cwd(), ".git");
97
+ if (!fs.existsSync(gitDir)) return false;
98
+ const hooksDir = path.join(gitDir, "hooks");
99
+ mkdirSafe(hooksDir);
100
+ const src = path.join(TEMPLATES_DIR, "hooks", hookName + ".js");
101
+ if (!fs.existsSync(src)) return false;
102
+
103
+ if (process.platform === "win32") {
104
+ // Windows: create a .bat shim that invokes node with the hook script
105
+ const shimContent =
106
+ `@echo off\r\nnode "%~dp0..\\..\\templates\\hooks\\${hookName}.js" %*\r\n`;
107
+ const dest = path.join(hooksDir, hookName);
108
+ fs.writeFileSync(dest, shimContent, "utf-8");
109
+ } else {
110
+ // Unix: create a sh shim
111
+ const shimContent =
112
+ `#!/bin/sh\nnode "$(dirname "$0")/../../templates/hooks/${hookName}.js" "$@"\n`;
113
+ const dest = path.join(hooksDir, hookName);
114
+ fs.writeFileSync(dest, shimContent, "utf-8");
115
+ makeExecutable(dest);
116
+ }
117
+ return true;
118
+ } catch (err) {
119
+ debugLog("installGitHook failed for " + hookName + ": " + (err.message || err));
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function ask(rl, question, defaultVal) {
125
+ return new Promise((resolve) => {
126
+ const suffix = defaultVal !== undefined ? ` (${defaultVal})` : "";
127
+ rl.question(` ${question}${suffix}: `, (answer) => {
128
+ resolve(answer.trim() || (defaultVal !== undefined ? String(defaultVal) : ""));
129
+ });
130
+ });
131
+ }
132
+
133
+ function readJsonSafe(filePath, fallback) {
134
+ try {
135
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
136
+ } catch (err) {
137
+ debugLog("readJsonSafe failed for " + filePath + ": " + (err.message || err));
138
+ return fallback;
139
+ }
140
+ }
141
+
142
+ function parseIntOr(str, fallback) {
143
+ const n = parseInt(str, 10);
144
+ return Number.isNaN(n) ? fallback : n;
145
+ }
146
+
147
+ const SECTION_START = "<!-- sticky-note:start";
148
+ const SECTION_END = "<!-- sticky-note:end -->";
149
+
150
+ /**
151
+ * Update the sticky-note section in a deployed instruction file.
152
+ * If markers exist, replaces content between them.
153
+ * If no markers but a known sticky-note heading exists, replaces from that heading onward.
154
+ * If neither, appends the template content.
155
+ * Returns true if the file was modified.
156
+ */
157
+ function updateInstructionSection(destPath, templateContent, heading) {
158
+ if (!fs.existsSync(destPath)) {
159
+ fs.writeFileSync(destPath, templateContent);
160
+ return true;
161
+ }
162
+
163
+ const existing = fs.readFileSync(destPath, "utf-8");
164
+ const startIdx = existing.indexOf(SECTION_START);
165
+ const endIdx = existing.indexOf(SECTION_END);
166
+
167
+ if (startIdx !== -1 && endIdx !== -1) {
168
+ // Replace between markers (inclusive)
169
+ const before = existing.slice(0, startIdx);
170
+ const after = existing.slice(endIdx + SECTION_END.length);
171
+ fs.writeFileSync(destPath, before + templateContent.trim() + after);
172
+ } else if (heading && existing.includes(heading)) {
173
+ // Migration: old file without markers — replace from known heading onward
174
+ const headingIdx = existing.indexOf(heading);
175
+ const before = existing.slice(0, headingIdx);
176
+ fs.writeFileSync(destPath, before + templateContent);
177
+ } else {
178
+ // No markers, no known heading — append with a blank line separator
179
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n";
180
+ fs.writeFileSync(destPath, existing + sep + templateContent);
181
+ }
182
+ return true;
183
+ }
184
+
185
+ function countJsonlLines(filePath) {
186
+ if (!fs.existsSync(filePath)) return 0;
187
+ try {
188
+ const content = fs.readFileSync(filePath, "utf-8");
189
+ return content.split(/\r?\n/).filter((l) => l.trim()).length;
190
+ } catch {
191
+ return 0;
192
+ }
193
+ }
194
+
195
+ // ──────────────────────────────────────────────
196
+ // Auto-detection
197
+ // ──────────────────────────────────────────────
198
+
199
+ function detectMcpServers() {
200
+ const servers = new Map();
201
+
202
+ const mcpJsonPath = path.join(process.cwd(), ".mcp.json");
203
+ if (fs.existsSync(mcpJsonPath)) {
204
+ try {
205
+ const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
206
+ const mcpServers = mcpJson.mcpServers || {};
207
+ for (const [name, config] of Object.entries(mcpServers)) {
208
+ const entry = {
209
+ name,
210
+ type: config.type || config.transport || "unknown",
211
+ source: ".mcp.json",
212
+ };
213
+ if (config.command) entry.command = config.command;
214
+ if (config.args) entry.args = config.args;
215
+ if (config.env) entry.env = config.env;
216
+ if (config.url) entry.url = config.url;
217
+ servers.set(name, entry);
218
+ }
219
+ } catch (err) { debugLog("detectMcpServers settings.json: " + (err.message || err)); }
220
+ }
221
+
222
+ const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
223
+ if (fs.existsSync(localSettingsPath)) {
224
+ try {
225
+ const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, "utf-8"));
226
+ const permissions = [
227
+ ...(localSettings.allow || []),
228
+ ...(localSettings.permissions?.allow || []),
229
+ ];
230
+ for (const perm of permissions) {
231
+ if (typeof perm === "string" && perm.startsWith("mcp__")) {
232
+ const serverName = perm.split("__")[1];
233
+ if (serverName && !servers.has(serverName)) {
234
+ servers.set(serverName, {
235
+ name: serverName,
236
+ type: "permission-detected",
237
+ source: "settings.local.json",
238
+ });
239
+ }
240
+ }
241
+ }
242
+ } catch (err) { debugLog("detectMcpServers settings.local.json: " + (err.message || err)); }
243
+ }
244
+
245
+ return Array.from(servers.values());
246
+ }
247
+
248
+ function detectSkills() {
249
+ const skills = new Set();
250
+
251
+ const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
252
+ if (fs.existsSync(localSettingsPath)) {
253
+ try {
254
+ const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, "utf-8"));
255
+ const permissions = [
256
+ ...(localSettings.allow || []),
257
+ ...(localSettings.permissions?.allow || []),
258
+ ];
259
+ for (const perm of permissions) {
260
+ if (typeof perm === "string") {
261
+ const match = perm.match(/^Skill\(([^)]+)\)/);
262
+ if (match) {
263
+ skills.add(match[1]);
264
+ }
265
+ }
266
+ }
267
+ } catch (err) { debugLog("detectSkills: " + (err.message || err)); }
268
+ }
269
+
270
+ return Array.from(skills);
271
+ }
272
+
273
+ // ──────────────────────────────────────────────
274
+ // MCP Sync from Sticky Note
275
+ // ──────────────────────────────────────────────
276
+
277
+ async function syncMcpFromStickyNote(rl) {
278
+ // V2: config is in a separate file
279
+ const configPath = path.join(process.cwd(), ".sticky-note", "sticky-note-config.json");
280
+ let config;
281
+ if (fs.existsSync(configPath)) {
282
+ config = readJsonSafe(configPath, {});
283
+ } else {
284
+ return;
285
+ }
286
+
287
+ const teamServers = config.mcp_servers || [];
288
+ const provisionable = teamServers.filter(
289
+ (s) => typeof s === "object" && (s.command || s.url)
290
+ );
291
+ if (provisionable.length === 0) return;
292
+
293
+ const mcpJsonPath = path.join(process.cwd(), ".mcp.json");
294
+ let existingServers = {};
295
+ if (fs.existsSync(mcpJsonPath)) {
296
+ try {
297
+ existingServers = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8")).mcpServers || {};
298
+ } catch (err) { debugLog("syncMcp read .mcp.json: " + (err.message || err)); }
299
+ }
300
+
301
+ const missing = provisionable.filter((s) => !existingServers[s.name]);
302
+ if (missing.length === 0) return;
303
+
304
+ print(`\n 🔄 Teammate MCP servers found in sticky-note:`);
305
+ for (const s of missing) {
306
+ const detail = s.command ? `${s.command} ${(s.args || []).join(" ")}` : s.url;
307
+ print(` • ${s.name} (${detail})`);
308
+ }
309
+
310
+ const answer = await ask(rl, "Add these to your .mcp.json?", "yes");
311
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
312
+ print(" ⏭️ Skipped MCP sync");
313
+ return;
314
+ }
315
+
316
+ let mcpJson = { mcpServers: {} };
317
+ if (fs.existsSync(mcpJsonPath)) {
318
+ try {
319
+ mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
320
+ mcpJson.mcpServers = mcpJson.mcpServers || {};
321
+ } catch (err) { debugLog("syncMcp parse .mcp.json: " + (err.message || err)); }
322
+ }
323
+
324
+ for (const s of missing) {
325
+ const config = { type: s.type || "stdio" };
326
+ if (s.command) config.command = s.command;
327
+ if (s.args) config.args = s.args;
328
+ if (s.env) config.env = s.env;
329
+ if (s.url) config.url = s.url;
330
+ mcpJson.mcpServers[s.name] = config;
331
+ print(` [OK] Added ${s.name} to .mcp.json`);
332
+ }
333
+
334
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
335
+ }
336
+
337
+ // ──────────────────────────────────────────────
338
+ // INIT command
339
+ // ──────────────────────────────────────────────
340
+
341
+ async function cmdInit() {
342
+ printBanner();
343
+
344
+ // Preflight checks
345
+ print(" Preflight checks...");
346
+
347
+ if (!isGitRepo()) {
348
+ print(" [ERR] Not a git repository. Run `git init` first.");
349
+ process.exit(1);
350
+ }
351
+ print(" [OK] Git repository detected");
352
+ print("");
353
+
354
+ // Auto-detect MCP servers and skills
355
+ print(" 🔍 Scanning for existing configuration...");
356
+ const detectedMcp = detectMcpServers();
357
+ const detectedSkills = detectSkills();
358
+
359
+ if (detectedMcp.length > 0) {
360
+ print(` 📡 Found ${detectedMcp.length} MCP server(s):`);
361
+ for (const s of detectedMcp) {
362
+ print(` • ${s.name} (${s.source})`);
363
+ }
364
+ }
365
+ if (detectedSkills.length > 0) {
366
+ print(` 🧩 Found ${detectedSkills.length} skill(s):`);
367
+ for (const s of detectedSkills) {
368
+ print(` • ${s}`);
369
+ }
370
+ }
371
+ if (detectedMcp.length === 0 && detectedSkills.length === 0) {
372
+ print(" ⏭️ No existing MCP servers or skills detected");
373
+ }
374
+ print("");
375
+
376
+ // Interactive prompts
377
+ const rl = readline.createInterface({
378
+ input: process.stdin,
379
+ output: process.stdout,
380
+ });
381
+
382
+ print(" 📋 Team Configuration");
383
+ print(" (Press Enter to accept detected defaults)\n");
384
+
385
+ const mcpDefault = detectedMcp.map((s) => s.name).join(", ");
386
+ const mcpServersRaw = await ask(
387
+ rl,
388
+ "MCP servers (comma-separated)",
389
+ mcpDefault || ""
390
+ );
391
+ const conventionsRaw = await ask(
392
+ rl,
393
+ "Team conventions (comma-separated)",
394
+ ""
395
+ );
396
+ const staleDays = await ask(rl, "Stale thread age in days", "14");
397
+ const injectTokenBudget = await ask(rl, "Inject context token budget", "1000");
398
+
399
+ // Sync MCP servers from teammate's sticky-note config
400
+ await syncMcpFromStickyNote(rl);
401
+
402
+ rl.close();
403
+
404
+ // Build MCP server list: prefer detected objects, fall back to names
405
+ const mcpNames = mcpServersRaw
406
+ ? mcpServersRaw.split(",").map((s) => s.trim()).filter(Boolean)
407
+ : [];
408
+ const mcpServers = mcpNames.map((name) => {
409
+ const detected = detectedMcp.find((d) => d.name === name);
410
+ return detected || { name, source: "manual" };
411
+ });
412
+
413
+ const skills = [...detectedSkills];
414
+
415
+ const conventions = conventionsRaw
416
+ ? conventionsRaw.split(",").map((s) => s.trim()).filter(Boolean)
417
+ : [];
418
+ const staleDaysResolved = parseIntOr(staleDays, 14);
419
+ const injectTokenBudgetResolved = parseIntOr(injectTokenBudget, 1000);
420
+
421
+ print("\n 📁 Creating files...\n");
422
+
423
+ // Create directories
424
+ const claudeHooksDir = path.join(process.cwd(), ".claude", "hooks");
425
+ const githubHooksDir = path.join(process.cwd(), ".github", "hooks");
426
+ const stickyNoteDir = path.join(process.cwd(), ".sticky-note");
427
+ mkdirSafe(claudeHooksDir);
428
+ mkdirSafe(githubHooksDir);
429
+ mkdirSafe(stickyNoteDir);
430
+
431
+ // Copy hook scripts
432
+ for (const file of HOOK_FILES) {
433
+ const src = path.join(TEMPLATES_DIR, "hooks", file);
434
+ const dest = path.join(claudeHooksDir, file);
435
+ copyFile(src, dest);
436
+ makeExecutable(dest);
437
+ print(` [OK] .claude/hooks/${file}`);
438
+ }
439
+
440
+ // Create settings.json (Claude Code)
441
+ const settingsTemplate = JSON.parse(readTemplate("settings.json"));
442
+ const settingsDest = path.join(process.cwd(), ".claude", "settings.json");
443
+ fs.writeFileSync(settingsDest, JSON.stringify(settingsTemplate, null, 2) + "\n");
444
+ print(" [OK] .claude/settings.json");
445
+
446
+ // Create hooks.json (Copilot CLI)
447
+ const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
448
+ const hooksDest = path.join(githubHooksDir, "hooks.json");
449
+ fs.writeFileSync(hooksDest, JSON.stringify(hooksTemplate, null, 2) + "\n");
450
+ print(" [OK] .github/hooks/hooks.json");
451
+
452
+ // Create sticky-note.json (V2 — no audit array)
453
+ const memoryDest = path.join(stickyNoteDir, "sticky-note.json");
454
+ if (!fs.existsSync(memoryDest)) {
455
+ const memoryTemplate = JSON.parse(readTemplate("sticky-note.json"));
456
+ fs.writeFileSync(memoryDest, JSON.stringify(memoryTemplate, null, 2) + "\n");
457
+ }
458
+ print(" [OK] .sticky-note/sticky-note.json (v2)");
459
+
460
+ // Create per-user audit and presence directories
461
+ const auditDir = path.join(stickyNoteDir, "audit");
462
+ const presenceDir = path.join(stickyNoteDir, "presence");
463
+ fs.mkdirSync(auditDir, { recursive: true });
464
+ fs.mkdirSync(presenceDir, { recursive: true });
465
+ print(" [OK] .sticky-note/audit/ (per-user audit logs)");
466
+ print(" [OK] .sticky-note/presence/ (per-user presence)");
467
+
468
+ // Migrate legacy single-file audit/presence if they exist
469
+ const legacyAuditPath = path.join(stickyNoteDir, "sticky-note-audit.jsonl");
470
+ const legacyPresencePath = path.join(stickyNoteDir, ".sticky-presence.json");
471
+ if (fs.existsSync(legacyAuditPath)) {
472
+ const user = process.env.USER || process.env.USERNAME || "unknown";
473
+ const dest = path.join(auditDir, user + ".jsonl");
474
+ if (fs.existsSync(dest)) {
475
+ fs.appendFileSync(dest, fs.readFileSync(legacyAuditPath, "utf-8"));
476
+ } else {
477
+ fs.renameSync(legacyAuditPath, dest);
478
+ }
479
+ try { if (fs.existsSync(legacyAuditPath)) fs.unlinkSync(legacyAuditPath); } catch (_) {}
480
+ print(" [OK] Migrated legacy audit log to audit/" + (process.env.USER || process.env.USERNAME || "unknown") + ".jsonl");
481
+ }
482
+ if (fs.existsSync(legacyPresencePath)) {
483
+ const data = readJsonSafe(legacyPresencePath, {});
484
+ for (const [u, info] of Object.entries(data)) {
485
+ const dest = path.join(presenceDir, u + ".json");
486
+ fs.writeFileSync(dest, JSON.stringify(info, null, 2) + "\n");
487
+ }
488
+ fs.unlinkSync(legacyPresencePath);
489
+ print(" [OK] Migrated legacy presence to per-user files");
490
+ }
491
+
492
+ // Create sticky-note-config.json
493
+ const configDest = path.join(stickyNoteDir, "sticky-note-config.json");
494
+ if (!fs.existsSync(configDest)) {
495
+ const configTemplate = JSON.parse(readTemplate("sticky-note-config.json"));
496
+ configTemplate.mcp_servers = mcpServers;
497
+ configTemplate.skills = skills;
498
+ configTemplate.conventions = conventions;
499
+ configTemplate.stale_days = staleDaysResolved;
500
+ configTemplate.inject_token_budget = injectTokenBudgetResolved;
501
+ configTemplate.hook_version = VERSION;
502
+ fs.writeFileSync(configDest, JSON.stringify(configTemplate, null, 2) + "\n");
503
+ } else {
504
+ // Update existing config with new settings
505
+ const existing = readJsonSafe(configDest, {});
506
+ existing.mcp_servers = mcpServers;
507
+ existing.skills = skills;
508
+ existing.conventions = conventions;
509
+ existing.stale_days = staleDaysResolved;
510
+ existing.inject_token_budget = injectTokenBudgetResolved;
511
+ existing.hook_version = VERSION;
512
+ fs.writeFileSync(configDest, JSON.stringify(existing, null, 2) + "\n");
513
+ }
514
+ print(" [OK] .sticky-note/sticky-note-config.json");
515
+
516
+ // Update .gitignore
517
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
518
+ const ignoreAdditions = fs
519
+ .readFileSync(path.join(TEMPLATES_DIR, "gitignore-additions.txt"), "utf-8")
520
+ .trim()
521
+ .split(/\r?\n/)
522
+ .filter((l) => l.trim() && !l.startsWith("#"));
523
+
524
+ let gitignoreContent = "";
525
+ if (fs.existsSync(gitignorePath)) {
526
+ gitignoreContent = fs.readFileSync(gitignorePath, "utf-8");
527
+ }
528
+
529
+ const missingEntries = ignoreAdditions.filter((e) => !gitignoreContent.includes(e.trim()));
530
+ if (missingEntries.length > 0) {
531
+ const block = "\n# Sticky Note\n" + missingEntries.join("\n") + "\n";
532
+ fs.appendFileSync(gitignorePath, block);
533
+ print(" [OK] .gitignore updated");
534
+ } else {
535
+ print(" ⏭️ .gitignore already configured");
536
+ }
537
+
538
+ // Add .gitattributes merge strategy
539
+ const gitattrsPath = path.join(process.cwd(), ".gitattributes");
540
+ const mergeRule = ".sticky-note/sticky-note.json merge=union";
541
+ let gitattrsContent = "";
542
+ if (fs.existsSync(gitattrsPath)) {
543
+ gitattrsContent = fs.readFileSync(gitattrsPath, "utf-8");
544
+ }
545
+ if (!gitattrsContent.includes(mergeRule)) {
546
+ const addition = gitattrsContent.endsWith("\n") || gitattrsContent === ""
547
+ ? `\n# Sticky Note - auto-merge threads from both sides\n${mergeRule}\n`
548
+ : `\n\n# Sticky Note - auto-merge threads from both sides\n${mergeRule}\n`;
549
+ fs.appendFileSync(gitattrsPath, addition);
550
+ print(" [OK] .gitattributes updated (merge=union)");
551
+ } else {
552
+ print(" ⏭️ .gitattributes already configured");
553
+ }
554
+
555
+ // Add git aliases for safe branch switching
556
+ try {
557
+ const swAlias = '!f() { npx sticky-note switch "$@"; }; f';
558
+ execFileSync("git", ["config", "alias.sw", swAlias], {
559
+ cwd: process.cwd(),
560
+ stdio: ["pipe", "pipe", "pipe"],
561
+ });
562
+ print(" [OK] git alias: git sw <branch> (safe switch with auto-stash)");
563
+ } catch (_) {
564
+ print(" ⏭️ Could not set git alias (non-fatal)");
565
+ }
566
+
567
+ // Deploy AI instruction files (CLAUDE.md + .github/copilot-instructions.md)
568
+ const claudeMdDest = path.join(process.cwd(), "CLAUDE.md");
569
+ if (!fs.existsSync(claudeMdDest)) {
570
+ copyFile(path.join(TEMPLATES_DIR, "CLAUDE.md"), claudeMdDest);
571
+ print(" [OK] CLAUDE.md (AI instructions for Claude Code)");
572
+ } else {
573
+ print(" ⏭️ CLAUDE.md already exists");
574
+ }
575
+
576
+ const copilotInstrDest = path.join(githubHooksDir, "..", "copilot-instructions.md");
577
+ if (!fs.existsSync(copilotInstrDest)) {
578
+ copyFile(path.join(TEMPLATES_DIR, "copilot-instructions.md"), copilotInstrDest);
579
+ print(" [OK] .github/copilot-instructions.md (AI instructions for Copilot CLI)");
580
+ } else {
581
+ print(" ⏭️ .github/copilot-instructions.md already exists");
582
+ }
583
+
584
+ // Install Codex wrapper if --codex flag
585
+ const wantCodex = process.argv.includes("--codex");
586
+ if (wantCodex) {
587
+ const codexSrc = path.join(TEMPLATES_DIR, "hooks", "sticky-codex.sh");
588
+ const codexDest = path.join(claudeHooksDir, "sticky-codex.sh");
589
+ copyFile(codexSrc, codexDest);
590
+ makeExecutable(codexDest);
591
+ print(" [OK] .claude/hooks/sticky-codex.sh");
592
+ print(`\n 🔧 Codex wrapper installed!`);
593
+ print(` Add this alias to your shell profile:`);
594
+ print(` alias sticky-codex="${codexDest}"`);
595
+ print("");
596
+ }
597
+
598
+ // Install git hooks (.git/hooks/) for attribution persistence
599
+ if (installGitHook("post-rewrite")) {
600
+ print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
601
+ }
602
+
603
+ // Done!
604
+ print("\n ✨ Sticky Note V2 initialized!\n");
605
+ print(" Next steps:");
606
+ print(" ┌──────────────────────────────────────────────────────────────┐");
607
+ print(" │ git add .claude .github .sticky-note .gitignore .gitattributes CLAUDE.md │");
608
+ print(" │ git commit -m \"feat: add sticky-note v2 hooks\" │");
609
+ print(" │ git push │");
610
+ print(" └──────────────────────────────────────────────────────────────┘");
611
+ print("");
612
+ print(" Teammates just need to `git pull` — no extra setup.");
613
+ print("");
614
+ }
615
+
616
+ // ──────────────────────────────────────────────
617
+ // UPDATE command
618
+ // ──────────────────────────────────────────────
619
+
620
+ function cmdUpdate() {
621
+ printBanner();
622
+
623
+ const claudeHooksDir = path.join(process.cwd(), ".claude", "hooks");
624
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
625
+ if (!fs.existsSync(claudeHooksDir)) {
626
+ print(" [ERR] No .claude/hooks/ directory found. Run `npx sticky-note init` first.");
627
+ process.exit(1);
628
+ }
629
+
630
+ print(" Updating hook scripts...\n");
631
+
632
+ for (const file of HOOK_FILES) {
633
+ const src = path.join(TEMPLATES_DIR, "hooks", file);
634
+ const dest = path.join(claudeHooksDir, file);
635
+ copyFile(src, dest);
636
+ makeExecutable(dest);
637
+ print(` [OK] .claude/hooks/${file}`);
638
+ }
639
+
640
+ // Update settings.json hooks
641
+ const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
642
+ if (fs.existsSync(settingsPath)) {
643
+ const settingsTemplate = JSON.parse(readTemplate("settings.json"));
644
+ const existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
645
+ existing.hooks = settingsTemplate.hooks;
646
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
647
+ print(" [OK] .claude/settings.json (hooks updated)");
648
+ }
649
+
650
+ // Update hooks.json
651
+ const hooksPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
652
+ if (fs.existsSync(hooksPath)) {
653
+ const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
654
+ fs.writeFileSync(hooksPath, JSON.stringify(hooksTemplate, null, 2) + "\n");
655
+ print(" [OK] .github/hooks/hooks.json (updated)");
656
+ }
657
+
658
+ // Update git hooks (.git/hooks/)
659
+ if (installGitHook("post-rewrite")) {
660
+ print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
661
+ }
662
+
663
+ // Update hook_version in config
664
+ const configPath = path.join(stickyDir, "sticky-note-config.json");
665
+ if (fs.existsSync(configPath)) {
666
+ const config = readJsonSafe(configPath, {});
667
+ config.hook_version = VERSION;
668
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
669
+ }
670
+
671
+ print(`\n ✨ Scripts updated to v${VERSION}`);
672
+
673
+ // Update instruction files (section-based: only replaces between markers)
674
+ const claudeMdDest = path.join(process.cwd(), "CLAUDE.md");
675
+ const claudeMdTemplate = readTemplate("CLAUDE.md");
676
+ if (updateInstructionSection(claudeMdDest, claudeMdTemplate, "# Sticky Note")) {
677
+ print(" [OK] CLAUDE.md (sticky-note section updated)");
678
+ }
679
+
680
+ const copilotInstrDest = path.join(process.cwd(), ".github", "copilot-instructions.md");
681
+ const copilotTemplate = readTemplate("copilot-instructions.md");
682
+ if (updateInstructionSection(copilotInstrDest, copilotTemplate, "# Sticky Note")) {
683
+ print(" [OK] .github/copilot-instructions.md (sticky-note section updated)");
684
+ }
685
+
686
+ print(" ⚠️ Thread data was NOT modified.\n");
687
+ }
688
+
689
+ // ──────────────────────────────────────────────
690
+ // STATUS command
691
+ // ──────────────────────────────────────────────
692
+
693
+ function cmdStatus() {
694
+ printBanner();
695
+
696
+ const claudeHooksDir = path.join(process.cwd(), ".claude", "hooks");
697
+ const hooksExist = fs.existsSync(claudeHooksDir);
698
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
699
+
700
+ print(" 📊 Diagnostic Report\n");
701
+
702
+ // Hook health
703
+ print(" Hook Scripts:");
704
+ if (hooksExist) {
705
+ for (const file of HOOK_FILES) {
706
+ const filePath = path.join(claudeHooksDir, file);
707
+ if (fs.existsSync(filePath)) {
708
+ print(` [OK] ${file}`);
709
+ } else {
710
+ print(` [ERR] ${file} (missing)`);
711
+ }
712
+ }
713
+ } else {
714
+ print(" [ERR] .claude/hooks/ directory not found");
715
+ }
716
+
717
+ // Config files
718
+ print("\n Config Files:");
719
+ const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
720
+ const hooksJsonPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
721
+ print(
722
+ ` ${fs.existsSync(settingsPath) ? "[OK]" : "[ERR]"} .claude/settings.json`
723
+ );
724
+ print(
725
+ ` ${fs.existsSync(hooksJsonPath) ? "[OK]" : "[ERR]"} .github/hooks/hooks.json`
726
+ );
727
+
728
+ // Sticky Note data — V2 format
729
+ const memoryPath = path.join(stickyDir, "sticky-note.json");
730
+ const configPath = path.join(stickyDir, "sticky-note-config.json");
731
+
732
+ print("\n Sticky Note Data:");
733
+ if (fs.existsSync(memoryPath)) {
734
+ try {
735
+ const memory = JSON.parse(fs.readFileSync(memoryPath, "utf-8"));
736
+ const threads = memory.threads || [];
737
+ const schemaVersion = memory.version || "2";
738
+
739
+ const openThreads = threads.filter((t) => t.status === "open").length;
740
+ const stuckThreads = threads.filter((t) => t.status === "stuck").length;
741
+ const staleThreads = threads.filter((t) => t.status === "stale").length;
742
+ const closedThreads = threads.filter((t) => t.status === "closed").length;
743
+ const expiredThreads = threads.filter((t) => t.status === "expired").length;
744
+
745
+ print(` [OK] sticky-note.json (schema v${schemaVersion})`);
746
+ print(` 📝 Threads: ${threads.length} total`);
747
+ print(` open=${openThreads} stuck=${stuckThreads} stale=${staleThreads} closed=${closedThreads} expired=${expiredThreads}`);
748
+ } catch {
749
+ print(" [ERR] sticky-note.json (invalid JSON)");
750
+ }
751
+ } else {
752
+ print(" [ERR] sticky-note.json not found");
753
+ }
754
+
755
+ // Config
756
+ if (fs.existsSync(configPath)) {
757
+ const config = readJsonSafe(configPath, {});
758
+ print(` [OK] sticky-note-config.json (v${config.hook_version || "?"})`);
759
+ if (config.mcp_servers && config.mcp_servers.length > 0) {
760
+ print(` 🔌 MCP servers: ${config.mcp_servers.length} configured`);
761
+ }
762
+ if (config.conventions && config.conventions.length > 0) {
763
+ print(` 📋 Conventions: ${config.conventions.length} defined`);
764
+ }
765
+ print(` Stale after: ${config.stale_days ?? "?"} days`);
766
+ print(` Inject token budget: ${config.inject_token_budget ?? 1000}`);
767
+ } else {
768
+ print(" [ERR] sticky-note-config.json not found");
769
+ }
770
+
771
+ // Audit (per-user)
772
+ const auditDir = path.join(stickyDir, "audit");
773
+ if (fs.existsSync(auditDir)) {
774
+ const auditFiles = fs.readdirSync(auditDir).filter((f) => f.endsWith(".jsonl"));
775
+ let totalEntries = 0;
776
+ const perUser = [];
777
+ for (const f of auditFiles) {
778
+ const count = countJsonlLines(path.join(auditDir, f));
779
+ totalEntries += count;
780
+ perUser.push(`${path.basename(f, ".jsonl")}(${count})`);
781
+ }
782
+ if (totalEntries > 0) {
783
+ print(` [OK] audit/ (${totalEntries} entries across ${auditFiles.length} user(s))`);
784
+ print(` ${perUser.join(", ")}`);
785
+ } else {
786
+ print(" ⏭️ audit/ (empty)");
787
+ }
788
+ } else {
789
+ // Check for legacy single file
790
+ const legacyAuditPath = path.join(stickyDir, "sticky-note-audit.jsonl");
791
+ const auditCount = countJsonlLines(legacyAuditPath);
792
+ if (auditCount > 0) {
793
+ print(` ⚠️ sticky-note-audit.jsonl (${auditCount} entries, legacy — run init to migrate)`);
794
+ } else {
795
+ print(" ⏭️ No audit data (run init to set up per-user audit)");
796
+ }
797
+ }
798
+
799
+ // Presence (per-user)
800
+ const presenceDir = path.join(stickyDir, "presence");
801
+ if (fs.existsSync(presenceDir)) {
802
+ const presenceFiles = fs.readdirSync(presenceDir).filter((f) => f.endsWith(".json"));
803
+ const now = Date.now();
804
+ const activeUsers = [];
805
+ for (const f of presenceFiles) {
806
+ const info = readJsonSafe(path.join(presenceDir, f), {});
807
+ const lastSeen = info.last_seen || "";
808
+ if (lastSeen) {
809
+ const ts = new Date(lastSeen).getTime();
810
+ if (!isNaN(ts) && now - ts < 15 * 60 * 1000) {
811
+ activeUsers.push(path.basename(f, ".json"));
812
+ }
813
+ }
814
+ }
815
+ print(` 👥 Presence: ${activeUsers.length > 0 ? activeUsers.join(", ") + " (active)" : "no active users"}`);
816
+ } else {
817
+ print(" ⏭️ No presence data (run init to set up per-user presence)");
818
+ }
819
+
820
+ print("\n Environment:");
821
+
822
+ print(
823
+ ` ${isGitRepo() ? "[OK]" : "[ERR]"} Git repository`
824
+ );
825
+
826
+ // V2.5: Attribution engine health
827
+ print("\n Attribution Engine (V2.5):");
828
+ const attrPath = path.join(process.cwd(), ".claude", "hooks", "sticky-attribution.js");
829
+ const notesPath = path.join(process.cwd(), ".claude", "hooks", "sticky-git-notes.js");
830
+ const preToolPath = path.join(process.cwd(), ".claude", "hooks", "pre-tool-use.js");
831
+ print(` ${fs.existsSync(attrPath) ? "[OK]" : "[--]"} sticky-attribution.js`);
832
+ print(` ${fs.existsSync(notesPath) ? "[OK]" : "[--]"} sticky-git-notes.js`);
833
+ print(` ${fs.existsSync(preToolPath) ? "[OK]" : "[--]"} pre-tool-use.js`);
834
+
835
+ // Check Git Notes configuration
836
+ let notesConfigured = false;
837
+ try {
838
+ const ref = execFileSync("git", ["config", "--get", "notes.rewriteRef"], { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] }).trim();
839
+ notesConfigured = ref.includes("sticky-note");
840
+ } catch (_) { /* not configured */ }
841
+ print(` ${notesConfigured ? "[OK]" : "[--]"} Git Notes rewrite configured`);
842
+ print(` Injection mode: two-tier (eager stuck + lazy file via git blame)`);
843
+ // Injected-this-session info
844
+ const injectedPath = path.join(stickyDir, ".sticky-injected");
845
+ if (fs.existsSync(injectedPath)) {
846
+ const injData = readJsonSafe(injectedPath, {});
847
+ const ids = injData.thread_ids || [];
848
+ if (ids.length > 0) {
849
+ print(` Injected this session: ${ids.length} thread(s)`);
850
+ }
851
+ }
852
+
853
+ print("");
854
+ }
855
+
856
+ // ──────────────────────────────────────────────
857
+ // Helpers (shared formatting)
858
+ // ──────────────────────────────────────────────
859
+
860
+ function statusIcon(status) {
861
+ switch (status) {
862
+ case "stuck": return "[STUCK]";
863
+ case "open": return "[OPEN]";
864
+ case "stale": return "[STALE]";
865
+ default: return "[CLOSED]";
866
+ }
867
+ }
868
+
869
+ // ──────────────────────────────────────────────
870
+ // THREADS command
871
+ // ──────────────────────────────────────────────
872
+
873
+ function cmdThreads() {
874
+ const memoryPath = path.join(process.cwd(), ".sticky-note", "sticky-note.json");
875
+ if (!fs.existsSync(memoryPath)) {
876
+ print(" [ERR] No sticky-note.json found. Run `npx sticky-note init` first.");
877
+ process.exit(1);
878
+ }
879
+
880
+ const memory = readJsonSafe(memoryPath, { threads: [] });
881
+ const threads = memory.threads || [];
882
+
883
+ // Filter: show open, stuck, closed (not expired)
884
+ const live = threads.filter((t) => ["open", "stuck", "closed", "stale"].includes(t.status));
885
+
886
+ if (live.length === 0) {
887
+ print(" No active threads.");
888
+ return;
889
+ }
890
+
891
+ print("");
892
+ for (const t of live) {
893
+ const icon = statusIcon(t.status);
894
+ const user = t.user || t.author || "?";
895
+ const files = (t.files_touched || []).slice(0, 3).join(", ");
896
+ const branch = t.branch ? ` (${t.branch})` : "";
897
+ const note = t.last_note ? ` — ${t.last_note.slice(0, 60)}` : "";
898
+
899
+ print(` ${icon} [${t.status}] ${user}${branch}: ${files}${note}`);
900
+ if (t.narrative) {
901
+ print(` 📖 ${t.narrative.slice(0, 100)}`);
902
+ }
903
+ if (t.failed_approaches && t.failed_approaches.length > 0) {
904
+ print(` ⚠️ ${t.failed_approaches.length} failed approach(es)`);
905
+ }
906
+ }
907
+ print("");
908
+ }
909
+
910
+ // ──────────────────────────────────────────────
911
+ // RESUME command
912
+ // ──────────────────────────────────────────────
913
+
914
+ function cmdResume() {
915
+ const args = process.argv.slice(3);
916
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
917
+ const memoryPath = path.join(stickyDir, "sticky-note.json");
918
+ const resumePath = path.join(stickyDir, ".sticky-resume");
919
+
920
+ if (!fs.existsSync(memoryPath)) {
921
+ print(" [ERR] No sticky-note.json found. Run `npx sticky-note init` first.");
922
+ process.exit(1);
923
+ }
924
+
925
+ // resume --clear: remove the resume signal
926
+ if (args.includes("--clear")) {
927
+ if (fs.existsSync(resumePath)) {
928
+ fs.unlinkSync(resumePath);
929
+ print(" [OK] Resume signal cleared.");
930
+ } else {
931
+ print(" ℹ️ No active resume signal.");
932
+ }
933
+ return;
934
+ }
935
+
936
+ // resume --list: show resumable threads
937
+ if (args.includes("--list") || args.length === 0) {
938
+ const memory = readJsonSafe(memoryPath, { threads: [] });
939
+ const threads = memory.threads || [];
940
+ const resumable = threads.filter((t) =>
941
+ ["closed", "stuck", "open", "stale"].includes(t.status)
942
+ );
943
+
944
+ if (resumable.length === 0) {
945
+ print(" No resumable threads.");
946
+ return;
947
+ }
948
+
949
+ // Show current resume signal if active
950
+ if (fs.existsSync(resumePath)) {
951
+ const activeId = fs.readFileSync(resumePath, "utf-8").trim();
952
+ print(`\n 🔄 Active resume: ${activeId}\n`);
953
+ }
954
+
955
+ print("\n Resumable threads:\n");
956
+ for (const t of resumable) {
957
+ const icon = statusIcon(t.status);
958
+ const user = t.user || t.author || "?";
959
+ const files = (t.files_touched || []).slice(0, 3).join(", ");
960
+ const branch = t.branch ? ` (${t.branch})` : "";
961
+ const note = t.last_note ? ` — ${t.last_note.slice(0, 60)}` : "";
962
+ const shortId = t.id.slice(0, 8);
963
+
964
+ print(` ${icon} ${shortId} [${t.status}] ${user}${branch}: ${files}${note}`);
965
+ const prompts = t.prompts || [];
966
+ if (prompts.length > 0) {
967
+ const preview = prompts.slice(0, 3).map((p, i) => ` ${i + 1}. ${p.slice(0, 60)}`).join("\n");
968
+ print(preview);
969
+ if (prompts.length > 3) print(` ... and ${prompts.length - 3} more prompt(s)`);
970
+ }
971
+ }
972
+ print(`\n Usage: npx sticky-note resume <thread-id>`);
973
+ print(" You can use the first 8 characters of the ID.\n");
974
+ return;
975
+ }
976
+
977
+ // resume <thread-id>: set the resume signal
978
+ const threadId = args[0];
979
+ const memory = readJsonSafe(memoryPath, { threads: [] });
980
+ const threads = memory.threads || [];
981
+
982
+ // Support partial ID matching (first 8 chars)
983
+ const match = threads.find((t) =>
984
+ t.id === threadId || t.id.startsWith(threadId)
985
+ );
986
+
987
+ if (!match) {
988
+ print(` [ERR] No thread found matching "${threadId}".`);
989
+ print(" Run `npx sticky-note resume --list` to see available threads.");
990
+ process.exit(1);
991
+ }
992
+
993
+ if (match.status === "expired") {
994
+ print(` [ERR] Thread ${match.id.slice(0, 8)} is expired and cannot be resumed.`);
995
+ process.exit(1);
996
+ }
997
+
998
+ // Write the resume signal file
999
+ fs.writeFileSync(resumePath, match.id + "\n");
1000
+
1001
+ const icon = statusIcon(match.status);
1002
+ print(`\n [OK] Resume signal set for thread ${match.id.slice(0, 8)}`);
1003
+ print(` ${icon} [${match.status}] ${match.user || "?"} (${match.branch || "no branch"})`);
1004
+ if (match.last_note) {
1005
+ print(` ${match.last_note.slice(0, 80)}`);
1006
+ }
1007
+
1008
+ // Output full thread context so AI assistants can use it immediately
1009
+ print(`\n ── Thread context ──────────────────────────────────`);
1010
+ print(` ID: ${match.id}`);
1011
+ print(` Author: ${match.user || match.author || "unknown"}`);
1012
+ print(` Tool: ${match.tool || "unknown"}`);
1013
+ print(` Branch: ${match.branch || "none"}`);
1014
+ print(` Status: ${match.status}`);
1015
+ print(` Created: ${match.created_at || "?"}`);
1016
+
1017
+ if (match.work_type && match.work_type !== "general") {
1018
+ print(` Type: ${match.work_type}`);
1019
+ }
1020
+
1021
+ const files = match.files_touched || [];
1022
+ if (files.length > 0) {
1023
+ print(` Files: ${files.join(", ")}`);
1024
+ }
1025
+
1026
+ if (match.narrative) {
1027
+ print(`\n 📖 Narrative:`);
1028
+ print(` ${match.narrative.slice(0, 500)}`);
1029
+ }
1030
+
1031
+ if (match.handoff_summary && match.handoff_summary !== "Session stopped — no summary available") {
1032
+ print(`\n 🤝 Handoff:`);
1033
+ print(` ${match.handoff_summary.slice(0, 300)}`);
1034
+ }
1035
+
1036
+ const failed = match.failed_approaches || [];
1037
+ if (failed.length > 0) {
1038
+ print(`\n ⚠️ Failed approaches (${failed.length}):`);
1039
+ for (const f of failed.slice(0, 5)) {
1040
+ const desc = typeof f === "string" ? f : f.description || JSON.stringify(f);
1041
+ print(` • ${desc.slice(0, 120)}`);
1042
+ }
1043
+ }
1044
+
1045
+ const prompts = match.prompts || [];
1046
+ if (prompts.length > 0) {
1047
+ print(`\n 💬 Conversation (${prompts.length} prompt(s)):`);
1048
+ for (const [i, p] of prompts.slice(0, 8).entries()) {
1049
+ print(` ${i + 1}. ${p.slice(0, 120)}`);
1050
+ }
1051
+ if (prompts.length > 8) {
1052
+ print(` ... and ${prompts.length - 8} more`);
1053
+ }
1054
+ }
1055
+
1056
+ print(` ────────────────────────────────────────────────────`);
1057
+ print(`\n Your next prompt will pick up this thread's context automatically.`);
1058
+ print(" Run `npx sticky-note resume --clear` to cancel.\n");
1059
+ }
1060
+
1061
+ // ──────────────────────────────────────────────
1062
+ // AUDIT command
1063
+ // ──────────────────────────────────────────────
1064
+
1065
+ function cmdReset() {
1066
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
1067
+ const memoryPath = path.join(stickyDir, "sticky-note.json");
1068
+
1069
+ if (!fs.existsSync(memoryPath)) {
1070
+ print(" [ERR] No sticky-note.json found. Run 'npx sticky-note init' first.");
1071
+ process.exit(1);
1072
+ }
1073
+
1074
+ const args = process.argv.slice(3);
1075
+ const force = args.includes("--force");
1076
+
1077
+ const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
1078
+ const threadCount = (memory.threads || []).length;
1079
+
1080
+ if (threadCount === 0) {
1081
+ print(" Nothing to reset -- 0 threads.");
1082
+ return;
1083
+ }
1084
+
1085
+ if (!force) {
1086
+ print(` This will permanently delete ${threadCount} thread(s) from sticky-note.json.`);
1087
+ print(" Run with --force to confirm, or --keep-audit to preserve the audit log.");
1088
+ return;
1089
+ }
1090
+
1091
+ const keepAudit = args.includes("--keep-audit");
1092
+
1093
+ memory.threads = [];
1094
+ fs.writeFileSync(memoryPath, JSON.stringify(memory, null, 2));
1095
+ print(` [OK] Cleared ${threadCount} thread(s) from sticky-note.json`);
1096
+
1097
+ if (!keepAudit) {
1098
+ // Delete per-user audit directory
1099
+ const auditDir = path.join(stickyDir, "audit");
1100
+ if (fs.existsSync(auditDir)) {
1101
+ for (const f of fs.readdirSync(auditDir)) {
1102
+ fs.unlinkSync(path.join(auditDir, f));
1103
+ }
1104
+ print(" [OK] Deleted all per-user audit logs");
1105
+ }
1106
+ // Also clean legacy single file if present
1107
+ const legacyAudit = path.join(stickyDir, "sticky-note-audit.jsonl");
1108
+ if (fs.existsSync(legacyAudit)) {
1109
+ fs.unlinkSync(legacyAudit);
1110
+ print(" [OK] Deleted legacy audit log");
1111
+ }
1112
+ } else {
1113
+ print(" [OK] Audit logs preserved (--keep-audit)");
1114
+ }
1115
+
1116
+ // Clean up presence directory
1117
+ const presenceDir = path.join(stickyDir, "presence");
1118
+ if (fs.existsSync(presenceDir)) {
1119
+ for (const f of fs.readdirSync(presenceDir)) {
1120
+ fs.unlinkSync(path.join(presenceDir, f));
1121
+ }
1122
+ }
1123
+
1124
+ // Clean up signal files and legacy presence
1125
+ for (const f of [".sticky-resume", ".sticky-session", ".sticky-head", ".sticky-presence.json"]) {
1126
+ const p = path.join(stickyDir, f);
1127
+ if (fs.existsSync(p)) fs.unlinkSync(p);
1128
+ }
1129
+ print(" [OK] Cleared session signal files");
1130
+ print("");
1131
+ print(" Fresh start. Next session will create a new thread.");
1132
+ }
1133
+
1134
+ function cmdAudit() {
1135
+ const args = process.argv.slice(3);
1136
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
1137
+ const auditDir = path.join(stickyDir, "audit");
1138
+
1139
+ // Collect all audit file paths (per-user + legacy)
1140
+ const auditPaths = [];
1141
+ if (fs.existsSync(auditDir)) {
1142
+ for (const f of fs.readdirSync(auditDir)) {
1143
+ if (f.endsWith(".jsonl")) auditPaths.push(path.join(auditDir, f));
1144
+ }
1145
+ }
1146
+ const legacyPath = path.join(stickyDir, "sticky-note-audit.jsonl");
1147
+ if (fs.existsSync(legacyPath)) auditPaths.push(legacyPath);
1148
+
1149
+ if (auditPaths.length === 0) {
1150
+ print(" [ERR] No audit files found. Run 'npx sticky-note init' first.");
1151
+ process.exit(1);
1152
+ }
1153
+
1154
+ // Parse flags
1155
+ let filterFile = null;
1156
+ let filterUser = null;
1157
+ let filterSince = null;
1158
+ let filterSession = null;
1159
+ let limit = 50;
1160
+
1161
+ for (let i = 0; i < args.length; i++) {
1162
+ switch (args[i]) {
1163
+ case "--file":
1164
+ if (i + 1 < args.length) filterFile = args[++i];
1165
+ break;
1166
+ case "--user":
1167
+ if (i + 1 < args.length) filterUser = args[++i];
1168
+ break;
1169
+ case "--since":
1170
+ if (i + 1 < args.length) filterSince = args[++i];
1171
+ break;
1172
+ case "--session":
1173
+ if (i + 1 < args.length) filterSession = args[++i];
1174
+ break;
1175
+ case "--limit":
1176
+ if (i + 1 < args.length) limit = parseIntOr(args[++i], 50);
1177
+ break;
1178
+ }
1179
+ }
1180
+
1181
+ // Read and filter all JSONL files, merge entries
1182
+ const matches = [];
1183
+
1184
+ for (const auditPath of auditPaths) {
1185
+ try {
1186
+ const content = fs.readFileSync(auditPath, "utf-8");
1187
+ const lines = content.split(/\r?\n/).filter((l) => l.trim());
1188
+ for (const line of lines) {
1189
+ let entry;
1190
+ try {
1191
+ entry = JSON.parse(line);
1192
+ } catch {
1193
+ continue;
1194
+ }
1195
+
1196
+ if (filterFile && !(entry.file || "").includes(filterFile)) continue;
1197
+ if (filterUser && entry.user !== filterUser) continue;
1198
+ if (filterSession && entry.session_id !== filterSession) continue;
1199
+ if (filterSince) {
1200
+ const ts = entry.ts || entry.timestamp || "";
1201
+ if (ts < filterSince) continue;
1202
+ }
1203
+
1204
+ matches.push(entry);
1205
+ }
1206
+ } catch (_) {
1207
+ // skip unreadable files
1208
+ }
1209
+ }
1210
+
1211
+ // Sort by timestamp, show most recent first
1212
+ matches.sort((a, b) => {
1213
+ const ta = a.ts || a.timestamp || "";
1214
+ const tb = b.ts || b.timestamp || "";
1215
+ return ta < tb ? -1 : ta > tb ? 1 : 0;
1216
+ });
1217
+
1218
+ const display = matches.slice(-limit).reverse();
1219
+
1220
+ if (display.length === 0) {
1221
+ print(" No matching audit entries.");
1222
+ return;
1223
+ }
1224
+
1225
+ print(`\n ${display.length} matching entries (of ${matches.length} total):\n`);
1226
+ for (const e of display) {
1227
+ const ts = (e.ts || e.timestamp || "").slice(0, 16);
1228
+ const user = e.user || "?";
1229
+ const type = e.type || "?";
1230
+ const file = e.file ? ` ${e.file}` : "";
1231
+ const tool = e.tool ? ` [${e.tool}]` : "";
1232
+ print(` ${ts} ${user} ${type}${tool}${file}`);
1233
+ }
1234
+ print("");
1235
+ }
1236
+
1237
+ // ──────────────────────────────────────────────
1238
+ // WHO command — show active users
1239
+ // ──────────────────────────────────────────────
1240
+
1241
+ function cmdWho() {
1242
+ printBanner();
1243
+
1244
+ const presenceDir = path.join(process.cwd(), ".sticky-note", "presence");
1245
+
1246
+ if (!fs.existsSync(presenceDir)) {
1247
+ // Check legacy
1248
+ const legacyPath = path.join(process.cwd(), ".sticky-note", ".sticky-presence.json");
1249
+ if (fs.existsSync(legacyPath)) {
1250
+ print(" ⚠️ Legacy presence file found. Run 'npx sticky-note init' to migrate.\n");
1251
+ const data = readJsonSafe(legacyPath, {});
1252
+ const now = Date.now();
1253
+ for (const [user, info] of Object.entries(data)) {
1254
+ const lastSeen = info.last_seen || "";
1255
+ const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1256
+ const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1257
+ const ago = lastSeen ? _relativeTime(lastSeen) : "unknown";
1258
+ const files = (info.active_files || []).slice(0, 5).join(", ");
1259
+ const marker = active ? "[ACTIVE]" : "[IDLE]";
1260
+ print(` ${marker} ${user} (${ago})`);
1261
+ if (files) print(` files: ${files}`);
1262
+ }
1263
+ } else {
1264
+ print(" No presence data. Run 'npx sticky-note init' to set up.\n");
1265
+ }
1266
+ return;
1267
+ }
1268
+
1269
+ const presenceFiles = fs.readdirSync(presenceDir).filter((f) => f.endsWith(".json"));
1270
+ if (presenceFiles.length === 0) {
1271
+ print(" No users tracked yet.\n");
1272
+ return;
1273
+ }
1274
+
1275
+ const now = Date.now();
1276
+ const users = [];
1277
+ for (const f of presenceFiles) {
1278
+ const user = path.basename(f, ".json");
1279
+ const info = readJsonSafe(path.join(presenceDir, f), {});
1280
+ const lastSeen = info.last_seen || "";
1281
+ const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
1282
+ const active = !isNaN(ts) && now - ts < 15 * 60 * 1000;
1283
+ users.push({ user, info, lastSeen, active });
1284
+ }
1285
+
1286
+ // Sort: active first, then by last_seen descending
1287
+ users.sort((a, b) => {
1288
+ if (a.active !== b.active) return a.active ? -1 : 1;
1289
+ return (b.lastSeen || "") > (a.lastSeen || "") ? 1 : -1;
1290
+ });
1291
+
1292
+ print(" Team Activity:\n");
1293
+ for (const { user, info, lastSeen, active } of users) {
1294
+ const ago = lastSeen ? _relativeTime(lastSeen) : "unknown";
1295
+ const files = (info.active_files || []).slice(0, 5).join(", ");
1296
+ const marker = active ? "[ACTIVE]" : "[IDLE]";
1297
+ print(` ${marker} ${user} (${ago})`);
1298
+ if (files) print(` files: ${files}`);
1299
+ }
1300
+ print("");
1301
+ }
1302
+
1303
+ function _relativeTime(tsStr) {
1304
+ try {
1305
+ const ts = new Date(tsStr);
1306
+ if (isNaN(ts.getTime())) return "unknown";
1307
+ const now = new Date();
1308
+ const deltaMs = now - ts;
1309
+ const mins = Math.floor(deltaMs / 60000);
1310
+ if (mins < 1) return "just now";
1311
+ if (mins < 60) return mins + "m ago";
1312
+ const hours = Math.floor(mins / 60);
1313
+ if (hours < 24) return hours + "h ago";
1314
+ return Math.floor(hours / 24) + "d ago";
1315
+ } catch (_) {
1316
+ return "unknown";
1317
+ }
1318
+ }
1319
+
1320
+ // ──────────────────────────────────────────────
1321
+ // SWITCH command — safe branch switching
1322
+ // ──────────────────────────────────────────────
1323
+
1324
+ function cmdSwitch() {
1325
+ const args = process.argv.slice(3);
1326
+ const branch = args.filter((a) => !a.startsWith("-"))[0];
1327
+
1328
+ if (!branch) {
1329
+ print(" Usage: npx sticky-note switch <branch>");
1330
+ print(" Safely switches git branches by auto-stashing .sticky-note/ data.\n");
1331
+ process.exit(1);
1332
+ }
1333
+
1334
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
1335
+ if (!fs.existsSync(stickyDir)) {
1336
+ print(" No .sticky-note/ directory. Running plain git switch.");
1337
+ try {
1338
+ execFileSync("git", ["switch", branch], { stdio: "inherit" });
1339
+ } catch (err) {
1340
+ debugLog("git switch failed: " + (err.message || err));
1341
+ process.exit(1);
1342
+ }
1343
+ return;
1344
+ }
1345
+
1346
+ // Step 1: Stash .sticky-note/ changes
1347
+ let stashed = false;
1348
+ try {
1349
+ const result = execFileSync(
1350
+ "git", ["stash", "push", "-m", "sticky-note-auto", "--", ".sticky-note/"],
1351
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
1352
+ );
1353
+ stashed = result.includes("Saved working directory");
1354
+ if (stashed) {
1355
+ print(" [OK] Stashed .sticky-note/ changes");
1356
+ }
1357
+ } catch (_) {
1358
+ // Nothing to stash, that's fine
1359
+ }
1360
+
1361
+ // Step 2: Switch branch
1362
+ let switchOk = false;
1363
+ try {
1364
+ execFileSync("git", ["switch", branch], { stdio: "inherit" });
1365
+ switchOk = true;
1366
+ print(` [OK] Switched to ${branch}`);
1367
+ } catch (err) {
1368
+ print(` [ERR] Failed to switch to ${branch}`);
1369
+ debugLog("git switch (stash workflow): " + (err.message || err));
1370
+ // Try to restore stash if we made one
1371
+ if (stashed) {
1372
+ try {
1373
+ execFileSync("git", ["stash", "pop"], { stdio: ["pipe", "pipe", "pipe"] });
1374
+ print(" [OK] Restored stashed .sticky-note/ changes");
1375
+ } catch (_2) {
1376
+ print(" [WARN] Stash exists but could not pop. Run 'git stash pop' manually.");
1377
+ }
1378
+ }
1379
+ process.exit(1);
1380
+ }
1381
+
1382
+ // Step 3: Pop stash to restore .sticky-note/ data
1383
+ if (stashed) {
1384
+ try {
1385
+ execFileSync("git", ["stash", "pop"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
1386
+ print(" [OK] Restored .sticky-note/ data");
1387
+ } catch (_) {
1388
+ // Conflicts — force our version (sticky-note data is branch-independent)
1389
+ try {
1390
+ execFileSync("git", ["checkout", "--theirs", "--", ".sticky-note/"], {
1391
+ stdio: ["pipe", "pipe", "pipe"],
1392
+ });
1393
+ execFileSync("git", ["stash", "drop"], { stdio: ["pipe", "pipe", "pipe"] });
1394
+ print(" [OK] Restored .sticky-note/ data (resolved conflicts)");
1395
+ } catch (_2) {
1396
+ print(" [WARN] Could not auto-resolve. Run 'git stash pop' and resolve manually.");
1397
+ }
1398
+ }
1399
+ }
1400
+
1401
+ print("");
1402
+ }
1403
+
1404
+ // ──────────────────────────────────────────────
1405
+ // Resume Thread command (V2.5 — MCP-style)
1406
+ // ──────────────────────────────────────────────
1407
+
1408
+ function cmdResumeThread() {
1409
+ const args = process.argv.slice(3);
1410
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
1411
+ const memoryPath = path.join(stickyDir, "sticky-note.json");
1412
+
1413
+ if (!fs.existsSync(memoryPath)) {
1414
+ print(" [ERR] No sticky-note.json found. Run `npx sticky-note init` first.");
1415
+ process.exit(1);
1416
+ }
1417
+
1418
+ // Parse flags
1419
+ let query = null;
1420
+ let user = null;
1421
+ let file = null;
1422
+ let jsonOutput = false;
1423
+
1424
+ for (let i = 0; i < args.length; i++) {
1425
+ switch (args[i]) {
1426
+ case "--query":
1427
+ case "-q":
1428
+ if (i + 1 < args.length) query = args[++i];
1429
+ break;
1430
+ case "--user":
1431
+ case "-u":
1432
+ if (i + 1 < args.length) user = args[++i];
1433
+ break;
1434
+ case "--file":
1435
+ case "-f":
1436
+ if (i + 1 < args.length) file = args[++i];
1437
+ break;
1438
+ case "--json":
1439
+ jsonOutput = true;
1440
+ break;
1441
+ default:
1442
+ // Positional argument is treated as query
1443
+ if (!args[i].startsWith("-") && !query) {
1444
+ query = args.slice(i).join(" ");
1445
+ i = args.length; // consume all remaining
1446
+ }
1447
+ break;
1448
+ }
1449
+ }
1450
+
1451
+ if (!query && !user && !file) {
1452
+ print(" Usage: npx sticky-note resume-thread [--query <text>] [--user <name>] [--file <path>] [--json]");
1453
+ print(" Or: npx sticky-note resume-thread \"pick up auth refresh work\"");
1454
+ process.exit(1);
1455
+ }
1456
+
1457
+ const memory = readJsonSafe(memoryPath, { threads: [] });
1458
+ const threads = (memory.threads || []).filter((t) => t.status !== "expired");
1459
+
1460
+ // Load attribution engine for search
1461
+ let attribution;
1462
+ try {
1463
+ attribution = require(path.join(__dirname, "..", "templates", "hooks", "sticky-attribution.js"));
1464
+ } catch (_) {
1465
+ try {
1466
+ attribution = require(path.join(process.cwd(), ".claude", "hooks", "sticky-attribution.js"));
1467
+ } catch (_) {
1468
+ attribution = null;
1469
+ }
1470
+ }
1471
+
1472
+ let candidates;
1473
+ if (attribution) {
1474
+ candidates = attribution.findThreadToResume(threads, { query, user, file });
1475
+ } else {
1476
+ // Fallback: simple text matching without attribution engine
1477
+ candidates = simpleThreadSearch(threads, query, user);
1478
+ }
1479
+
1480
+ if (candidates.length === 0) {
1481
+ const msg = "No matching threads found.";
1482
+ if (jsonOutput) {
1483
+ print(JSON.stringify({ error: msg, candidates: [] }));
1484
+ } else {
1485
+ print(` [ERR] ${msg}`);
1486
+ if (query) print(` Query: "${query}"`);
1487
+ if (user) print(` User: "${user}"`);
1488
+ }
1489
+ process.exit(1);
1490
+ }
1491
+
1492
+ // If top 2 are close in score, show both and let user choose
1493
+ const top = candidates[0];
1494
+ const showConfirmation = candidates.length > 1 &&
1495
+ (top.score - candidates[1].score) < 0.5 &&
1496
+ top.score > 0;
1497
+
1498
+ if (jsonOutput) {
1499
+ const result = {
1500
+ best_match: formatThreadResult(top),
1501
+ alternatives: showConfirmation
1502
+ ? candidates.slice(1, 3).map(formatThreadResult)
1503
+ : [],
1504
+ };
1505
+ print(JSON.stringify(result, null, 2));
1506
+ // Set active resume
1507
+ setActiveResume(stickyDir, top.thread.id);
1508
+ return;
1509
+ }
1510
+
1511
+ printBanner();
1512
+
1513
+ if (showConfirmation) {
1514
+ print(" Found multiple close matches:\n");
1515
+ for (let i = 0; i < Math.min(candidates.length, 3); i++) {
1516
+ const c = candidates[i];
1517
+ const t = c.thread;
1518
+ const icon = statusIcon(t.status);
1519
+ const shortId = t.id.slice(0, 8);
1520
+ print(` ${i + 1}. ${icon} ${shortId} [${t.status}] ${t.user || "?"} (${t.branch || "no branch"}) — score: ${c.score.toFixed(1)}`);
1521
+ if (t.narrative) print(` ${t.narrative.slice(0, 100)}`);
1522
+ if (c.match_reasons && c.match_reasons.length) {
1523
+ print(` Matched by: ${c.match_reasons.join(", ")}`);
1524
+ }
1525
+ }
1526
+ print("\n Selecting best match (#1)...\n");
1527
+ }
1528
+
1529
+ // Set active resume thread
1530
+ const match = top.thread;
1531
+ setActiveResume(stickyDir, match.id);
1532
+
1533
+ const icon = statusIcon(match.status);
1534
+ print(` [OK] Resuming thread ${match.id.slice(0, 8)}`);
1535
+ print(` ${icon} [${match.status}] ${match.user || "?"} (${match.branch || "no branch"})`);
1536
+ if (match.narrative) print(` 📖 ${match.narrative.slice(0, 200)}`);
1537
+ if (match.handoff_summary) print(` 🤝 ${match.handoff_summary.slice(0, 200)}`);
1538
+
1539
+ const contributors = match.contributors || [match.user || "unknown"];
1540
+ if (contributors.length > 1) {
1541
+ print(` 👥 Contributors: ${contributors.join(", ")}`);
1542
+ }
1543
+
1544
+ const failed = match.failed_approaches || [];
1545
+ if (failed.length > 0) {
1546
+ print(` ⚠️ ${failed.length} failed approach(es)`);
1547
+ }
1548
+
1549
+ print(`\n Thread context will be injected into your next AI session.`);
1550
+ print("");
1551
+ }
1552
+
1553
+ function simpleThreadSearch(threads, query, user) {
1554
+ const results = [];
1555
+
1556
+ for (const thread of threads) {
1557
+ let score = 0;
1558
+
1559
+ if (user && (thread.user || "").toLowerCase() !== user.toLowerCase()) continue;
1560
+ if (user) score += 1;
1561
+
1562
+ if (query) {
1563
+ const queryLower = query.toLowerCase();
1564
+ const fields = [
1565
+ thread.narrative || "",
1566
+ thread.last_note || "",
1567
+ thread.handoff_summary || "",
1568
+ (thread.files_touched || []).join(" "),
1569
+ (thread.failed_approaches || []).map((f) => f.description || "").join(" "),
1570
+ ].join(" ").toLowerCase();
1571
+
1572
+ const words = queryLower.split(/\s+/).filter((w) => w.length >= 2);
1573
+ let matches = 0;
1574
+ for (const w of words) {
1575
+ if (fields.includes(w)) matches++;
1576
+ }
1577
+ score += words.length > 0 ? (matches / words.length) * 3 : 0;
1578
+ }
1579
+
1580
+ if (score > 0 || (!query && user)) {
1581
+ results.push({ thread, score, match_reasons: [] });
1582
+ }
1583
+ }
1584
+
1585
+ results.sort((a, b) => b.score - a.score);
1586
+ return results;
1587
+ }
1588
+
1589
+ function formatThreadResult(candidate) {
1590
+ const t = candidate.thread;
1591
+ return {
1592
+ id: t.id,
1593
+ user: t.user || "unknown",
1594
+ status: t.status,
1595
+ branch: t.branch || "",
1596
+ narrative: (t.narrative || "").substring(0, 300),
1597
+ files_touched: (t.files_touched || []).slice(0, 10),
1598
+ score: Math.round(candidate.score * 10) / 10,
1599
+ match_reasons: candidate.match_reasons || [],
1600
+ contributors: t.contributors || [t.user || "unknown"],
1601
+ };
1602
+ }
1603
+
1604
+ function setActiveResume(stickyDir, threadId) {
1605
+ const resumePath = path.join(stickyDir, ".sticky-active-resume");
1606
+ const signalPath = path.join(stickyDir, ".sticky-resume");
1607
+ fs.writeFileSync(resumePath, threadId + "\n");
1608
+ fs.writeFileSync(signalPath, threadId + "\n");
1609
+ }
1610
+
1611
+ // ──────────────────────────────────────────────
1612
+ // Get Line Attribution command (V2.5)
1613
+ // ──────────────────────────────────────────────
1614
+
1615
+ function cmdGetLineAttribution() {
1616
+ const args = process.argv.slice(3);
1617
+
1618
+ // Parse flags
1619
+ let file = null;
1620
+ let lineRange = null;
1621
+ let since = null;
1622
+ let jsonOutput = true; // always JSON for this command
1623
+
1624
+ for (let i = 0; i < args.length; i++) {
1625
+ switch (args[i]) {
1626
+ case "--file":
1627
+ case "-f":
1628
+ if (i + 1 < args.length) file = args[++i];
1629
+ break;
1630
+ case "--line-range":
1631
+ case "--lines":
1632
+ if (i + 1 < args.length) {
1633
+ const parts = args[++i].split(/[-:,]/);
1634
+ if (parts.length === 2) {
1635
+ lineRange = [parseInt(parts[0], 10), parseInt(parts[1], 10)];
1636
+ }
1637
+ }
1638
+ break;
1639
+ case "--since":
1640
+ if (i + 1 < args.length) since = args[++i];
1641
+ break;
1642
+ default:
1643
+ if (!args[i].startsWith("-") && !file) file = args[i];
1644
+ break;
1645
+ }
1646
+ }
1647
+
1648
+ if (!file) {
1649
+ print(JSON.stringify({ error: "Usage: npx sticky-note get-line-attribution --file <path> [--line-range start:end] [--since ISO-date]" }));
1650
+ process.exit(1);
1651
+ }
1652
+
1653
+ // Load attribution engine
1654
+ let attribution;
1655
+ try {
1656
+ attribution = require(path.join(__dirname, "..", "templates", "hooks", "sticky-attribution.js"));
1657
+ } catch (_) {
1658
+ try {
1659
+ attribution = require(path.join(process.cwd(), ".claude", "hooks", "sticky-attribution.js"));
1660
+ } catch (_) {
1661
+ attribution = null;
1662
+ }
1663
+ }
1664
+
1665
+ if (!attribution) {
1666
+ print(JSON.stringify({ error: "Attribution engine not found. Run npx sticky-note update." }));
1667
+ process.exit(1);
1668
+ }
1669
+
1670
+ const options = {};
1671
+ if (lineRange) options.lineRange = lineRange;
1672
+ if (since) options.since = since;
1673
+
1674
+ const result = attribution.getFileAttribution(file, options);
1675
+
1676
+ const output = {
1677
+ file,
1678
+ threads: result.threads.map((t) => ({
1679
+ id: t.thread.id,
1680
+ user: t.thread.user,
1681
+ status: t.thread.status,
1682
+ branch: t.thread.branch,
1683
+ narrative: (t.thread.narrative || "").substring(0, 200),
1684
+ files_touched: (t.thread.files_touched || []).slice(0, 10),
1685
+ contributors: t.thread.contributors || [t.thread.user || "unknown"],
1686
+ lines: t.lines,
1687
+ line_ranges: t.line_ranges,
1688
+ tier: t.tier,
1689
+ })),
1690
+ line_map: result.line_map,
1691
+ };
1692
+
1693
+ print(JSON.stringify(output, null, 2));
1694
+ }
1695
+
1696
+ // ──────────────────────────────────────────────
1697
+ // Checkpoint command (V2.5)
1698
+ // ──────────────────────────────────────────────
1699
+
1700
+ function cmdCheckpoint() {
1701
+ const args = process.argv.slice(3);
1702
+ let topic = null;
1703
+ let clear = false;
1704
+ let show = false;
1705
+
1706
+ for (let i = 0; i < args.length; i++) {
1707
+ if (args[i] === "--topic" || args[i] === "-t") {
1708
+ topic = args[i + 1] || null;
1709
+ i++;
1710
+ } else if (args[i] === "--clear") {
1711
+ clear = true;
1712
+ } else if (args[i] === "--show") {
1713
+ show = true;
1714
+ } else if (!args[i].startsWith("-")) {
1715
+ // Positional argument = topic
1716
+ topic = args.slice(i).join(" ");
1717
+ break;
1718
+ }
1719
+ }
1720
+
1721
+ // Load git-notes module
1722
+ let gitNotes;
1723
+ try {
1724
+ gitNotes = require(path.join(__dirname, "..", "templates", "hooks", "sticky-git-notes.js"));
1725
+ } catch (_) {
1726
+ try {
1727
+ gitNotes = require(path.join(process.cwd(), ".claude", "hooks", "sticky-git-notes.js"));
1728
+ } catch (_) {
1729
+ print("[ERR] Git notes module not found. Run npx sticky-note update.");
1730
+ process.exit(1);
1731
+ }
1732
+ }
1733
+
1734
+ if (clear) {
1735
+ gitNotes.clearCheckpoint();
1736
+ print(" Checkpoint cleared.");
1737
+ return;
1738
+ }
1739
+
1740
+ if (show) {
1741
+ const cp = gitNotes.getCurrentCheckpoint();
1742
+ if (cp) {
1743
+ print(` Current checkpoint: ${cp.topic}`);
1744
+ print(` Set at: ${cp.ts}`);
1745
+ if (cp.session_id) print(` Session: ${cp.session_id}`);
1746
+ } else {
1747
+ print(" No active checkpoint.");
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+ if (!topic) {
1753
+ print("Usage: npx sticky-note checkpoint <topic>");
1754
+ print(" npx sticky-note checkpoint --topic \"fixing auth token expiry\"");
1755
+ print(" npx sticky-note checkpoint --show");
1756
+ print(" npx sticky-note checkpoint --clear");
1757
+ process.exit(1);
1758
+ }
1759
+
1760
+ // Load utils for session/user info
1761
+ let utils;
1762
+ try {
1763
+ utils = require(path.join(__dirname, "..", "templates", "hooks", "sticky-utils.js"));
1764
+ } catch (_) {
1765
+ try {
1766
+ utils = require(path.join(process.cwd(), ".claude", "hooks", "sticky-utils.js"));
1767
+ } catch (_) {
1768
+ utils = null;
1769
+ }
1770
+ }
1771
+
1772
+ const user = utils ? utils.getUser() : (process.env.USER || process.env.USERNAME || "unknown");
1773
+ const sessionId = utils ? utils.readSessionIdFromFile() : null;
1774
+ const now = new Date().toISOString();
1775
+
1776
+ const checkpoint = {
1777
+ topic,
1778
+ user,
1779
+ ts: now,
1780
+ session_id: sessionId || null,
1781
+ };
1782
+ gitNotes.saveCheckpoint(checkpoint);
1783
+
1784
+ // Also write to audit
1785
+ if (utils) {
1786
+ utils.appendAuditLine({
1787
+ type: "checkpoint",
1788
+ user,
1789
+ ts: now,
1790
+ session_id: sessionId,
1791
+ topic,
1792
+ });
1793
+ }
1794
+
1795
+ print(` ✓ Checkpoint set: "${topic}"`);
1796
+ print(` Subsequent AI edits will be tagged with this topic.`);
1797
+ }
1798
+
1799
+ // ──────────────────────────────────────────────
1800
+ // GC command (manual tombstone sweep)
1801
+ // ──────────────────────────────────────────────
1802
+
1803
+ function cmdGc() {
1804
+ printBanner();
1805
+
1806
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
1807
+ const memoryPath = path.join(stickyDir, "sticky-note.json");
1808
+ const configPath = path.join(stickyDir, "sticky-note-config.json");
1809
+
1810
+ if (!fs.existsSync(memoryPath)) {
1811
+ print(" [ERR] No sticky-note.json found.");
1812
+ process.exit(1);
1813
+ }
1814
+
1815
+ const memory = readJsonSafe(memoryPath, { version: "2", threads: [] });
1816
+ const config = readJsonSafe(configPath, { stale_days: 14 });
1817
+ const staleDays = config.stale_days ?? 14;
1818
+ const threads = memory.threads || [];
1819
+ const now = new Date();
1820
+
1821
+ let tombstoned = 0;
1822
+ for (const thread of threads) {
1823
+ if (thread.status !== "closed") continue;
1824
+
1825
+ const tsField = thread.last_activity_at || thread.closed_at || thread.updated_at || "";
1826
+ if (!tsField) continue;
1827
+
1828
+ const ts = new Date(tsField);
1829
+ const daysAgo = (now - ts) / (1000 * 60 * 60 * 24);
1830
+
1831
+ if (daysAgo >= staleDays) {
1832
+ const id = thread.id;
1833
+ const user = thread.user || thread.author || "unknown";
1834
+ const closedAt = thread.closed_at || tsField;
1835
+
1836
+ // Replace thread data with minimal tombstone
1837
+ const tombstone = { id, status: "expired", user, closed_at: closedAt };
1838
+ const keys = Object.keys(thread);
1839
+ for (const k of keys) delete thread[k];
1840
+ Object.assign(thread, tombstone);
1841
+ tombstoned++;
1842
+ }
1843
+ }
1844
+
1845
+ if (tombstoned > 0) {
1846
+ fs.writeFileSync(memoryPath, JSON.stringify(memory, null, 2) + "\n");
1847
+ print(` 🗑️ Tombstoned ${tombstoned} closed thread(s) older than ${staleDays} days.`);
1848
+ } else {
1849
+ print(` [OK] No threads to tombstone (stale_days=${staleDays}).`);
1850
+ }
1851
+ print("");
1852
+ }
1853
+
1854
+ // ──────────────────────────────────────────────
1855
+ // Main
1856
+ // ──────────────────────────────────────────────
1857
+
1858
+ async function main() {
1859
+ const args = process.argv.slice(2);
1860
+ const command = args[0];
1861
+
1862
+ switch (command) {
1863
+ case "init":
1864
+ await cmdInit();
1865
+ break;
1866
+ case "update":
1867
+ cmdUpdate();
1868
+ break;
1869
+ case "status":
1870
+ cmdStatus();
1871
+ break;
1872
+ case "threads":
1873
+ cmdThreads();
1874
+ break;
1875
+ case "resume":
1876
+ cmdResume();
1877
+ break;
1878
+ case "audit":
1879
+ cmdAudit();
1880
+ break;
1881
+ case "who":
1882
+ cmdWho();
1883
+ break;
1884
+ case "switch":
1885
+ cmdSwitch();
1886
+ break;
1887
+ case "gc":
1888
+ cmdGc();
1889
+ break;
1890
+ case "reset":
1891
+ cmdReset();
1892
+ break;
1893
+ case "resume-thread":
1894
+ cmdResumeThread();
1895
+ break;
1896
+ case "get-line-attribution":
1897
+ cmdGetLineAttribution();
1898
+ break;
1899
+ case "checkpoint":
1900
+ cmdCheckpoint();
1901
+ break;
1902
+ case "--version":
1903
+ case "-v":
1904
+ print(`sticky-note v${VERSION}`);
1905
+ break;
1906
+ case "--help":
1907
+ case "-h":
1908
+ case undefined:
1909
+ printBanner();
1910
+ print(" Usage: npx sticky-note <command>\n");
1911
+ print(" Commands:");
1912
+ print(" init Interactive setup — creates V2.5 hooks and config");
1913
+ print(" update Update hook scripts (preserves data)");
1914
+ print(" status Diagnostic report: threads, audit, attribution health");
1915
+ print(" threads List open/stuck threads");
1916
+ print(" resume Resume a previous thread (--list, --clear, <id>)");
1917
+ print(" resume-thread Smart thread resume (--query, --user, --file, --json)");
1918
+ print(" audit Query audit trail (--file, --user, --since, --session)");
1919
+ print(" who Show active and recent team members");
1920
+ print(" switch Safe branch switching (auto-stashes .sticky-note/)");
1921
+ print(" gc Manual tombstone sweep for expired threads");
1922
+ print(" reset Wipe all threads and start fresh (--force, --keep-audit)");
1923
+ print(" get-line-attribution File→thread attribution with line ranges (--file, --lines)");
1924
+ print(" checkpoint Set work-topic checkpoint for attribution (--topic, --show, --clear)");
1925
+ print("");
1926
+ print(" Options:");
1927
+ print(" --version Show version");
1928
+ print(" --help Show this help");
1929
+ print("");
1930
+ break;
1931
+ default:
1932
+ print(` [ERR] Unknown command: ${command}`);
1933
+ print(` Run 'npx sticky-note --help' for usage.`);
1934
+ process.exit(1);
1935
+ }
1936
+ }
1937
+
1938
+ main().catch(err => {
1939
+ process.stderr.write(`Fatal error: ${err.message}\n`);
1940
+ process.exit(1);
1941
+ });