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
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
|
+
});
|