claude-prism 1.2.6 → 1.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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * claude-prism — HANDOFF.md Generation
3
+ * Shared logic for auto-generating session handoff documents
4
+ */
5
+
6
+ import { readFileSync, existsSync, readdirSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { execSync } from 'child_process';
9
+
10
+ /**
11
+ * Generate HANDOFF.md content from current project state
12
+ * @param {string} projectRoot - Project root directory
13
+ * @returns {string} Markdown content for HANDOFF.md
14
+ */
15
+ export function generateHandoff(projectRoot) {
16
+ const sections = [];
17
+ sections.push('# HANDOFF — Session Transition Document\n');
18
+ sections.push(`> Auto-generated by claude-prism at ${new Date().toISOString()}\n`);
19
+
20
+ // 1. Plan progress
21
+ const planInfo = getActivePlanInfo(projectRoot);
22
+ sections.push('## Status\n');
23
+ if (planInfo) {
24
+ const { total, done, planName } = planInfo;
25
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
26
+ sections.push(`- Active plan: \`${planName}\``);
27
+ sections.push(`- Progress: ${done}/${total} tasks (${pct}%)`);
28
+ if (planInfo.nextBatch) {
29
+ sections.push(`- Next batch: ${planInfo.nextBatch}`);
30
+ }
31
+ } else {
32
+ sections.push('- No active plan file found');
33
+ }
34
+
35
+ // 2. Current git state
36
+ sections.push('\n## Current State\n');
37
+ const gitInfo = getGitInfo(projectRoot);
38
+ sections.push(`- Branch: \`${gitInfo.branch}\``);
39
+ if (gitInfo.uncommitted > 0) {
40
+ sections.push(`- Uncommitted changes: ${gitInfo.uncommitted} file(s)`);
41
+ } else {
42
+ sections.push('- Working tree clean');
43
+ }
44
+ if (gitInfo.recentCommits.length > 0) {
45
+ sections.push('\nRecent commits:');
46
+ for (const commit of gitInfo.recentCommits) {
47
+ sections.push(`- ${commit}`);
48
+ }
49
+ }
50
+
51
+ // 3. Next steps (derived from plan)
52
+ sections.push('\n## Next Steps\n');
53
+ if (planInfo && planInfo.nextTasks.length > 0) {
54
+ for (const task of planInfo.nextTasks) {
55
+ sections.push(`- [ ] ${task}`);
56
+ }
57
+ } else {
58
+ sections.push('- Review current state and determine next action');
59
+ }
60
+
61
+ // 4. Decisions placeholder
62
+ sections.push('\n## Decisions Made\n');
63
+ sections.push('- (Populated by session context — review plan file for architectural decisions)');
64
+
65
+ // 5. Known issues
66
+ sections.push('\n## Known Issues\n');
67
+ sections.push('- (None auto-detected — review test results and build output)');
68
+
69
+ return sections.join('\n') + '\n';
70
+ }
71
+
72
+ /**
73
+ * Read active plan file and extract progress info
74
+ */
75
+ export function getActivePlanInfo(projectRoot) {
76
+ const plansDir = join(projectRoot, '.prism', 'plans');
77
+ if (!existsSync(plansDir)) return null;
78
+
79
+ const planFiles = readdirSync(plansDir)
80
+ .filter(f => f.endsWith('.md'))
81
+ .sort()
82
+ .reverse();
83
+
84
+ if (planFiles.length === 0) return null;
85
+
86
+ const planName = planFiles[0];
87
+ const content = readFileSync(join(plansDir, planName), 'utf8');
88
+
89
+ return parsePlanContent(content, planName);
90
+ }
91
+
92
+ /**
93
+ * Parse plan markdown content for progress info
94
+ * @param {string} content - Plan file content
95
+ * @param {string} planName - Plan filename
96
+ * @returns {{ planName, total, done, nextBatch, nextTasks, currentBatchFiles }}
97
+ */
98
+ export function parsePlanContent(content, planName) {
99
+ const lines = content.split('\n');
100
+ let total = 0;
101
+ let done = 0;
102
+ let nextBatch = null;
103
+ const nextTasks = [];
104
+ let currentBatchFiles = [];
105
+ let inUnfinishedBatch = false;
106
+ let currentBatchName = '';
107
+
108
+ for (const line of lines) {
109
+ const checkboxMatch = line.match(/^[-*]\s+\[([ x])\]\s+(.+)/);
110
+ if (checkboxMatch) {
111
+ total++;
112
+ if (checkboxMatch[1] === 'x') {
113
+ done++;
114
+ } else {
115
+ if (!inUnfinishedBatch) {
116
+ // First unfinished task — we're in the current batch
117
+ inUnfinishedBatch = true;
118
+ }
119
+ if (nextTasks.length < 5) {
120
+ nextTasks.push(checkboxMatch[2]);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Detect batch headers
126
+ const batchMatch = line.match(/^#{1,3}\s+Batch\s+\d+[:\s]*(.+)?/i);
127
+ if (batchMatch) {
128
+ if (!nextBatch && inUnfinishedBatch) {
129
+ // Already past it
130
+ } else if (!nextBatch) {
131
+ // Check if there are unchecked items below
132
+ }
133
+ currentBatchName = batchMatch[0];
134
+ }
135
+
136
+ // Track file references in current batch
137
+ if (inUnfinishedBatch) {
138
+ const fileMatch = line.match(/`([^`]+\.\w+)`/);
139
+ if (fileMatch && fileMatch[1].includes('/')) {
140
+ currentBatchFiles.push(fileMatch[1]);
141
+ }
142
+ }
143
+ }
144
+
145
+ // Find the first batch with uncompleted items
146
+ let batchDone = 0;
147
+ let batchTotal = 0;
148
+ let foundFirstUncompleted = false;
149
+ for (const line of lines) {
150
+ const batchMatch = line.match(/^#{1,3}\s+(Batch\s+\d+[:\s]*.+)/i);
151
+ if (batchMatch) {
152
+ if (batchTotal > 0 && batchDone < batchTotal && !foundFirstUncompleted) {
153
+ nextBatch = currentBatchName;
154
+ foundFirstUncompleted = true;
155
+ }
156
+ currentBatchName = batchMatch[1];
157
+ batchDone = 0;
158
+ batchTotal = 0;
159
+ }
160
+ const cb = line.match(/^[-*]\s+\[([ x])\]/);
161
+ if (cb) {
162
+ batchTotal++;
163
+ if (cb[1] === 'x') batchDone++;
164
+ }
165
+ }
166
+ if (!foundFirstUncompleted && batchTotal > 0 && batchDone < batchTotal) {
167
+ nextBatch = currentBatchName;
168
+ }
169
+
170
+ return { planName, total, done, nextBatch, nextTasks, currentBatchFiles };
171
+ }
172
+
173
+ /**
174
+ * Get git status info
175
+ */
176
+ function getGitInfo(projectRoot) {
177
+ const info = { branch: 'unknown', uncommitted: 0, recentCommits: [] };
178
+
179
+ try {
180
+ info.branch = execSync('git rev-parse --abbrev-ref HEAD', {
181
+ cwd: projectRoot, encoding: 'utf8', timeout: 5000
182
+ }).trim();
183
+ } catch { /* not a git repo */ }
184
+
185
+ try {
186
+ const status = execSync('git status --porcelain', {
187
+ cwd: projectRoot, encoding: 'utf8', timeout: 5000
188
+ }).trim();
189
+ if (status) {
190
+ info.uncommitted = status.split('\n').filter(Boolean).length;
191
+ }
192
+ } catch { /* ignore */ }
193
+
194
+ try {
195
+ const log = execSync('git log --oneline -3', {
196
+ cwd: projectRoot, encoding: 'utf8', timeout: 5000
197
+ }).trim();
198
+ if (log) {
199
+ info.recentCommits = log.split('\n');
200
+ }
201
+ } catch { /* ignore */ }
202
+
203
+ return info;
204
+ }
package/lib/installer.mjs CHANGED
@@ -46,6 +46,12 @@ export async function init(projectDir, options = {}) {
46
46
  copyFileSync(join(runnersDir, 'pre-tool.mjs'), join(hooksDir, 'pre-tool.mjs'));
47
47
  copyFileSync(join(runnersDir, 'post-tool.mjs'), join(hooksDir, 'post-tool.mjs'));
48
48
 
49
+ // Copy new event runners
50
+ copyFileSync(join(runnersDir, 'precompact.mjs'), join(hooksDir, 'precompact.mjs'));
51
+ copyFileSync(join(runnersDir, 'session-end.mjs'), join(hooksDir, 'session-end.mjs'));
52
+ copyFileSync(join(runnersDir, 'subagent-start.mjs'), join(hooksDir, 'subagent-start.mjs'));
53
+ copyFileSync(join(runnersDir, 'task-completed.mjs'), join(hooksDir, 'task-completed.mjs'));
54
+
49
55
  // Copy rule logic files
50
56
  const rulesDestDir = join(claudeDir, 'rules');
51
57
  mkdirSync(rulesDestDir, { recursive: true });
@@ -54,11 +60,17 @@ export async function init(projectDir, options = {}) {
54
60
  copyFileSync(join(hooksSourceDir, 'test-tracker.mjs'), join(rulesDestDir, 'test-tracker.mjs'));
55
61
  copyFileSync(join(hooksSourceDir, 'plan-enforcement.mjs'), join(rulesDestDir, 'plan-enforcement.mjs'));
56
62
 
63
+ // Copy new handler rule files
64
+ copyFileSync(join(hooksSourceDir, 'precompact-handler.mjs'), join(rulesDestDir, 'precompact-handler.mjs'));
65
+ copyFileSync(join(hooksSourceDir, 'session-end-handler.mjs'), join(rulesDestDir, 'session-end-handler.mjs'));
66
+ copyFileSync(join(hooksSourceDir, 'subagent-scope-injector.mjs'), join(rulesDestDir, 'subagent-scope-injector.mjs'));
67
+ copyFileSync(join(hooksSourceDir, 'task-plan-sync.mjs'), join(rulesDestDir, 'task-plan-sync.mjs'));
68
+
57
69
  // Copy lib dependencies
58
70
  const libDestDir = join(claudeDir, 'lib');
59
71
  mkdirSync(libDestDir, { recursive: true });
60
72
  const libSourceDir = join(__dirname);
61
- for (const file of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs']) {
73
+ for (const file of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs']) {
62
74
  copyFileSync(join(libSourceDir, file), join(libDestDir, file));
63
75
  }
64
76
 
@@ -69,8 +81,11 @@ export async function init(projectDir, options = {}) {
69
81
  // 4. Inject rules into CLAUDE.md
70
82
  injectRules(projectDir);
71
83
 
72
- // 5. Create .claude-prism.json config
73
- const configPath = join(projectDir, '.claude-prism.json');
84
+ // 5. Create .prism/ directory with config.json
85
+ const prismDir = join(projectDir, '.prism');
86
+ mkdirSync(prismDir, { recursive: true });
87
+
88
+ const configPath = join(prismDir, 'config.json');
74
89
  if (!existsSync(configPath)) {
75
90
  writeFileSync(configPath, JSON.stringify({
76
91
  version: 1,
@@ -85,7 +100,13 @@ export async function init(projectDir, options = {}) {
85
100
  // Write version file
86
101
  const pkgPath = join(__dirname, '..', 'package.json');
87
102
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
88
- writeFileSync(join(claudeDir, '.prism-version'), pkg.version);
103
+ writeFileSync(join(prismDir, '.version'), pkg.version);
104
+
105
+ // Create .prism/.gitignore (version file is local-only)
106
+ const prismGitignore = join(prismDir, '.gitignore');
107
+ if (!existsSync(prismGitignore)) {
108
+ writeFileSync(prismGitignore, '.version\n');
109
+ }
89
110
  }
90
111
 
91
112
  /**
@@ -107,9 +128,13 @@ export function check(projectDir) {
107
128
  && readFileSync(claudeMdPath, 'utf8').includes('<!-- PRISM:START -->');
108
129
 
109
130
  const hooks = existsSync(join(claudeDir, 'hooks', 'pre-tool.mjs'))
110
- && existsSync(join(claudeDir, 'hooks', 'post-tool.mjs'));
131
+ && existsSync(join(claudeDir, 'hooks', 'post-tool.mjs'))
132
+ && existsSync(join(claudeDir, 'hooks', 'precompact.mjs'))
133
+ && existsSync(join(claudeDir, 'hooks', 'session-end.mjs'))
134
+ && existsSync(join(claudeDir, 'hooks', 'subagent-start.mjs'))
135
+ && existsSync(join(claudeDir, 'hooks', 'task-completed.mjs'));
111
136
 
112
- const config = existsSync(join(projectDir, '.claude-prism.json'));
137
+ const config = existsSync(join(projectDir, '.prism', 'config.json'));
113
138
 
114
139
  return {
115
140
  commands,
@@ -151,7 +176,7 @@ export function uninstall(projectDir) {
151
176
  }
152
177
 
153
178
  // 3. Remove hooks
154
- for (const hook of ['pre-tool.mjs', 'post-tool.mjs', 'user-prompt.mjs']) {
179
+ for (const hook of ['pre-tool.mjs', 'post-tool.mjs', 'user-prompt.mjs', 'precompact.mjs', 'session-end.mjs', 'subagent-start.mjs', 'task-completed.mjs']) {
155
180
  const p = join(claudeDir, 'hooks', hook);
156
181
  if (existsSync(p)) rmSync(p);
157
182
  }
@@ -175,7 +200,7 @@ export function uninstall(projectDir) {
175
200
  if (settings.hooks) {
176
201
  for (const [event, hookList] of Object.entries(settings.hooks)) {
177
202
  settings.hooks[event] = hookList.filter(
178
- h => !h.hooks?.some(hh => hh.command?.includes('pre-tool') || hh.command?.includes('post-tool') || hh.command?.includes('user-prompt') || hh.command?.includes('commit-guard') || hh.command?.includes('debug-loop') || hh.command?.includes('test-tracker') || hh.command?.includes('scope-guard'))
203
+ h => !h.hooks?.some(hh => hh.command?.includes('pre-tool') || hh.command?.includes('post-tool') || hh.command?.includes('user-prompt') || hh.command?.includes('commit-guard') || hh.command?.includes('debug-loop') || hh.command?.includes('test-tracker') || hh.command?.includes('scope-guard') || hh.command?.includes('precompact') || hh.command?.includes('session-end') || hh.command?.includes('subagent-start') || hh.command?.includes('task-completed'))
179
204
  );
180
205
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
181
206
  }
@@ -183,15 +208,31 @@ export function uninstall(projectDir) {
183
208
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
184
209
  }
185
210
 
186
- // 6. Remove config files
187
- const configPath = join(projectDir, '.claude-prism.json');
211
+ // 6. Remove config files (new + legacy paths)
212
+ const prismDir = join(projectDir, '.prism');
213
+ const configPath = join(prismDir, 'config.json');
188
214
  if (existsSync(configPath)) rmSync(configPath);
189
- const legacyConfigPath = join(projectDir, '.prism.json');
190
- if (existsSync(legacyConfigPath)) rmSync(legacyConfigPath);
191
-
192
- // Remove version file
193
- const versionFile = join(claudeDir, '.prism-version');
215
+ const versionFile = join(prismDir, '.version');
194
216
  if (existsSync(versionFile)) rmSync(versionFile);
217
+ const prismGitignore = join(prismDir, '.gitignore');
218
+ if (existsSync(prismGitignore)) rmSync(prismGitignore);
219
+ // Remove .prism/ directory if empty
220
+ if (existsSync(prismDir)) {
221
+ try {
222
+ const remaining = readdirSync(prismDir);
223
+ if (remaining.length === 0) rmSync(prismDir, { recursive: true });
224
+ } catch { /* ignore */ }
225
+ }
226
+
227
+ // Legacy config paths
228
+ const legacyConfig = join(projectDir, '.claude-prism.json');
229
+ if (existsSync(legacyConfig)) rmSync(legacyConfig);
230
+ const legacyConfig2 = join(projectDir, '.prism.json');
231
+ if (existsSync(legacyConfig2)) rmSync(legacyConfig2);
232
+
233
+ // Legacy version file
234
+ const legacyVersion = join(claudeDir, '.prism-version');
235
+ if (existsSync(legacyVersion)) rmSync(legacyVersion);
195
236
  }
196
237
 
197
238
  /**
@@ -215,14 +256,72 @@ export async function update(projectDir) {
215
256
  } catch { /* not our package, proceed normally */ }
216
257
  }
217
258
 
218
- // Migration: rename .prism.json to .claude-prism.json
259
+ const claudeDir = join(projectDir, '.claude');
260
+
261
+ // Migration chain: .prism.json → .claude-prism.json → .prism/config.json
262
+ const prismDir = join(projectDir, '.prism');
263
+ mkdirSync(prismDir, { recursive: true });
264
+
219
265
  const oldConfigPath = join(projectDir, '.prism.json');
220
- const newConfigPath = join(projectDir, '.claude-prism.json');
221
- if (existsSync(oldConfigPath) && !existsSync(newConfigPath)) {
222
- renameSync(oldConfigPath, newConfigPath);
266
+ const midConfigPath = join(projectDir, '.claude-prism.json');
267
+ const configPath = join(prismDir, 'config.json');
268
+
269
+ // Step 1: .prism.json → .claude-prism.json (legacy v0.x)
270
+ if (existsSync(oldConfigPath) && !existsSync(midConfigPath) && !existsSync(configPath)) {
271
+ renameSync(oldConfigPath, configPath);
272
+ }
273
+ // Step 2: .claude-prism.json → .prism/config.json (legacy v1.x)
274
+ if (existsSync(midConfigPath) && !existsSync(configPath)) {
275
+ renameSync(midConfigPath, configPath);
276
+ }
277
+ // Clean up leftover legacy configs
278
+ if (existsSync(oldConfigPath)) rmSync(oldConfigPath);
279
+ if (existsSync(midConfigPath)) rmSync(midConfigPath);
280
+
281
+ // Migration: .claude/.prism-version → .prism/.version
282
+ const legacyVersionFile = join(claudeDir, '.prism-version');
283
+ const newVersionFile = join(prismDir, '.version');
284
+ if (existsSync(legacyVersionFile)) {
285
+ if (!existsSync(newVersionFile)) {
286
+ renameSync(legacyVersionFile, newVersionFile);
287
+ } else {
288
+ rmSync(legacyVersionFile);
289
+ }
290
+ }
291
+
292
+ // Migration: docs/plans/ → .prism/plans/
293
+ const legacyPlansDir = join(projectDir, 'docs', 'plans');
294
+ const newPlansDir = join(prismDir, 'plans');
295
+ if (existsSync(legacyPlansDir)) {
296
+ mkdirSync(newPlansDir, { recursive: true });
297
+ const planFiles = readdirSync(legacyPlansDir).filter(f => f.endsWith('.md'));
298
+ for (const f of planFiles) {
299
+ const src = join(legacyPlansDir, f);
300
+ const dest = join(newPlansDir, f);
301
+ if (!existsSync(dest)) {
302
+ renameSync(src, dest);
303
+ }
304
+ }
305
+ // Clean up empty legacy plans dir
306
+ try {
307
+ const remaining = readdirSync(legacyPlansDir);
308
+ if (remaining.length === 0) {
309
+ rmSync(legacyPlansDir, { recursive: true });
310
+ // Also remove docs/ if empty
311
+ const docsDir = join(projectDir, 'docs');
312
+ if (existsSync(docsDir) && readdirSync(docsDir).length === 0) {
313
+ rmSync(docsDir, { recursive: true });
314
+ }
315
+ }
316
+ } catch { /* ignore */ }
317
+ }
318
+
319
+ // Create .prism/.gitignore (.version is local-only)
320
+ const prismGitignore = join(prismDir, '.gitignore');
321
+ if (!existsSync(prismGitignore)) {
322
+ writeFileSync(prismGitignore, '.version\n');
223
323
  }
224
324
 
225
- const configPath = newConfigPath;
226
325
  let hooks = true;
227
326
 
228
327
  if (existsSync(configPath)) {
@@ -230,8 +329,6 @@ export async function update(projectDir) {
230
329
  hooks = config.hooks?.['commit-guard']?.enabled !== false;
231
330
  }
232
331
 
233
- const claudeDir = join(projectDir, '.claude');
234
-
235
332
  // Migration: remove all legacy files from previous versions
236
333
  const legacyFiles = [
237
334
  // Legacy flat commands
@@ -251,6 +348,8 @@ export async function update(projectDir) {
251
348
  join(claudeDir, 'rules', 'turn-reporter.mjs'),
252
349
  // Removed lib files
253
350
  join(claudeDir, 'lib', 'adapter.mjs'),
351
+ // Legacy version file (moved to .prism/.version)
352
+ join(claudeDir, '.prism-version'),
254
353
  ];
255
354
  for (const p of legacyFiles) {
256
355
  if (existsSync(p)) rmSync(p);
@@ -318,7 +417,7 @@ export function doctor(projectDir, options = {}) {
318
417
  }
319
418
 
320
419
  // Check hooks
321
- for (const hook of ['pre-tool.mjs', 'post-tool.mjs']) {
420
+ for (const hook of ['pre-tool.mjs', 'post-tool.mjs', 'precompact.mjs', 'session-end.mjs', 'subagent-start.mjs', 'task-completed.mjs']) {
322
421
  if (!existsSync(join(claudeDir, 'hooks', hook))) {
323
422
  issues.push(`Missing hook: ${hook}`);
324
423
  fixes.push('Run `prism update` to restore missing files');
@@ -338,9 +437,9 @@ export function doctor(projectDir, options = {}) {
338
437
  }
339
438
  }
340
439
 
341
- // Check config
342
- if (!existsSync(join(projectDir, '.claude-prism.json'))) {
343
- issues.push('Missing .claude-prism.json config');
440
+ // Check config (.prism/config.json)
441
+ if (!existsSync(join(projectDir, '.prism', 'config.json'))) {
442
+ issues.push('Missing .prism/config.json');
344
443
  fixes.push('Run `prism init` to create config');
345
444
  }
346
445
 
@@ -359,6 +458,7 @@ export function doctor(projectDir, options = {}) {
359
458
  { path: join(claudeDir, 'rules', 'alignment.mjs'), label: 'Legacy alignment rule' },
360
459
  { path: join(claudeDir, 'hooks', 'user-prompt.mjs'), label: 'Legacy user-prompt hook' },
361
460
  { path: join(projectDir, '.prism.json'), label: 'Legacy .prism.json config' },
461
+ { path: join(projectDir, '.claude-prism.json'), label: 'Legacy .claude-prism.json config' },
362
462
  ];
363
463
  for (const { path, label } of legacyCheck) {
364
464
  if (existsSync(path)) {
@@ -368,7 +468,7 @@ export function doctor(projectDir, options = {}) {
368
468
  }
369
469
 
370
470
  // Check version mismatch
371
- const versionFile = join(claudeDir, '.prism-version');
471
+ const versionFile = join(projectDir, '.prism', '.version');
372
472
  if (existsSync(versionFile)) {
373
473
  const installedVersion = readFileSync(versionFile, 'utf8').trim();
374
474
  const pkgPath = join(__dirname, '..', 'package.json');
@@ -404,7 +504,7 @@ export function stats(projectDir, options = {}) {
404
504
  const pkgPath = join(__dirname, '..', 'package.json');
405
505
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
406
506
 
407
- const configPath = join(projectDir, '.claude-prism.json');
507
+ const configPath = join(projectDir, '.prism', 'config.json');
408
508
  let hookConfig = {};
409
509
 
410
510
  if (existsSync(configPath)) {
@@ -417,9 +517,12 @@ export function stats(projectDir, options = {}) {
417
517
  }
418
518
 
419
519
  let planFiles = 0;
420
- const plansDir = join(projectDir, 'docs', 'plans');
520
+ const plansDir = join(projectDir, '.prism', 'plans');
521
+ const legacyPlansDir = join(projectDir, 'docs', 'plans');
421
522
  if (existsSync(plansDir)) {
422
523
  planFiles = readdirSync(plansDir).filter(f => f.endsWith('.md')).length;
524
+ } else if (existsSync(legacyPlansDir)) {
525
+ planFiles = readdirSync(legacyPlansDir).filter(f => f.endsWith('.md')).length;
423
526
  }
424
527
 
425
528
  const omc = detectOmc(options.homeDir);
@@ -513,7 +616,7 @@ export function dryRun(projectDir, options = {}) {
513
616
 
514
617
  // Hooks
515
618
  if (hooks) {
516
- for (const hook of ['pre-tool.mjs', 'post-tool.mjs']) {
619
+ for (const hook of ['pre-tool.mjs', 'post-tool.mjs', 'precompact.mjs', 'session-end.mjs', 'subagent-start.mjs', 'task-completed.mjs']) {
517
620
  const target = join(claudeDir, 'hooks', hook);
518
621
  actions.push({
519
622
  type: 'hook',
@@ -522,7 +625,7 @@ export function dryRun(projectDir, options = {}) {
522
625
  });
523
626
  }
524
627
 
525
- for (const rule of ['commit-guard.mjs', 'test-tracker.mjs', 'plan-enforcement.mjs']) {
628
+ for (const rule of ['commit-guard.mjs', 'test-tracker.mjs', 'plan-enforcement.mjs', 'precompact-handler.mjs', 'session-end-handler.mjs', 'subagent-scope-injector.mjs', 'task-plan-sync.mjs']) {
526
629
  const target = join(claudeDir, 'rules', rule);
527
630
  actions.push({
528
631
  type: 'rule',
@@ -531,7 +634,7 @@ export function dryRun(projectDir, options = {}) {
531
634
  });
532
635
  }
533
636
 
534
- for (const lib of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs']) {
637
+ for (const lib of ['state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs', 'session.mjs', 'handoff.mjs', 'webhook.mjs']) {
535
638
  const target = join(claudeDir, 'lib', lib);
536
639
  actions.push({
537
640
  type: 'lib',
@@ -550,9 +653,9 @@ export function dryRun(projectDir, options = {}) {
550
653
  });
551
654
 
552
655
  // Config
553
- const configPath = join(projectDir, '.claude-prism.json');
656
+ const configPath = join(projectDir, '.prism', 'config.json');
554
657
  if (!existsSync(configPath)) {
555
- actions.push({ type: 'config', path: '.claude-prism.json', status: 'create' });
658
+ actions.push({ type: 'config', path: '.prism/config.json', status: 'create' });
556
659
  }
557
660
 
558
661
  return { actions };
package/lib/messages.mjs CHANGED
@@ -7,7 +7,11 @@ const MESSAGES = {
7
7
  'commit-guard.warn.no-test': '🌈 Prism > No test run detected this session. Run tests before committing.',
8
8
  'commit-guard.warn.stale': '🌈 Prism > Last test run was {minutes}min ago. Run tests before committing.',
9
9
  'test-tracker.warn.failed': '🌈 Prism 📊 Tests FAILED. Fix before committing.',
10
- 'plan-enforcement.warn.no-plan': '🌈 Prism > Editing {count} unique source files without a plan. Create a plan at docs/plans/ per EUDEC DECOMPOSE protocol.',
10
+ 'plan-enforcement.warn.no-plan': '🌈 Prism > Editing {count} unique source files without a plan. Create a plan at .prism/plans/ per EUDEC DECOMPOSE protocol.',
11
+ 'precompact-handler.info.saved': '🌈 Prism > HANDOFF.md auto-saved before compaction.',
12
+ 'session-end-handler.info.saved': '🌈 Prism > Session summary saved to PROJECT-MEMORY.md.',
13
+ 'subagent-scope-injector.info.scope': '🌈 Prism Scope >',
14
+ 'task-plan-sync.info.updated': '🌈 Prism > Plan updated: {task}. Progress: {done}/{total} ({pct}%)',
11
15
  };
12
16
 
13
17
  export function getMessage(_lang, key, params = {}) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * claude-prism — HTTP Webhook Dispatcher
3
+ * Non-blocking fire-and-forget webhook notifications
4
+ */
5
+
6
+ /**
7
+ * Dispatch a webhook event to configured endpoints
8
+ * @param {Object} config - Prism config with webhooks array
9
+ * @param {string} event - Event name (e.g. 'compaction', 'session-end', 'batch-complete')
10
+ * @param {Object} payload - Event payload data
11
+ * @returns {Promise<void>}
12
+ */
13
+ export async function dispatchWebhook(config, event, payload) {
14
+ const webhooks = config.webhooks || [];
15
+ if (webhooks.length === 0) return;
16
+
17
+ const body = JSON.stringify({
18
+ event,
19
+ timestamp: new Date().toISOString(),
20
+ source: 'claude-prism',
21
+ payload
22
+ });
23
+
24
+ const promises = [];
25
+
26
+ for (const hook of webhooks) {
27
+ // Filter by subscribed events
28
+ if (hook.events && hook.events.length > 0 && !hook.events.includes(event)) {
29
+ continue;
30
+ }
31
+
32
+ if (!hook.url) continue;
33
+
34
+ const headers = {
35
+ 'Content-Type': 'application/json',
36
+ 'User-Agent': 'claude-prism-webhook/1.0',
37
+ ...(hook.headers || {})
38
+ };
39
+
40
+ // Fire-and-forget with timeout
41
+ const controller = new AbortController();
42
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
43
+
44
+ const p = fetch(hook.url, {
45
+ method: 'POST',
46
+ headers,
47
+ body,
48
+ signal: controller.signal
49
+ })
50
+ .catch(() => { /* silent fail — webhooks are best-effort */ })
51
+ .finally(() => clearTimeout(timeoutId));
52
+
53
+ promises.push(p);
54
+ }
55
+
56
+ await Promise.allSettled(promises);
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-prism",
3
- "version": "1.2.6",
3
+ "version": "1.4.0",
4
4
  "description": "EUDEC methodology framework for AI coding agents — Essence, Understand, Decompose, Execute, Checkpoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,10 @@
30
30
  "bin/",
31
31
  "lib/",
32
32
  "hooks/",
33
+ "scripts/",
33
34
  "templates/",
35
+ ".claude-plugin/",
36
+ "plugin-hooks.json",
34
37
  "README.md",
35
38
  "CHANGELOG.md"
36
39
  ],