sticky-note-cli 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * track-work.js — Audit Trail (V2.5)
5
+ *
6
+ * Hook: PostToolUse (Claude Code) / postToolUse (Copilot CLI)
7
+ * Appends one JSONL line per tool call. Updates presence heartbeat.
8
+ * V2.5: Captures line-level changes for write tools and writes Git Notes.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const { execFileSync } = require("child_process");
14
+
15
+ function safeExit() {
16
+ try {
17
+ process.stdout.write(JSON.stringify({ output: "" }) + "\n");
18
+ } catch (_) {
19
+ process.stdout.write('{"output":""}\n');
20
+ }
21
+ process.exit(0);
22
+ }
23
+
24
+ let utils;
25
+ try {
26
+ utils = require("./sticky-utils.js");
27
+ } catch (_) {
28
+ safeExit();
29
+ }
30
+
31
+ let gitNotes;
32
+ try {
33
+ gitNotes = require("./sticky-git-notes.js");
34
+ } catch (_) {
35
+ gitNotes = null;
36
+ }
37
+
38
+ const {
39
+ getConfigPath,
40
+ getUserPresencePath,
41
+ loadJson,
42
+ saveJson,
43
+ appendAuditLine,
44
+ getUser,
45
+ getSessionId,
46
+ } = utils;
47
+
48
+ const WRITE_TOOLS = new Set([
49
+ "edit", "Edit", "create", "Create", "Write", "write", "MultiEdit", "multi_edit",
50
+ ]);
51
+
52
+ function _debugPath() {
53
+ const scriptDir = path.dirname(path.resolve(__filename));
54
+ return path.join(scriptDir, "..", "..", ".sticky-note", ".sticky-debug.jsonl");
55
+ }
56
+
57
+ function logDebug(toolName, hookInput) {
58
+ if (!WRITE_TOOLS.has(toolName)) return;
59
+ try {
60
+ const entry = {
61
+ ts: new Date().toISOString(),
62
+ tool: toolName,
63
+ hook_input_keys: Object.keys(hookInput).sort(),
64
+ hook_input: {},
65
+ };
66
+ const exclude = new Set(["output", "result", "stdout", "stderr"]);
67
+ for (const [k, v] of Object.entries(hookInput)) {
68
+ if (!exclude.has(k)) entry.hook_input[k] = v;
69
+ }
70
+ const debugFile = _debugPath();
71
+ fs.mkdirSync(path.dirname(debugFile), { recursive: true });
72
+ fs.appendFileSync(debugFile, JSON.stringify(entry) + "\n", "utf-8");
73
+ } catch (_) {
74
+ // ignore
75
+ }
76
+ }
77
+
78
+ function extractFilePath(hookInput) {
79
+ const cwd = hookInput.cwd || "";
80
+ let rawPath = null;
81
+
82
+ for (const containerKey of ["tool_input", "input", "toolInput", "toolArgs", "params"]) {
83
+ const container = hookInput[containerKey];
84
+ if (container && typeof container === "object" && !Array.isArray(container)) {
85
+ for (const key of ["file_path", "filePath", "path", "filename", "file"]) {
86
+ if (key in container) {
87
+ rawPath = container[key];
88
+ break;
89
+ }
90
+ }
91
+ }
92
+ if (rawPath) break;
93
+ }
94
+
95
+ if (!rawPath) {
96
+ for (const key of ["file_path", "filePath", "path", "file"]) {
97
+ if (key in hookInput) {
98
+ rawPath = hookInput[key];
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ if (!rawPath) return null;
105
+
106
+ if (cwd && path.isAbsolute(rawPath)) {
107
+ try {
108
+ return path.relative(cwd, rawPath);
109
+ } catch (_) {
110
+ // fall through
111
+ }
112
+ }
113
+ return rawPath;
114
+ }
115
+
116
+ function updatePresence(user, filePath) {
117
+ const presencePath = getUserPresencePath(user);
118
+ try {
119
+ const entry = loadJson(presencePath, { active_files: [], last_seen: "" });
120
+
121
+ if (filePath) {
122
+ const active = entry.active_files || [];
123
+ if (!active.includes(filePath)) {
124
+ active.push(filePath);
125
+ }
126
+ entry.active_files = [...new Set(active)].slice(-10);
127
+ }
128
+
129
+ entry.last_seen = new Date().toISOString();
130
+ saveJson(presencePath, entry);
131
+ } catch (_) {
132
+ // ignore
133
+ }
134
+ }
135
+
136
+ function autoDetectMcp(toolName) {
137
+ if (!toolName || !toolName.startsWith("mcp__")) return null;
138
+ const parts = toolName.split("__");
139
+ if (parts.length >= 2) return parts[1];
140
+ return null;
141
+ }
142
+
143
+ // ── V2.5: Line-level change tracking ─────────────────────
144
+
145
+ /**
146
+ * After a write tool completes, capture exact line ranges changed.
147
+ * Uses git diff --unified=0 to get precise line numbers.
148
+ * Returns array of { start, count } or null on failure.
149
+ */
150
+ function captureLineChanges(filePath) {
151
+ if (!filePath) return null;
152
+ try {
153
+ const raw = execFileSync("git", ["diff", "--unified=0", "--", filePath], {
154
+ encoding: "utf-8",
155
+ timeout: 5000,
156
+ stdio: ["pipe", "pipe", "pipe"],
157
+ });
158
+ if (!raw.trim()) return null;
159
+
160
+ const ranges = [];
161
+ for (const line of raw.split(/\r?\n/)) {
162
+ // @@ -old_start,old_count +new_start,new_count @@
163
+ const match = line.match(/^@@\s.*\+(\d+)(?:,(\d+))?\s@@/);
164
+ if (match) {
165
+ const start = parseInt(match[1], 10);
166
+ const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;
167
+ if (count > 0) {
168
+ ranges.push({ start, count, end: start + count - 1 });
169
+ }
170
+ }
171
+ }
172
+ return ranges.length > 0 ? ranges : null;
173
+ } catch (_) {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Write a Git Note on HEAD linking this edit to the session.
180
+ */
181
+ function writeEditNote(sessionId, user, filePath, lineRanges, checkpoint) {
182
+ if (!gitNotes) return;
183
+ try {
184
+ const headSha = execFileSync("git", ["rev-parse", "HEAD"], {
185
+ encoding: "utf-8",
186
+ timeout: 3000,
187
+ stdio: ["pipe", "pipe", "pipe"],
188
+ }).trim();
189
+ if (!headSha || !/^[0-9a-f]{40}$/.test(headSha)) return;
190
+
191
+ const noteData = {
192
+ session_id: sessionId,
193
+ user,
194
+ file: filePath,
195
+ ts: new Date().toISOString(),
196
+ type: "ai_edit",
197
+ };
198
+ if (lineRanges) {
199
+ noteData.lines_changed = lineRanges.map((r) => `${r.start}-${r.end}`);
200
+ }
201
+ if (checkpoint) {
202
+ noteData.checkpoint = checkpoint.topic;
203
+ }
204
+ gitNotes.writeNote(headSha, noteData);
205
+ } catch (_) {
206
+ // ignore — notes are best-effort
207
+ }
208
+ }
209
+
210
+ // ── Main ──────────────────────────────────────────────────
211
+
212
+ function main() {
213
+ let hookInput = {};
214
+ try {
215
+ if (!process.stdin.isTTY) {
216
+ const raw = fs.readFileSync(0, "utf-8").trim();
217
+ if (raw) {
218
+ hookInput = JSON.parse(raw);
219
+ }
220
+ }
221
+ } catch (_) {
222
+ hookInput = {};
223
+ }
224
+
225
+ const sessionId = getSessionId(hookInput);
226
+
227
+ let toolName = hookInput.tool_name || "unknown";
228
+ if (toolName === "unknown") {
229
+ const toolObj = hookInput.tool || "unknown";
230
+ if (toolObj && typeof toolObj === "object") {
231
+ toolName = toolObj.name || "unknown";
232
+ } else if (typeof toolObj === "string") {
233
+ toolName = toolObj;
234
+ }
235
+ }
236
+ if (toolName === "unknown") {
237
+ for (const key of ["toolName", "name"]) {
238
+ const val = hookInput[key];
239
+ if (val && typeof val === "string") {
240
+ toolName = val;
241
+ break;
242
+ }
243
+ }
244
+ }
245
+
246
+ const filePath = extractFilePath(hookInput);
247
+ if (!filePath) {
248
+ logDebug(toolName, hookInput);
249
+ }
250
+
251
+ const user = getUser();
252
+ const now = new Date().toISOString();
253
+ const isWriteTool = WRITE_TOOLS.has(toolName);
254
+
255
+ // V2.5: Capture line-level changes for write tools
256
+ let lineRanges = null;
257
+ let checkpoint = null;
258
+ if (isWriteTool && filePath) {
259
+ lineRanges = captureLineChanges(filePath);
260
+ if (gitNotes) {
261
+ checkpoint = gitNotes.getCurrentCheckpoint();
262
+ }
263
+ }
264
+
265
+ const entry = {
266
+ type: "tool_use",
267
+ user,
268
+ ts: now,
269
+ tool: toolName,
270
+ session_id: sessionId,
271
+ };
272
+ if (filePath) {
273
+ entry.file = filePath;
274
+ }
275
+ if (lineRanges) {
276
+ entry.lines_changed = lineRanges.map((r) => `${r.start}-${r.end}`);
277
+ }
278
+ if (checkpoint) {
279
+ entry.checkpoint_topic = checkpoint.topic;
280
+ }
281
+ appendAuditLine(entry);
282
+
283
+ updatePresence(user, filePath);
284
+
285
+ // V2.5: Write Git Note for write tools
286
+ if (isWriteTool && filePath) {
287
+ writeEditNote(sessionId, user, filePath, lineRanges, checkpoint);
288
+ }
289
+
290
+ const serverName = autoDetectMcp(toolName);
291
+ if (serverName) {
292
+ const configPath = getConfigPath();
293
+ const config = loadJson(configPath, { stale_days: 14, mcp_servers: [] });
294
+ const mcpServers = config.mcp_servers || [];
295
+ const knownNames = new Set(
296
+ mcpServers.map((s) => (typeof s === "object" ? s.name : s))
297
+ );
298
+ if (!knownNames.has(serverName)) {
299
+ mcpServers.push({ name: serverName, source: "auto-detected" });
300
+ config.mcp_servers = mcpServers;
301
+ saveJson(configPath, config);
302
+ }
303
+ }
304
+
305
+ // Build status message for transparency
306
+ const linePart = lineRanges && lineRanges.length > 0 ? ` (lines ${lineRanges.map(r => `${r.start}-${r.end}`).join(", ")})` : "";
307
+ const statusMsg = `[STICKY-NOTE] Tracked ${toolName}${filePath ? " on " + filePath : ""}${linePart}`;
308
+
309
+ try {
310
+ process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
311
+ } catch (_) {
312
+ process.stdout.write('{"output":""}\n');
313
+ }
314
+ process.exit(0);
315
+ }
316
+
317
+ try {
318
+ main();
319
+ } catch (_) {
320
+ safeExit();
321
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "sessionStart": [
5
+ {
6
+ "type": "command",
7
+ "bash": "COPILOT_CLI=1 node .claude/hooks/session-start.js --copilot-cli",
8
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/session-start.js --copilot-cli"
9
+ }
10
+ ],
11
+ "sessionEnd": [
12
+ {
13
+ "type": "command",
14
+ "bash": "COPILOT_CLI=1 node .claude/hooks/session-end.js --copilot-cli",
15
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/session-end.js --copilot-cli"
16
+ }
17
+ ],
18
+ "userPromptSubmitted": [
19
+ {
20
+ "type": "command",
21
+ "bash": "COPILOT_CLI=1 node .claude/hooks/inject-context.js --copilot-cli",
22
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/inject-context.js --copilot-cli"
23
+ }
24
+ ],
25
+ "preToolUse": [
26
+ {
27
+ "type": "command",
28
+ "bash": "COPILOT_CLI=1 node .claude/hooks/pre-tool-use.js --copilot-cli",
29
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/pre-tool-use.js --copilot-cli"
30
+ }
31
+ ],
32
+ "postToolUse": [
33
+ {
34
+ "type": "command",
35
+ "bash": "COPILOT_CLI=1 node .claude/hooks/track-work.js --copilot-cli",
36
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/track-work.js --copilot-cli"
37
+ }
38
+ ],
39
+ "errorOccurred": [
40
+ {
41
+ "type": "command",
42
+ "bash": "COPILOT_CLI=1 node .claude/hooks/on-error.js --copilot-cli",
43
+ "powershell": "$env:COPILOT_CLI='1'; node .claude/hooks/on-error.js --copilot-cli"
44
+ }
45
+ ]
46
+ }
47
+ }
@@ -0,0 +1,81 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "node .claude/hooks/session-start.js"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "SessionEnd": [
15
+ {
16
+ "matcher": "",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node .claude/hooks/session-end.js"
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "UserPromptSubmit": [
26
+ {
27
+ "matcher": "",
28
+ "hooks": [
29
+ {
30
+ "type": "command",
31
+ "command": "node .claude/hooks/inject-context.js"
32
+ }
33
+ ]
34
+ }
35
+ ],
36
+ "PreToolUse": [
37
+ {
38
+ "matcher": "",
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node .claude/hooks/pre-tool-use.js"
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "PostToolUse": [
48
+ {
49
+ "matcher": "",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "node .claude/hooks/track-work.js"
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "Stop": [
59
+ {
60
+ "matcher": "",
61
+ "hooks": [
62
+ {
63
+ "type": "command",
64
+ "command": "node .claude/hooks/on-stop.js"
65
+ }
66
+ ]
67
+ }
68
+ ],
69
+ "PostToolUseFailure": [
70
+ {
71
+ "matcher": "",
72
+ "hooks": [
73
+ {
74
+ "type": "command",
75
+ "command": "node .claude/hooks/on-error.js"
76
+ }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "stale_days": 14,
3
+ "inject_token_budget": 1000,
4
+ "mcp_servers": [],
5
+ "skills": [],
6
+ "conventions": [],
7
+ "hook_version": "2.5.0"
8
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "2",
3
+ "project": "",
4
+ "threads": []
5
+ }