@tekmidian/pai 0.3.2 → 0.4.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.
Files changed (57) hide show
  1. package/dist/cli/index.mjs +279 -21
  2. package/dist/cli/index.mjs.map +1 -1
  3. package/dist/hooks/capture-all-events.mjs +238 -0
  4. package/dist/hooks/capture-all-events.mjs.map +7 -0
  5. package/dist/hooks/capture-session-summary.mjs +198 -0
  6. package/dist/hooks/capture-session-summary.mjs.map +7 -0
  7. package/dist/hooks/capture-tool-output.mjs +105 -0
  8. package/dist/hooks/capture-tool-output.mjs.map +7 -0
  9. package/dist/hooks/cleanup-session-files.mjs +129 -0
  10. package/dist/hooks/cleanup-session-files.mjs.map +7 -0
  11. package/dist/hooks/context-compression-hook.mjs +283 -0
  12. package/dist/hooks/context-compression-hook.mjs.map +7 -0
  13. package/dist/hooks/initialize-session.mjs +206 -0
  14. package/dist/hooks/initialize-session.mjs.map +7 -0
  15. package/dist/hooks/load-core-context.mjs +110 -0
  16. package/dist/hooks/load-core-context.mjs.map +7 -0
  17. package/dist/hooks/load-project-context.mjs +548 -0
  18. package/dist/hooks/load-project-context.mjs.map +7 -0
  19. package/dist/hooks/security-validator.mjs +159 -0
  20. package/dist/hooks/security-validator.mjs.map +7 -0
  21. package/dist/hooks/stop-hook.mjs +625 -0
  22. package/dist/hooks/stop-hook.mjs.map +7 -0
  23. package/dist/hooks/subagent-stop-hook.mjs +152 -0
  24. package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
  25. package/dist/hooks/sync-todo-to-md.mjs +322 -0
  26. package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
  27. package/dist/hooks/update-tab-on-action.mjs +90 -0
  28. package/dist/hooks/update-tab-on-action.mjs.map +7 -0
  29. package/dist/hooks/update-tab-titles.mjs +55 -0
  30. package/dist/hooks/update-tab-titles.mjs.map +7 -0
  31. package/package.json +4 -2
  32. package/scripts/build-hooks.mjs +51 -0
  33. package/src/hooks/ts/capture-all-events.ts +179 -0
  34. package/src/hooks/ts/lib/detect-environment.ts +53 -0
  35. package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
  36. package/src/hooks/ts/lib/pai-paths.ts +124 -0
  37. package/src/hooks/ts/lib/project-utils.ts +914 -0
  38. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
  39. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
  40. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
  41. package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
  42. package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
  43. package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
  44. package/src/hooks/ts/session-start/initialize-session.ts +155 -0
  45. package/src/hooks/ts/session-start/load-core-context.ts +104 -0
  46. package/src/hooks/ts/session-start/load-project-context.ts +394 -0
  47. package/src/hooks/ts/stop/stop-hook.ts +407 -0
  48. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
  49. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
  50. package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
  51. package/tab-color-command.sh +24 -0
  52. package/templates/skills/createskill-skill.template.md +78 -0
  53. package/templates/skills/history-system.template.md +371 -0
  54. package/templates/skills/hook-system.template.md +913 -0
  55. package/templates/skills/sessions-skill.template.md +102 -0
  56. package/templates/skills/skill-system.template.md +214 -0
  57. package/templates/skills/terminal-tabs.template.md +120 -0
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * load-project-context.ts
5
+ *
6
+ * SessionStart hook that sets up project context:
7
+ * - Checks for CLAUDE.md in various locations (Claude Code handles loading)
8
+ * - Sets up Notes/ directory in ~/.claude/projects/{encoded-path}/
9
+ * - Ensures TODO.md exists
10
+ * - Sends ntfy.sh notification (mandatory)
11
+ * - Displays session continuity info (like session-init.sh)
12
+ *
13
+ * This hook complements Claude Code's native CLAUDE.md loading by:
14
+ * - Setting up the Notes infrastructure
15
+ * - Showing the latest session note for continuity
16
+ * - Sending ntfy.sh notifications
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
20
+ import { join, basename, dirname } from 'path';
21
+ import { execSync } from 'child_process';
22
+ import {
23
+ PAI_DIR,
24
+ findNotesDir,
25
+ getProjectDir,
26
+ getCurrentNotePath,
27
+ createSessionNote,
28
+ findTodoPath,
29
+ findAllClaudeMdPaths,
30
+ sendNtfyNotification
31
+ } from '../lib/project-utils';
32
+
33
+ /**
34
+ * Find the pai CLI binary path dynamically.
35
+ * Tries `which pai` first, then common fallback locations.
36
+ */
37
+ function findPaiBinary(): string {
38
+ try {
39
+ return execSync('which pai', { encoding: 'utf-8' }).trim();
40
+ } catch {
41
+ // Fallback locations in order of preference
42
+ const fallbacks = [
43
+ '/usr/local/bin/pai',
44
+ '/opt/homebrew/bin/pai',
45
+ `${process.env.HOME}/.local/bin/pai`,
46
+ ];
47
+ for (const p of fallbacks) {
48
+ if (existsSync(p)) return p;
49
+ }
50
+ }
51
+ return 'pai'; // Last resort: rely on PATH at runtime
52
+ }
53
+
54
+ /**
55
+ * Check session-routing.json for an active route.
56
+ * Returns the routed Notes path if set, or null to use default behavior.
57
+ */
58
+ function getRoutedNotesPath(): string | null {
59
+ const routingFile = join(PAI_DIR, 'session-routing.json');
60
+ if (!existsSync(routingFile)) return null;
61
+
62
+ try {
63
+ const routing = JSON.parse(readFileSync(routingFile, 'utf-8'));
64
+ const active = routing?.active_session;
65
+ if (active?.notes_path) {
66
+ return active.notes_path;
67
+ }
68
+ } catch {
69
+ // Ignore parse errors
70
+ }
71
+ return null;
72
+ }
73
+
74
+ interface HookInput {
75
+ session_id: string;
76
+ cwd: string;
77
+ hook_event_name: string;
78
+ }
79
+
80
+ async function main() {
81
+ console.error('\nload-project-context.ts starting...');
82
+
83
+ // Read hook input from stdin
84
+ let hookInput: HookInput | null = null;
85
+ try {
86
+ const chunks: Buffer[] = [];
87
+ for await (const chunk of process.stdin) {
88
+ chunks.push(chunk);
89
+ }
90
+ const input = Buffer.concat(chunks).toString('utf-8');
91
+ if (input.trim()) {
92
+ hookInput = JSON.parse(input);
93
+ }
94
+ } catch (error) {
95
+ console.error('Could not parse hook input, using process.cwd()');
96
+ }
97
+
98
+ // Get current working directory
99
+ const cwd = hookInput?.cwd || process.cwd();
100
+
101
+ // Determine meaningful project name
102
+ // If cwd is a Notes directory, use parent directory name instead
103
+ let projectName = basename(cwd);
104
+ if (projectName.toLowerCase() === 'notes') {
105
+ projectName = basename(dirname(cwd));
106
+ }
107
+
108
+ console.error(`Working directory: ${cwd}`);
109
+ console.error(`Project: ${projectName}`);
110
+
111
+ // Check if this is a subagent session - skip for subagents
112
+ const isSubagent = process.env.CLAUDE_AGENT_TYPE !== undefined ||
113
+ (process.env.CLAUDE_PROJECT_DIR || '').includes('/.claude/agents/');
114
+
115
+ if (isSubagent) {
116
+ console.error('Subagent session - skipping project context setup');
117
+ process.exit(0);
118
+ }
119
+
120
+ // 1. Find and READ all CLAUDE.md files - inject them into context
121
+ // This ensures Claude actually processes the instructions, not just sees them in headers
122
+ const claudeMdPaths = findAllClaudeMdPaths(cwd);
123
+ const claudeMdContents: { path: string; content: string }[] = [];
124
+
125
+ if (claudeMdPaths.length > 0) {
126
+ console.error(`Found ${claudeMdPaths.length} CLAUDE.md file(s):`);
127
+ for (const path of claudeMdPaths) {
128
+ console.error(` - ${path}`);
129
+ try {
130
+ const content = readFileSync(path, 'utf-8');
131
+ claudeMdContents.push({ path, content });
132
+ console.error(` Read ${content.length} chars`);
133
+ } catch (error) {
134
+ console.error(` Could not read: ${error}`);
135
+ }
136
+ }
137
+ } else {
138
+ console.error('No CLAUDE.md found in project');
139
+ console.error(' Consider creating one at ./CLAUDE.md or ./.claude/CLAUDE.md');
140
+ }
141
+
142
+ // 2. Find or create Notes directory
143
+ // Priority:
144
+ // 1. Active session routing (pai route <project>) → routed Obsidian path
145
+ // 2. Local Notes/ in cwd → use it (git-trackable, e.g. symlink to Obsidian)
146
+ // 3. Central ~/.claude/projects/.../Notes/ → fallback
147
+ const routedPath = getRoutedNotesPath();
148
+ let notesDir: string;
149
+
150
+ if (routedPath) {
151
+ // Routing is active - use the configured Obsidian Notes path
152
+ const { mkdirSync } = await import('fs');
153
+ if (!existsSync(routedPath)) {
154
+ mkdirSync(routedPath, { recursive: true });
155
+ console.error(`Created routed Notes: ${routedPath}`);
156
+ } else {
157
+ console.error(`Notes directory: ${routedPath} (routed via pai route)`);
158
+ }
159
+ notesDir = routedPath;
160
+ } else {
161
+ const notesInfo = findNotesDir(cwd);
162
+
163
+ if (notesInfo.isLocal) {
164
+ notesDir = notesInfo.path;
165
+ console.error(`Notes directory: ${notesDir} (local)`);
166
+ } else {
167
+ // Create central Notes directory
168
+ if (!existsSync(notesInfo.path)) {
169
+ const { mkdirSync } = await import('fs');
170
+ mkdirSync(notesInfo.path, { recursive: true });
171
+ console.error(`Created central Notes: ${notesInfo.path}`);
172
+ } else {
173
+ console.error(`Notes directory: ${notesInfo.path} (central)`);
174
+ }
175
+ notesDir = notesInfo.path;
176
+ }
177
+ }
178
+
179
+ // 3. Cleanup old .jsonl files from project root (move to sessions/)
180
+ // Keep the newest one for potential resume, move older ones to sessions/
181
+ const projectDir = getProjectDir(cwd);
182
+ if (existsSync(projectDir)) {
183
+ try {
184
+ const files = readdirSync(projectDir);
185
+ const jsonlFiles = files
186
+ .filter(f => f.endsWith('.jsonl'))
187
+ .map(f => ({
188
+ name: f,
189
+ path: join(projectDir, f),
190
+ mtime: statSync(join(projectDir, f)).mtime.getTime()
191
+ }))
192
+ .sort((a, b) => b.mtime - a.mtime); // newest first
193
+
194
+ if (jsonlFiles.length > 1) {
195
+ const { mkdirSync, renameSync } = await import('fs');
196
+ const sessionsDir = join(projectDir, 'sessions');
197
+ if (!existsSync(sessionsDir)) {
198
+ mkdirSync(sessionsDir, { recursive: true });
199
+ }
200
+
201
+ // Move all except the newest
202
+ for (let i = 1; i < jsonlFiles.length; i++) {
203
+ const file = jsonlFiles[i];
204
+ const destPath = join(sessionsDir, file.name);
205
+ if (!existsSync(destPath)) {
206
+ renameSync(file.path, destPath);
207
+ console.error(`Moved old session: ${file.name} → sessions/`);
208
+ }
209
+ }
210
+ }
211
+ } catch (error) {
212
+ console.error(`Could not cleanup old .jsonl files: ${error}`);
213
+ }
214
+ }
215
+
216
+ // 4. Find or create TODO.md
217
+ const todoPath = findTodoPath(cwd);
218
+ const hasTodo = existsSync(todoPath);
219
+ if (hasTodo) {
220
+ console.error(`TODO.md: ${todoPath}`);
221
+ } else {
222
+ // Create TODO.md in the Notes directory
223
+ const newTodoPath = join(notesDir, 'TODO.md');
224
+ const { writeFileSync } = await import('fs');
225
+ writeFileSync(newTodoPath, `# TODO\n\n## Offen\n\n- [ ] \n\n---\n\n*Created: ${new Date().toISOString()}*\n`);
226
+ console.error(`Created TODO.md: ${newTodoPath}`);
227
+ }
228
+
229
+ // 5. Check for existing note or create new one
230
+ let activeNotePath: string | null = null;
231
+
232
+ if (notesDir) { // notesDir is always set now (local or central)
233
+ const currentNotePath = getCurrentNotePath(notesDir);
234
+
235
+ // Determine if we need a new note
236
+ let needsNewNote = false;
237
+ if (!currentNotePath) {
238
+ needsNewNote = true;
239
+ console.error('\nNo previous session notes found - creating new one');
240
+ } else {
241
+ // Check if the existing note is completed
242
+ try {
243
+ const content = readFileSync(currentNotePath, 'utf-8');
244
+ if (content.includes('**Status:** Completed') || content.includes('**Completed:**')) {
245
+ needsNewNote = true;
246
+ console.error(`\nPrevious note completed - creating new one`);
247
+ const summaryMatch = content.match(/## Next Steps\n\n([^\n]+)/);
248
+ if (summaryMatch) {
249
+ console.error(` Previous: ${summaryMatch[1].substring(0, 60)}...`);
250
+ }
251
+ } else {
252
+ console.error(`\nContinuing session note: ${basename(currentNotePath)}`);
253
+ }
254
+ } catch {
255
+ needsNewNote = true;
256
+ }
257
+ }
258
+
259
+ // Create new note if needed
260
+ if (needsNewNote) {
261
+ activeNotePath = createSessionNote(notesDir, projectName);
262
+ console.error(`Created: ${basename(activeNotePath)}`);
263
+ } else {
264
+ activeNotePath = currentNotePath!;
265
+ // Show preview of current note
266
+ try {
267
+ const content = readFileSync(activeNotePath, 'utf-8');
268
+ const lines = content.split('\n').slice(0, 12);
269
+ console.error('--- Current Note Preview ---');
270
+ for (const line of lines) {
271
+ console.error(line);
272
+ }
273
+ console.error('--- End Preview ---\n');
274
+ } catch {
275
+ // Ignore read errors
276
+ }
277
+ }
278
+ }
279
+
280
+ // 6. Show TODO.md preview
281
+ if (existsSync(todoPath)) {
282
+ try {
283
+ const todoContent = readFileSync(todoPath, 'utf-8');
284
+ const todoLines = todoContent.split('\n').filter(l => l.includes('[ ]')).slice(0, 5);
285
+ if (todoLines.length > 0) {
286
+ console.error('\nOpen TODOs:');
287
+ for (const line of todoLines) {
288
+ console.error(` ${line.trim()}`);
289
+ }
290
+ }
291
+ } catch {
292
+ // Ignore read errors
293
+ }
294
+ }
295
+
296
+ // 7. Send ntfy.sh notification (MANDATORY)
297
+ await sendNtfyNotification(`Session started in ${projectName}`);
298
+
299
+ // 7.5. Run pai project detect to identify the registered PAI project
300
+ const paiBin = findPaiBinary();
301
+ let paiProjectBlock = '';
302
+ try {
303
+ const { execFileSync } = await import('child_process');
304
+ const raw = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {
305
+ encoding: 'utf-8',
306
+ env: process.env,
307
+ }).trim();
308
+
309
+ if (raw) {
310
+ const detected = JSON.parse(raw) as {
311
+ slug?: string;
312
+ display_name?: string;
313
+ root_path?: string;
314
+ match_type?: string;
315
+ relative_path?: string | null;
316
+ session_count?: number;
317
+ status?: string;
318
+ error?: string;
319
+ cwd?: string;
320
+ };
321
+
322
+ if (detected.error === 'no_match') {
323
+ paiProjectBlock = `PAI Project Registry: No registered project matches this directory.
324
+ Run "pai project add ." to register this project, or use /route to tag the session.`;
325
+ console.error('PAI detect: no match for', cwd);
326
+ } else if (detected.slug) {
327
+ const name = detected.display_name || detected.slug;
328
+ const nameSlug = ` (slug: ${detected.slug})`;
329
+ const matchDesc = detected.match_type === 'exact'
330
+ ? 'exact'
331
+ : `parent (+${detected.relative_path ?? ''})`;
332
+ const statusFlag = detected.status && detected.status !== 'active'
333
+ ? ` [${detected.status.toUpperCase()}]`
334
+ : '';
335
+ paiProjectBlock = `PAI Project Registry: ${name}${statusFlag}${nameSlug}
336
+ Match: ${matchDesc} | Sessions: ${detected.session_count ?? 0}${detected.status && detected.status !== 'active' ? `\nWARNING: Project status is "${detected.status}". Run: pai project health --fix` : ''}`;
337
+ console.error(`PAI detect: matched "${detected.slug}" (${detected.match_type})`);
338
+ }
339
+ }
340
+ } catch (e) {
341
+ // Non-fatal — don't break session start if pai is unavailable
342
+ console.error('pai project detect failed:', e);
343
+ }
344
+
345
+ // 8. Output system reminder with session info
346
+ const reminder = `
347
+ <system-reminder>
348
+ PROJECT CONTEXT LOADED
349
+
350
+ Project: ${projectName}
351
+ Working Directory: ${cwd}
352
+ ${notesDir ? `Notes Directory: ${notesDir}${routedPath ? ' (routed via pai route)' : ''}` : 'Notes: disabled (no local Notes/ directory)'}
353
+ ${hasTodo ? `TODO: ${todoPath}` : 'TODO: not found'}
354
+ ${claudeMdPaths.length > 0 ? `CLAUDE.md: ${claudeMdPaths.join(', ')}` : 'No CLAUDE.md found'}
355
+ ${activeNotePath ? `Active Note: ${basename(activeNotePath)}` : ''}
356
+ ${routedPath ? `\nNote Routing: ACTIVE (pai route is set - notes go to Obsidian vault)` : ''}
357
+ ${paiProjectBlock ? `\n${paiProjectBlock}` : ''}
358
+ Session Commands:
359
+ - "pause session" → Save checkpoint, update TODO, exit (no compact)
360
+ - "end session" → Finalize note, commit if needed, start fresh next time
361
+ - "pai route clear" → Clear note routing (in a new session)
362
+ </system-reminder>
363
+ `;
364
+
365
+ // Output to stdout for Claude to receive
366
+ console.log(reminder);
367
+
368
+ // 9. INJECT CLAUDE.md contents as system-reminders
369
+ // This ensures Claude actually reads and processes the instructions
370
+ for (const { path, content } of claudeMdContents) {
371
+ const claudeMdReminder = `
372
+ <system-reminder>
373
+ LOCAL CLAUDE.md LOADED (MANDATORY - READ AND FOLLOW)
374
+
375
+ Source: ${path}
376
+
377
+ ${content}
378
+
379
+ ---
380
+ THE ABOVE INSTRUCTIONS ARE MANDATORY. Follow them exactly.
381
+ </system-reminder>
382
+ `;
383
+ console.log(claudeMdReminder);
384
+ console.error(`Injected CLAUDE.md content from: ${path}`);
385
+ }
386
+
387
+ console.error('\nProject context setup complete\n');
388
+ process.exit(0);
389
+ }
390
+
391
+ main().catch(error => {
392
+ console.error('load-project-context.ts error:', error);
393
+ process.exit(0); // Don't block session start
394
+ });