claude-mneme 2.9.1

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,353 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PostToolUse Hook - Task and Git Commit Capture
4
+ * Captures task context from TodoWrite, TaskCreate, TaskUpdate
5
+ * and commit messages from Bash git commits
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { ensureMemoryDirs, appendLogEntry, withFileLock, logError } from './utils.mjs';
11
+
12
+ /**
13
+ * Read the persisted todo hash from disk (survives across process invocations)
14
+ */
15
+ function readLastTodoHash(projectDir) {
16
+ const hashPath = join(projectDir, '.last-todo-hash');
17
+ try {
18
+ return existsSync(hashPath) ? readFileSync(hashPath, 'utf-8').trim() : '';
19
+ } catch {
20
+ return '';
21
+ }
22
+ }
23
+
24
+ function writeLastTodoHash(projectDir, hash) {
25
+ try {
26
+ writeFileSync(join(projectDir, '.last-todo-hash'), hash);
27
+ } catch (e) {
28
+ logError(e, 'post-tool-use:writeLastTodoHash');
29
+ }
30
+ }
31
+
32
+ // Read hook input from stdin
33
+ let input = '';
34
+ process.stdin.setEncoding('utf8');
35
+ process.stdin.on('data', chunk => input += chunk);
36
+ process.stdin.on('end', () => {
37
+ try {
38
+ const hookData = JSON.parse(input);
39
+ processToolUse(hookData);
40
+ } catch (e) {
41
+ logError(e, 'post-tool-use');
42
+ process.exit(0);
43
+ }
44
+ });
45
+
46
+ /**
47
+ * Extract commit message from git commit command
48
+ */
49
+ function extractCommitMessage(command) {
50
+ if (!command || typeof command !== 'string') {
51
+ return null;
52
+ }
53
+
54
+ // Match various git commit patterns
55
+ // HEREDOC style: git commit -m "$(cat <<'EOF'\nmessage\nEOF\n)"
56
+ // git commit -m "message"
57
+ // git commit -m 'message'
58
+ // git commit -am "message"
59
+ // git commit --message="message"
60
+
61
+ // HEREDOC pattern first — must check before simple -m (which would match the shell wrapper)
62
+ let match = command.match(/<<['"]?EOF['"]?\s*\n\s*([^\n]+)/);
63
+ if (match) {
64
+ return match[1].trim();
65
+ }
66
+
67
+ // Simple -m flag patterns (also handles combined flags like -am)
68
+ match = command.match(/git\s+commit\s+[^"']*-[a-z]*m\s*["']([^"']+)["']/);
69
+ if (match) {
70
+ return match[1].trim();
71
+ }
72
+
73
+ // --message= pattern
74
+ match = command.match(/git\s+commit\s+[^"']*--message=["']([^"']+)["']/);
75
+ if (match) {
76
+ return match[1].trim();
77
+ }
78
+
79
+ // Fallback: try to find any quoted string after commit
80
+ match = command.match(/git\s+commit.*["']([^"']{10,})["']/);
81
+ if (match) {
82
+ return match[1].trim();
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Process TodoWrite tool usage
90
+ */
91
+ function processTodoWrite(hookData) {
92
+ const { tool_input, cwd } = hookData;
93
+
94
+ const todos = tool_input?.todos;
95
+ if (!todos || !Array.isArray(todos) || todos.length === 0) {
96
+ return false;
97
+ }
98
+
99
+ // Extract meaningful task info
100
+ const inProgress = todos.filter(t => t.status === 'in_progress').map(t => t.content);
101
+ const completed = todos.filter(t => t.status === 'completed').map(t => t.content);
102
+ const pending = todos.filter(t => t.status === 'pending').map(t => t.content);
103
+
104
+ // Create a hash to detect significant changes
105
+ const todoHash = JSON.stringify({ inProgress, completed: completed.length, pending: pending.length });
106
+
107
+ // Only log if there's a meaningful change (new in_progress task or completions)
108
+ const paths = ensureMemoryDirs(cwd || process.cwd());
109
+ if (todoHash === readLastTodoHash(paths.project)) {
110
+ return false;
111
+ }
112
+ writeLastTodoHash(paths.project, todoHash);
113
+
114
+ // Build content focusing on what's being worked on
115
+ const parts = [];
116
+ if (inProgress.length > 0) {
117
+ parts.push(`Working on: ${inProgress.join(', ')}`);
118
+ }
119
+ if (completed.length > 0 && pending.length > 0) {
120
+ parts.push(`(${completed.length} done, ${pending.length} remaining)`);
121
+ }
122
+
123
+ if (parts.length === 0) {
124
+ return false;
125
+ }
126
+
127
+ const entry = {
128
+ ts: new Date().toISOString(),
129
+ type: 'task',
130
+ content: parts.join(' ')
131
+ };
132
+
133
+ appendLogEntry(entry, cwd || process.cwd());
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Read the task subject tracking file
139
+ * Enhanced to track full task lifecycle for outcome tracking
140
+ */
141
+ function readTaskTracking(projectDir) {
142
+ const trackingPath = join(projectDir, 'active-tasks.json');
143
+ if (existsSync(trackingPath)) {
144
+ try {
145
+ const data = JSON.parse(readFileSync(trackingPath, 'utf-8'));
146
+ // Migrate old format (simple subjects) to new format (task objects)
147
+ if (data.subjects && typeof Object.values(data.subjects)[0] === 'string') {
148
+ const migrated = { nextId: data.nextId, tasks: {} };
149
+ for (const [id, subject] of Object.entries(data.subjects)) {
150
+ migrated.tasks[id] = { subject, status: 'pending', createdAt: null };
151
+ }
152
+ return migrated;
153
+ }
154
+ return data;
155
+ } catch {
156
+ return { nextId: 1, tasks: {} };
157
+ }
158
+ }
159
+ return { nextId: 1, tasks: {} };
160
+ }
161
+
162
+ /**
163
+ * Write the task subject tracking file
164
+ */
165
+ function writeTaskTracking(projectDir, tracking) {
166
+ const trackingPath = join(projectDir, 'active-tasks.json');
167
+ writeFileSync(trackingPath, JSON.stringify(tracking));
168
+ }
169
+
170
+ /**
171
+ * Process TaskCreate tool usage
172
+ * Stores the task with metadata for outcome tracking
173
+ */
174
+ function processTaskCreate(hookData) {
175
+ const { tool_input, cwd } = hookData;
176
+
177
+ const { subject, description } = tool_input || {};
178
+ if (!subject) {
179
+ return false;
180
+ }
181
+
182
+ const paths = ensureMemoryDirs(cwd || process.cwd());
183
+ const taskLockPath = join(paths.project, 'active-tasks.json.lock');
184
+ const result = withFileLock(taskLockPath, () => {
185
+ const tracking = readTaskTracking(paths.project);
186
+
187
+ tracking.tasks = tracking.tasks || {};
188
+ const task = {
189
+ subject,
190
+ status: 'pending',
191
+ createdAt: new Date().toISOString()
192
+ };
193
+ if (description) task.description = description;
194
+ tracking.tasks[String(tracking.nextId)] = task;
195
+ tracking.nextId++;
196
+
197
+ writeTaskTracking(paths.project, tracking);
198
+ return true;
199
+ }, 5);
200
+ return result === true;
201
+ }
202
+
203
+ /**
204
+ * Process TaskUpdate tool usage
205
+ * Tracks task lifecycle and logs meaningful state changes with outcomes
206
+ */
207
+ function processTaskUpdate(hookData) {
208
+ const { tool_input, cwd } = hookData;
209
+
210
+ const { taskId, status, subject } = tool_input || {};
211
+ if (!taskId) {
212
+ return false;
213
+ }
214
+
215
+ const effectiveCwd = cwd || process.cwd();
216
+ const paths = ensureMemoryDirs(effectiveCwd);
217
+ const taskLockPath = join(paths.project, 'active-tasks.json.lock');
218
+
219
+ // Collect log entries to append outside the lock (appendLogEntry has its own locking)
220
+ let logEntry = null;
221
+
222
+ const result = withFileLock(taskLockPath, () => {
223
+ const tracking = readTaskTracking(paths.project);
224
+ tracking.tasks = tracking.tasks || {};
225
+
226
+ const task = tracking.tasks[String(taskId)];
227
+
228
+ // Handle deleted tasks - log as abandoned if was in progress
229
+ if (status === 'deleted') {
230
+ if (task && task.status === 'in_progress') {
231
+ logEntry = {
232
+ ts: new Date().toISOString(),
233
+ type: 'task',
234
+ action: 'abandoned',
235
+ outcome: 'abandoned',
236
+ subject: task.subject,
237
+ duration: task.createdAt ? Date.now() - new Date(task.createdAt).getTime() : null
238
+ };
239
+ if (task.description) logEntry.description = task.description;
240
+ }
241
+ delete tracking.tasks[String(taskId)];
242
+ writeTaskTracking(paths.project, tracking);
243
+ return task?.status === 'in_progress';
244
+ }
245
+
246
+ // Update task status
247
+ if (task) {
248
+ task.status = status;
249
+
250
+ if (status === 'in_progress' && !task.startedAt) {
251
+ task.startedAt = new Date().toISOString();
252
+ }
253
+
254
+ if (status === 'completed') {
255
+ const resolvedSubject = subject || task.subject;
256
+ if (!resolvedSubject) return false;
257
+
258
+ logEntry = {
259
+ ts: new Date().toISOString(),
260
+ type: 'task',
261
+ action: 'completed',
262
+ outcome: 'completed',
263
+ subject: resolvedSubject,
264
+ duration: task.startedAt ? Date.now() - new Date(task.startedAt).getTime() : null
265
+ };
266
+
267
+ delete tracking.tasks[String(taskId)];
268
+ writeTaskTracking(paths.project, tracking);
269
+ return true;
270
+ }
271
+
272
+ writeTaskTracking(paths.project, tracking);
273
+ } else {
274
+ tracking.tasks[String(taskId)] = {
275
+ subject: subject || `Task ${taskId}`,
276
+ status: status || 'pending',
277
+ createdAt: new Date().toISOString(),
278
+ startedAt: status === 'in_progress' ? new Date().toISOString() : null
279
+ };
280
+ writeTaskTracking(paths.project, tracking);
281
+ }
282
+
283
+ return false;
284
+ }, 5);
285
+
286
+ // Append log entry outside the task lock to avoid nested lock contention
287
+ if (logEntry) {
288
+ appendLogEntry(logEntry, effectiveCwd);
289
+ }
290
+
291
+ return result === true;
292
+ }
293
+
294
+ /**
295
+ * Process Bash tool usage - only capture git commits
296
+ */
297
+ function processBash(hookData) {
298
+ const { tool_input, cwd } = hookData;
299
+
300
+ const command = tool_input?.command;
301
+ if (!command || typeof command !== 'string') {
302
+ return false;
303
+ }
304
+
305
+ // Only capture git commit commands
306
+ if (!command.includes('git') || !command.includes('commit')) {
307
+ return false;
308
+ }
309
+
310
+ // More specific check - must be a git commit command
311
+ if (!/git\s+commit/.test(command)) {
312
+ return false;
313
+ }
314
+
315
+ const commitMessage = extractCommitMessage(command);
316
+ if (!commitMessage) {
317
+ return false;
318
+ }
319
+
320
+ // Truncate very long commit messages
321
+ let message = commitMessage;
322
+ if (message.length > 200) {
323
+ message = message.substring(0, 200) + '...';
324
+ }
325
+
326
+ const entry = {
327
+ ts: new Date().toISOString(),
328
+ type: 'commit',
329
+ content: message
330
+ };
331
+
332
+ appendLogEntry(entry, cwd || process.cwd());
333
+ return true;
334
+ }
335
+
336
+ function processToolUse(hookData) {
337
+ const { tool_name } = hookData;
338
+
339
+ if (tool_name === 'TodoWrite') {
340
+ processTodoWrite(hookData);
341
+ } else if (tool_name === 'TaskCreate') {
342
+ processTaskCreate(hookData);
343
+ } else if (tool_name === 'TaskUpdate') {
344
+ processTaskUpdate(hookData);
345
+ } else if (tool_name === 'Bash') {
346
+ processBash(hookData);
347
+ }
348
+
349
+ process.exit(0);
350
+ }
351
+
352
+ // Timeout fallback
353
+ setTimeout(() => process.exit(0), 5000);