claude-prism 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.
package/bin/cli.mjs CHANGED
@@ -35,6 +35,7 @@ if (hasFlag('version') || hasFlag('v')) {
35
35
 
36
36
  const cwd = process.cwd();
37
37
 
38
+ try {
38
39
  switch (command) {
39
40
  case 'init': {
40
41
  if (hasFlag('global')) {
@@ -49,6 +50,19 @@ switch (command) {
49
50
  const language = getFlag('lang') || 'en';
50
51
  const hooks = !hasFlag('no-hooks');
51
52
 
53
+ if (hasFlag('dry-run')) {
54
+ const { dryRun } = await import('../lib/installer.mjs');
55
+ const result = dryRun(cwd, { language, hooks });
56
+ console.log('🌈 claude-prism init --dry-run\n');
57
+ console.log(' Files that would be created/updated:\n');
58
+ for (const action of result.actions) {
59
+ const icon = action.status === 'create' ? '🆕' : '🔄';
60
+ console.log(` ${icon} [${action.status}] ${action.path}`);
61
+ }
62
+ console.log(`\n Total: ${result.actions.length} files`);
63
+ break;
64
+ }
65
+
52
66
  console.log('🌈 claude-prism init\n');
53
67
  await init(cwd, { language, hooks });
54
68
 
@@ -182,8 +196,23 @@ Usage:
182
196
  Options:
183
197
  --lang=XX Language: en (default), ko, ja, zh
184
198
  --no-hooks Skip enforcement hooks
199
+ --dry-run Show what init would do without making changes
185
200
  --global Install/uninstall globally (all projects)
186
201
  --ci Output JSON for CI integration
187
202
  --version Show version`);
188
203
  }
189
204
  }
205
+ } catch (err) {
206
+ const msg = err.message || String(err);
207
+ process.stderr.write(`🌈 Prism Error: ${msg}\n`);
208
+
209
+ if (/EACCES|permission/i.test(msg)) {
210
+ process.stderr.write('💡 Check directory permissions\n');
211
+ } else if (/JSON|parse/i.test(msg)) {
212
+ process.stderr.write('💡 Config file may be corrupted. Try `prism reset` or delete .claude-prism.json\n');
213
+ } else if (/ENOENT.*package\.json/i.test(msg)) {
214
+ process.stderr.write('💡 Not in a project directory?\n');
215
+ }
216
+
217
+ process.exit(1);
218
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * claude-prism — Alignment Detection
3
+ * Detects scope drift and major unconfirmed decisions
4
+ */
5
+
6
+ import { readJsonState, writeJsonState } from '../lib/state.mjs';
7
+ import { getMessage } from '../lib/messages.mjs';
8
+
9
+ // Major decision patterns — commands that represent significant choices
10
+ const MAJOR_DECISION_PATTERNS = [
11
+ { pattern: /\bnpm\s+install\b|\bpnpm\s+add\b|\byarn\s+add\b|\bbun\s+add\b/, label: 'package-install' },
12
+ { pattern: /\bprisma\s+migrate\b|\bsequelize\b|\bknex\s+migrate\b/, label: 'db-migration' },
13
+ { pattern: /\brm\s+-rf?\b|\brmdir\b/, label: 'destructive-delete' },
14
+ ];
15
+
16
+ // Config files that represent major changes when modified
17
+ const MAJOR_CONFIG_FILES = [
18
+ 'tsconfig.json', 'package.json', '.env', 'docker-compose.yml',
19
+ 'Dockerfile', '.github/workflows', 'webpack.config', 'vite.config',
20
+ 'next.config', 'tailwind.config',
21
+ ];
22
+
23
+ export const alignment = {
24
+ name: 'alignment',
25
+
26
+ evaluate(ctx, config, stateDir) {
27
+ if (!config.enabled) return { type: 'pass' };
28
+
29
+ const messages = [];
30
+
31
+ // 1. Directory scope tracking
32
+ if (ctx.filePath) {
33
+ const dir = ctx.filePath.split('/').slice(0, -1).join('/') || '.';
34
+ let scopeDirs = readJsonState(stateDir, 'scope-directories') || [];
35
+
36
+ if (!scopeDirs.includes(dir)) {
37
+ // First 3 unique directories establish the "base scope"
38
+ if (scopeDirs.length < 3) {
39
+ scopeDirs.push(dir);
40
+ writeJsonState(stateDir, 'scope-directories', scopeDirs);
41
+ } else {
42
+ // New directory outside base scope — potential drift
43
+ const driftCount = parseInt(
44
+ (readJsonState(stateDir, 'drift-count') || 0).toString(), 10
45
+ ) || 0;
46
+ const newDriftCount = driftCount + 1;
47
+ writeJsonState(stateDir, 'drift-count', newDriftCount);
48
+
49
+ if (newDriftCount >= (config.driftThreshold || 2)) {
50
+ return {
51
+ type: 'warn',
52
+ message: `🌈 Prism 🧭 Scope drift: editing ${dir} (outside base scope: ${scopeDirs.slice(0, 3).join(', ')}). Verify this is intended.`
53
+ };
54
+ }
55
+ }
56
+ }
57
+
58
+ // 2. Major config file detection
59
+ const fileName = ctx.filePath.split('/').pop();
60
+ const isMajorConfig = MAJOR_CONFIG_FILES.some(f =>
61
+ ctx.filePath.includes(f) || fileName === f
62
+ );
63
+ if (isMajorConfig) {
64
+ messages.push(`🌈 Prism 🔧 Config change: ${fileName}. Ensure this was discussed with user.`);
65
+ }
66
+ }
67
+
68
+ // 3. Major command detection
69
+ if (ctx.command) {
70
+ for (const { pattern, label } of MAJOR_DECISION_PATTERNS) {
71
+ if (pattern.test(ctx.command)) {
72
+ if (label === 'destructive-delete') {
73
+ return {
74
+ type: 'warn',
75
+ message: `🌈 Prism ⚠️ Destructive command detected: ${ctx.command.slice(0, 60)}. Confirm with user before proceeding.`
76
+ };
77
+ }
78
+ if (label === 'package-install') {
79
+ messages.push(`🌈 Prism 📦 New dependency being installed. Verify this was agreed upon.`);
80
+ }
81
+ if (label === 'db-migration') {
82
+ messages.push(`🌈 Prism 🗄️ Database migration detected. This is a major decision — confirm with user.`);
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ if (messages.length > 0) {
89
+ return { type: 'pass', message: messages.join('\n') };
90
+ }
91
+
92
+ return { type: 'pass' };
93
+ }
94
+ };
@@ -4,12 +4,14 @@
4
4
  */
5
5
 
6
6
  import { readState } from '../lib/state.mjs';
7
+ import { getMessage } from '../lib/messages.mjs';
7
8
 
8
9
  export const commitGuard = {
9
10
  name: 'commit-guard',
10
11
 
11
12
  evaluate(ctx, config, stateDir) {
12
13
  const command = ctx.command || '';
14
+ const lang = config.language || 'en';
13
15
 
14
16
  if (!command.includes('git commit')) return { type: 'pass' };
15
17
  if (command.includes('--allow-empty')) return { type: 'pass' };
@@ -19,7 +21,7 @@ export const commitGuard = {
19
21
  if (testResult !== null && testResult.trim() === 'fail') {
20
22
  return {
21
23
  type: 'block',
22
- message: '🌈 Prism ✋ Commit blocked: last test run FAILED. Fix tests before committing.'
24
+ message: getMessage(lang, 'commit-guard.block.failed')
23
25
  };
24
26
  }
25
27
 
@@ -28,7 +30,7 @@ export const commitGuard = {
28
30
  if (lastTestRaw === null) {
29
31
  return {
30
32
  type: 'warn',
31
- message: '🌈 Prism > No test run detected this session. Run tests before committing.'
33
+ message: getMessage(lang, 'commit-guard.warn.no-test')
32
34
  };
33
35
  }
34
36
 
@@ -39,7 +41,7 @@ export const commitGuard = {
39
41
  if (diff > (config.maxTestAge || 300)) {
40
42
  return {
41
43
  type: 'warn',
42
- message: `🌈 Prism > Last test run was ${Math.floor(diff / 60)}min ago. Run tests before committing.`
44
+ message: getMessage(lang, 'commit-guard.warn.stale', { minutes: Math.floor(diff / 60) })
43
45
  };
44
46
  }
45
47
 
@@ -5,6 +5,8 @@
5
5
 
6
6
  import { createHash } from 'crypto';
7
7
  import { readState, writeState, readJsonState, writeJsonState } from '../lib/state.mjs';
8
+ import { DEFAULTS, buildSourcePattern } from '../lib/config.mjs';
9
+ import { getMessage } from '../lib/messages.mjs';
8
10
 
9
11
  export const debugLoop = {
10
12
  name: 'debug-loop',
@@ -14,7 +16,8 @@ export const debugLoop = {
14
16
  if (!filePath) return { type: 'pass' };
15
17
 
16
18
  // Skip non-source files
17
- if (!/\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h)$/.test(filePath)) return { type: 'pass' };
19
+ const sourcePattern = buildSourcePattern(config.sourceExtensions || DEFAULTS.sourceExtensions);
20
+ if (!sourcePattern.test(filePath)) return { type: 'pass' };
18
21
 
19
22
  const hash = createHash('md5').update(filePath).digest('hex').slice(0, 8);
20
23
  const countKey = `edit-count-${hash}`;
@@ -34,6 +37,7 @@ export const debugLoop = {
34
37
  writeJsonState(stateDir, logKey, editLog);
35
38
 
36
39
  const name = filePath.split('/').pop();
40
+ const lang = config.language || 'en';
37
41
  const pattern = count >= config.warnAt ? analyzePattern(editLog) : null;
38
42
 
39
43
  if (count >= config.blockAt) {
@@ -41,12 +45,12 @@ export const debugLoop = {
41
45
  if (pattern === 'divergent') {
42
46
  return {
43
47
  type: 'block',
44
- message: `🌈 Prism Debug Loop blocked: ${name} edited ${count} times on same area. Discuss approach with user before continuing.`
48
+ message: getMessage(lang, 'debug-loop.block.divergent', { name, count })
45
49
  };
46
50
  }
47
51
  return {
48
52
  type: 'warn',
49
- message: `🌈 Prism > Debug Loop: ${name} edited ${count} times (different areas). Consider if this is expected.`
53
+ message: getMessage(lang, 'debug-loop.warn.convergent', { name, count })
50
54
  };
51
55
  }
52
56
 
@@ -55,7 +59,7 @@ export const debugLoop = {
55
59
  if (pattern === 'divergent') {
56
60
  return {
57
61
  type: 'warn',
58
- message: `🌈 Prism > Debug Loop: ${name} edited ${count} times on same area. Stop and investigate root cause.`
62
+ message: getMessage(lang, 'debug-loop.warn.divergent', { name, count })
59
63
  };
60
64
  }
61
65
  // Convergent edits (different areas) = normal progressive work → pass
@@ -68,8 +72,19 @@ export const debugLoop = {
68
72
  function analyzePattern(log) {
69
73
  if (log.length < 3) return null;
70
74
  const recent = log.slice(-3).map(e => e.snippet);
75
+
76
+ // Filter out empty/whitespace snippets — can't determine pattern
77
+ if (recent.some(s => !s || !s.trim())) return null;
78
+
79
+ // Exact duplicates = clearly divergent (same code edited repeatedly)
71
80
  const uniqueSnippets = new Set(recent).size;
72
81
  if (uniqueSnippets === 1) return 'divergent';
73
- const overlap = recent.filter(s => recent[0] && s.includes(recent[0].slice(0, 20))).length;
82
+
83
+ // Check for meaningful overlap using longer window
84
+ const baseSnippet = recent[0].slice(0, 40);
85
+ // Skip overlap check if base is too short for meaningful comparison
86
+ if (baseSnippet.length < 10) return uniqueSnippets <= 2 ? 'divergent' : 'convergent';
87
+
88
+ const overlap = recent.filter(s => s.includes(baseSnippet)).length;
74
89
  return overlap >= 2 ? 'divergent' : 'convergent';
75
90
  }
@@ -4,9 +4,9 @@
4
4
  */
5
5
 
6
6
  import { readJsonState, writeJsonState } from '../lib/state.mjs';
7
+ import { DEFAULTS, buildSourcePattern, buildTestPattern } from '../lib/config.mjs';
8
+ import { getMessage } from '../lib/messages.mjs';
7
9
 
8
- const SOURCE_PATTERN = /\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|svelte|vue)$/;
9
- const TEST_PATTERN = /\.(test|spec|_test)\./;
10
10
  const PLAN_PATTERN = /(?:^|\/)docs\/plans\/.*\.md$|(?:^|\/).*plan.*\.md$/i;
11
11
 
12
12
  export const scopeGuard = {
@@ -17,16 +17,20 @@ export const scopeGuard = {
17
17
  if (!filePath) return { type: 'pass' };
18
18
 
19
19
  // Plan file created → mark plan as active (thresholds will be doubled)
20
+ const lang = config.language || 'en';
20
21
  if (PLAN_PATTERN.test(filePath)) {
21
22
  writeJsonState(stateDir, 'scope-has-plan', true);
22
- return { type: 'pass', message: '🌈 Prism 📋 Plan file detected. Scope thresholds raised.' };
23
+ return { type: 'pass', message: getMessage(lang, 'scope-guard.plan-detected') };
23
24
  }
24
25
 
25
26
  // Only track source files
26
- if (!SOURCE_PATTERN.test(filePath)) return { type: 'pass' };
27
+ const sourcePattern = buildSourcePattern(config.sourceExtensions || DEFAULTS.sourceExtensions);
28
+ const testPattern = buildTestPattern(config.testPatterns || DEFAULTS.testPatterns);
29
+
30
+ if (!sourcePattern.test(filePath)) return { type: 'pass' };
27
31
 
28
32
  // Don't count test files toward scope
29
- if (TEST_PATTERN.test(filePath)) return { type: 'pass' };
33
+ if (testPattern.test(filePath)) return { type: 'pass' };
30
34
 
31
35
  // Track unique files
32
36
  let files = readJsonState(stateDir, 'scope-files') || [];
@@ -52,14 +56,14 @@ export const scopeGuard = {
52
56
  if (count >= blockAt) {
53
57
  return {
54
58
  type: 'block',
55
- message: `🌈 Prism Scope Guard: ${count} unique files modified without a plan. Run /prism to decompose before continuing.`
59
+ message: getMessage(lang, 'scope-guard.block', { count })
56
60
  };
57
61
  }
58
62
 
59
63
  if (count >= warnAt) {
60
64
  return {
61
65
  type: 'warn',
62
- message: `🌈 Prism > Scope Guard: ${count} unique files modified. Consider running /prism to decompose the task.`
66
+ message: getMessage(lang, 'scope-guard.warn', { count })
63
67
  };
64
68
  }
65
69
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { writeState } from '../lib/state.mjs';
7
+ import { getMessage } from '../lib/messages.mjs';
7
8
 
8
9
  const TEST_PATTERNS = [
9
10
  /\bnpm\s+test\b/,
@@ -16,6 +17,64 @@ const TEST_PATTERNS = [
16
17
  /\bgo\s+test\b/,
17
18
  /\bmake\s+test\b/,
18
19
  /\bnpx\s+(jest|vitest|mocha)\b/,
20
+ /\bbun\s+test\b/,
21
+ /\bpnpm\s+test\b/,
22
+ /\byarn\s+test\b/,
23
+ /\bdeno\s+test\b/,
24
+ /\bpnpm\s+exec\s+jest\b/,
25
+ /\bbunx\s+vitest\b/,
26
+ /\brspec\b/,
27
+ /\bdotnet\s+test\b/,
28
+ /\bmvn\s+test\b/,
29
+ /\bgradle\s+test\b/,
30
+ ];
31
+
32
+ const RESULT_DETECTORS = [
33
+ {
34
+ name: 'node-test-runner',
35
+ detect: (output) => /# (?:pass|fail) \d+/.test(output),
36
+ isFail: (output) => /# fail [1-9]/.test(output),
37
+ },
38
+ {
39
+ name: 'jest',
40
+ detect: (output) => /Tests:.*\d+/.test(output),
41
+ isFail: (output) => /\d+ failed/.test(output),
42
+ },
43
+ {
44
+ name: 'vitest',
45
+ detect: (output) => /Tests\s+\d+ (?:passed|failed)/.test(output),
46
+ isFail: (output) => /Tests\s+\d+ failed/.test(output),
47
+ },
48
+ {
49
+ name: 'pytest',
50
+ detect: (output) => /={3,}.*={3,}/.test(output) || /\d+ passed/.test(output),
51
+ isFail: (output) => /\d+ failed/.test(output) || /\d+ error/.test(output),
52
+ },
53
+ {
54
+ name: 'go',
55
+ detect: (output) => /^(?:PASS|FAIL)$|--- (?:PASS|FAIL)/m.test(output),
56
+ isFail: (output) => /^FAIL$/m.test(output) || /--- FAIL:/m.test(output),
57
+ },
58
+ {
59
+ name: 'cargo',
60
+ detect: (output) => /test result:/.test(output),
61
+ isFail: (output) => /test result: FAILED/.test(output),
62
+ },
63
+ {
64
+ name: 'mocha',
65
+ detect: (output) => /\d+ passing/.test(output),
66
+ isFail: (output) => /\d+ failing/.test(output),
67
+ },
68
+ {
69
+ name: 'rspec',
70
+ detect: (output) => /\d+ examples?/.test(output),
71
+ isFail: (output) => /[1-9] failures?/.test(output),
72
+ },
73
+ {
74
+ name: 'dotnet',
75
+ detect: (output) => /Test Run (?:Successful|Failed)/.test(output),
76
+ isFail: (output) => /Test Run Failed/.test(output),
77
+ },
19
78
  ];
20
79
 
21
80
  export const testTracker = {
@@ -27,32 +86,28 @@ export const testTracker = {
27
86
  const isTestCommand = TEST_PATTERNS.some(p => p.test(command));
28
87
  if (!isTestCommand) return { type: 'pass' };
29
88
 
30
- // Record timestamp
31
89
  const now = Math.floor(Date.now() / 1000);
32
90
  writeState(stateDir, 'last-test-run', String(now));
33
91
 
34
- // Record result — Claude Code does not provide exitCode,
35
- // so we infer pass/fail from stdout and interrupted flag
36
92
  let passed;
37
93
  if (ctx.interrupted) {
38
94
  passed = false;
39
95
  } else {
40
- const stdout = ctx.stdout || '';
41
- const failMatch = stdout.match(/# fail (\d+)/);
42
- if (failMatch) {
43
- passed = parseInt(failMatch[1], 10) === 0;
44
- } else if (/(?:FAIL|FAILED|ERROR)\b/i.test(stdout) && !/# pass \d+/.test(stdout)) {
45
- passed = false;
96
+ const output = (ctx.stdout || '') + '\n' + (ctx.stderr || '');
97
+ const matched = RESULT_DETECTORS.find(d => d.detect(output));
98
+ if (matched) {
99
+ passed = !matched.isFail(output);
46
100
  } else {
47
101
  passed = true;
48
102
  }
49
103
  }
104
+
50
105
  writeState(stateDir, 'last-test-result', passed ? 'pass' : 'fail');
51
106
 
52
107
  if (!passed) {
53
108
  return {
54
109
  type: 'warn',
55
- message: '🌈 Prism 📊 Tests FAILED. Fix before committing.'
110
+ message: getMessage(config.language || 'en', 'test-tracker.warn.failed')
56
111
  };
57
112
  }
58
113
 
@@ -0,0 +1,70 @@
1
+ /**
2
+ * claude-prism — Turn Reporter
3
+ * UserPromptSubmit hook: tracks turns, injects previous turn summary
4
+ */
5
+
6
+ import { readState, writeState, readJsonState, writeJsonState } from '../lib/state.mjs';
7
+ import { getMessage } from '../lib/messages.mjs';
8
+
9
+ export const turnReporter = {
10
+ name: 'turn-reporter',
11
+
12
+ evaluate(ctx, config, stateDir) {
13
+ // Increment turn counter
14
+ const prevTurn = parseInt(readState(stateDir, 'turn-count') || '0', 10) || 0;
15
+ const turnNumber = prevTurn + 1;
16
+ writeState(stateDir, 'turn-count', String(turnNumber));
17
+
18
+ // Read previous turn actions (recorded by post-tool pipeline)
19
+ const prevActions = readJsonState(stateDir, 'turn-actions') || [];
20
+ // Reset actions for new turn
21
+ writeJsonState(stateDir, 'turn-actions', []);
22
+
23
+ // Check autonomous run length
24
+ const autoTurns = parseInt(readState(stateDir, 'auto-turns') || '0', 10) || 0;
25
+ if (ctx.userPrompt) {
26
+ // User input detected — reset auto counter
27
+ writeState(stateDir, 'auto-turns', '0');
28
+ } else {
29
+ // Autonomous turn
30
+ const newAutoTurns = autoTurns + 1;
31
+ writeState(stateDir, 'auto-turns', String(newAutoTurns));
32
+
33
+ if (newAutoTurns >= (config.silentTurnsWarning || 5)) {
34
+ const scopeFiles = readJsonState(stateDir, 'scope-files') || [];
35
+ return {
36
+ type: 'warn',
37
+ message: `🌈 Prism ⏰ ${newAutoTurns} turns without user input. Files changed: ${scopeFiles.length}. Report progress before continuing.`
38
+ };
39
+ }
40
+ }
41
+
42
+ // Build previous turn summary if there were actions
43
+ if (prevActions.length === 0) return { type: 'pass' };
44
+
45
+ const fileActions = prevActions.filter(a => a.type === 'file-edit' || a.type === 'file-create');
46
+ const testActions = prevActions.filter(a => a.type === 'test-run');
47
+ const blockActions = prevActions.filter(a => a.type === 'block');
48
+
49
+ const parts = [`🌈 Prism Turn #${turnNumber - 1}:`];
50
+ if (fileActions.length > 0) {
51
+ const names = [...new Set(fileActions.map(a => a.file))].slice(0, 5);
52
+ parts.push(`Files: ${names.join(', ')}${fileActions.length > 5 ? ` +${fileActions.length - 5} more` : ''}`);
53
+ }
54
+ if (testActions.length > 0) {
55
+ const passed = testActions.filter(a => a.passed).length;
56
+ const failed = testActions.length - passed;
57
+ parts.push(`Tests: ${passed} passed${failed > 0 ? `, ${failed} failed` : ''}`);
58
+ }
59
+ if (blockActions.length > 0) {
60
+ parts.push(`Blocks: ${blockActions.length}`);
61
+ }
62
+
63
+ if (parts.length <= 1) return { type: 'pass' };
64
+
65
+ return {
66
+ type: 'pass',
67
+ message: parts.join(' | ')
68
+ };
69
+ }
70
+ };
package/lib/adapter.mjs CHANGED
@@ -19,6 +19,7 @@ const TOOL_ACTION_MAP = {
19
19
  const EVENT_PHASE_MAP = {
20
20
  'PreToolUse': 'pre',
21
21
  'PostToolUse': 'post',
22
+ 'UserPromptSubmit': 'prompt',
22
23
  };
23
24
 
24
25
  export function parseInput() {
package/lib/config.mjs CHANGED
@@ -8,11 +8,15 @@ import { join } from 'path';
8
8
 
9
9
  const DEFAULTS = {
10
10
  language: 'en',
11
+ sourceExtensions: ['ts', 'tsx', 'js', 'jsx', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'svelte', 'vue', 'rb', 'kt', 'swift', 'php', 'cs', 'scala', 'ex', 'clj', 'zig', 'lua', 'dart'],
12
+ testPatterns: ['test', 'spec', '_test'],
13
+ customRules: [],
11
14
  hooks: {
12
15
  'commit-guard': { enabled: true, maxTestAge: 300 },
13
16
  'debug-loop': { enabled: true, warnAt: 3, blockAt: 5 },
14
17
  'test-tracker': { enabled: true },
15
- 'scope-guard': { enabled: true, warnAt: 4, blockAt: 7, agentWarnAt: 8, agentBlockAt: 12 }
18
+ 'scope-guard': { enabled: true, warnAt: 4, blockAt: 7, agentWarnAt: 8, agentBlockAt: 12 },
19
+ 'alignment': { enabled: true, driftThreshold: 2 }
16
20
  }
17
21
  };
18
22
 
@@ -33,7 +37,12 @@ export function loadConfig(projectRoot) {
33
37
 
34
38
  export function getHookConfig(hookName, projectRoot) {
35
39
  const config = loadConfig(projectRoot);
36
- return config.hooks?.[hookName] || DEFAULTS.hooks[hookName] || { enabled: true };
40
+ const hookConfig = config.hooks?.[hookName] || DEFAULTS.hooks[hookName] || { enabled: true };
41
+ // Include top-level fields needed by hooks
42
+ hookConfig.language = config.language || DEFAULTS.language;
43
+ hookConfig.sourceExtensions = config.sourceExtensions || DEFAULTS.sourceExtensions;
44
+ hookConfig.testPatterns = config.testPatterns || DEFAULTS.testPatterns;
45
+ return hookConfig;
37
46
  }
38
47
 
39
48
  const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
@@ -51,4 +60,14 @@ function deepMerge(target, source) {
51
60
  return result;
52
61
  }
53
62
 
63
+ export function buildSourcePattern(extensions) {
64
+ const escaped = extensions.map(e => e.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
65
+ return new RegExp(`\\.(${escaped.join('|')})$`);
66
+ }
67
+
68
+ export function buildTestPattern(patterns) {
69
+ const escaped = patterns.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
70
+ return new RegExp(`\\.(${escaped.join('|')})\\.`);
71
+ }
72
+
54
73
  export { DEFAULTS };
package/lib/installer.mjs CHANGED
@@ -49,6 +49,11 @@ export async function init(projectDir, options = {}) {
49
49
  copyFileSync(join(runnersDir, 'test-tracker.mjs'), join(hooksDir, 'test-tracker.mjs'));
50
50
  copyFileSync(join(runnersDir, 'scope-guard.mjs'), join(hooksDir, 'scope-guard.mjs'));
51
51
 
52
+ // Copy unified pipeline runners
53
+ copyFileSync(join(runnersDir, 'post-tool.mjs'), join(hooksDir, 'post-tool.mjs'));
54
+ copyFileSync(join(runnersDir, 'pre-tool.mjs'), join(hooksDir, 'pre-tool.mjs'));
55
+ copyFileSync(join(runnersDir, 'user-prompt.mjs'), join(hooksDir, 'user-prompt.mjs'));
56
+
52
57
  // Copy rule logic files
53
58
  const rulesDestDir = join(claudeDir, 'rules');
54
59
  mkdirSync(rulesDestDir, { recursive: true });
@@ -57,12 +62,14 @@ export async function init(projectDir, options = {}) {
57
62
  copyFileSync(join(hooksSourceDir, 'debug-loop.mjs'), join(rulesDestDir, 'debug-loop.mjs'));
58
63
  copyFileSync(join(hooksSourceDir, 'test-tracker.mjs'), join(rulesDestDir, 'test-tracker.mjs'));
59
64
  copyFileSync(join(hooksSourceDir, 'scope-guard.mjs'), join(rulesDestDir, 'scope-guard.mjs'));
65
+ copyFileSync(join(hooksSourceDir, 'turn-reporter.mjs'), join(rulesDestDir, 'turn-reporter.mjs'));
66
+ copyFileSync(join(hooksSourceDir, 'alignment.mjs'), join(rulesDestDir, 'alignment.mjs'));
60
67
 
61
68
  // Copy lib dependencies (adapter + state + config + utils)
62
69
  const libDestDir = join(claudeDir, 'lib');
63
70
  mkdirSync(libDestDir, { recursive: true });
64
71
  const libSourceDir = join(__dirname);
65
- for (const file of ['adapter.mjs', 'state.mjs', 'config.mjs', 'utils.mjs']) {
72
+ for (const file of ['adapter.mjs', 'state.mjs', 'config.mjs', 'utils.mjs', 'pipeline.mjs']) {
66
73
  copyFileSync(join(libSourceDir, file), join(libDestDir, file));
67
74
  }
68
75
 
@@ -82,10 +89,16 @@ export async function init(projectDir, options = {}) {
82
89
  'commit-guard': { enabled: true, maxTestAge: 300 },
83
90
  'debug-loop': { enabled: true, warnAt: 3, blockAt: 5 },
84
91
  'test-tracker': { enabled: true },
85
- 'scope-guard': { enabled: true, warnAt: 4, blockAt: 7, agentWarnAt: 8, agentBlockAt: 12 }
92
+ 'scope-guard': { enabled: true, warnAt: 4, blockAt: 7, agentWarnAt: 8, agentBlockAt: 12 },
93
+ 'alignment': { enabled: true, driftThreshold: 2 }
86
94
  }
87
95
  }, null, 2) + '\n');
88
96
  }
97
+
98
+ // Write version file for doctor to detect mismatches
99
+ const pkgPath = join(__dirname, '..', 'package.json');
100
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
101
+ writeFileSync(join(claudeDir, '.prism-version'), pkg.version);
89
102
  }
90
103
 
91
104
  /**
@@ -153,7 +166,7 @@ export function uninstall(projectDir) {
153
166
  }
154
167
 
155
168
  // 3. Remove prism hooks
156
- for (const hook of ['commit-guard.mjs', 'debug-loop.mjs', 'test-tracker.mjs', 'scope-guard.mjs']) {
169
+ for (const hook of ['commit-guard.mjs', 'debug-loop.mjs', 'test-tracker.mjs', 'scope-guard.mjs', 'user-prompt.mjs']) {
157
170
  const p = join(claudeDir, 'hooks', hook);
158
171
  if (existsSync(p)) rmSync(p);
159
172
  }
@@ -171,7 +184,7 @@ export function uninstall(projectDir) {
171
184
  if (settings.hooks) {
172
185
  for (const [event, hookList] of Object.entries(settings.hooks)) {
173
186
  settings.hooks[event] = hookList.filter(
174
- h => !h.hooks?.some(hh => hh.command?.includes('commit-guard') || hh.command?.includes('debug-loop') || hh.command?.includes('test-tracker') || hh.command?.includes('scope-guard'))
187
+ h => !h.hooks?.some(hh => hh.command?.includes('commit-guard') || hh.command?.includes('debug-loop') || hh.command?.includes('test-tracker') || hh.command?.includes('scope-guard') || hh.command?.includes('user-prompt'))
175
188
  );
176
189
  if (settings.hooks[event].length === 0) delete settings.hooks[event];
177
190
  }
@@ -184,6 +197,10 @@ export function uninstall(projectDir) {
184
197
  if (existsSync(configPath)) rmSync(configPath);
185
198
  const legacyConfigPath = join(projectDir, '.prism.json');
186
199
  if (existsSync(legacyConfigPath)) rmSync(legacyConfigPath);
200
+
201
+ // Remove version file
202
+ const versionFile = join(claudeDir, '.prism-version');
203
+ if (existsSync(versionFile)) rmSync(versionFile);
187
204
  }
188
205
 
189
206
  /**
@@ -265,6 +282,14 @@ export function doctor(projectDir, options = {}) {
265
282
  }
266
283
  }
267
284
 
285
+ // Check optional hooks (warn only, don't affect ok)
286
+ for (const hook of ['user-prompt.mjs']) {
287
+ if (!existsSync(join(claudeDir, 'hooks', hook))) {
288
+ issues.push(`Missing optional hook: ${hook} (turn reporter)`);
289
+ fixes.push('Run `prism update` to restore missing files');
290
+ }
291
+ }
292
+
268
293
  // Check CLAUDE.md
269
294
  const claudeMdPath = join(projectDir, 'CLAUDE.md');
270
295
  if (!existsSync(claudeMdPath)) {
@@ -300,6 +325,18 @@ export function doctor(projectDir, options = {}) {
300
325
  fixes.push('Run `prism update` to restore');
301
326
  }
302
327
 
328
+ // Check version mismatch
329
+ const versionFile = join(claudeDir, '.prism-version');
330
+ if (existsSync(versionFile)) {
331
+ const installedVersion = readFileSync(versionFile, 'utf8').trim();
332
+ const pkgPath = join(__dirname, '..', 'package.json');
333
+ const currentVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
334
+ if (installedVersion !== currentVersion) {
335
+ issues.push(`Version mismatch: installed v${installedVersion}, CLI v${currentVersion}`);
336
+ fixes.push('Run `prism update` to sync installed files with current CLI version');
337
+ }
338
+ }
339
+
303
340
  // Deduplicate fixes
304
341
  const uniqueFixes = [...new Set(fixes)];
305
342
 
@@ -419,20 +456,93 @@ export function uninstallGlobal(options = {}) {
419
456
  if (existsSync(skillDir)) rmSync(skillDir, { recursive: true });
420
457
  }
421
458
 
459
+ /**
460
+ * Dry-run: show what init would do without making changes
461
+ * @param {string} projectDir
462
+ * @param {Object} options
463
+ * @returns {{ actions: Array<{type: string, path: string, status: string}> }}
464
+ */
465
+ export function dryRun(projectDir, options = {}) {
466
+ const { language = 'en', hooks = true } = options;
467
+ const claudeDir = join(projectDir, '.claude');
468
+ const actions = [];
469
+
470
+ // Commands
471
+ const nsCommandsDir = join(claudeDir, 'commands', 'claude-prism');
472
+ const commandFiles = ['prism.md', 'checkpoint.md', 'plan.md', 'doctor.md', 'stats.md', 'help.md', 'update.md'];
473
+ for (const cmd of commandFiles) {
474
+ const target = join(nsCommandsDir, cmd);
475
+ actions.push({
476
+ type: 'command',
477
+ path: `.claude/commands/claude-prism/${cmd}`,
478
+ status: existsSync(target) ? 'update' : 'create'
479
+ });
480
+ }
481
+
482
+ // Hooks
483
+ if (hooks) {
484
+ const hookFiles = ['commit-guard.mjs', 'debug-loop.mjs', 'test-tracker.mjs', 'scope-guard.mjs', 'pre-tool.mjs', 'post-tool.mjs', 'user-prompt.mjs'];
485
+ for (const hook of hookFiles) {
486
+ const target = join(claudeDir, 'hooks', hook);
487
+ actions.push({
488
+ type: 'hook',
489
+ path: `.claude/hooks/${hook}`,
490
+ status: existsSync(target) ? 'update' : 'create'
491
+ });
492
+ }
493
+
494
+ const ruleFiles = ['commit-guard.mjs', 'debug-loop.mjs', 'test-tracker.mjs', 'scope-guard.mjs', 'turn-reporter.mjs'];
495
+ for (const rule of ruleFiles) {
496
+ const target = join(claudeDir, 'rules', rule);
497
+ actions.push({
498
+ type: 'rule',
499
+ path: `.claude/rules/${rule}`,
500
+ status: existsSync(target) ? 'update' : 'create'
501
+ });
502
+ }
503
+
504
+ const libFiles = ['adapter.mjs', 'state.mjs', 'config.mjs', 'utils.mjs', 'messages.mjs', 'pipeline.mjs'];
505
+ for (const lib of libFiles) {
506
+ const target = join(claudeDir, 'lib', lib);
507
+ actions.push({
508
+ type: 'lib',
509
+ path: `.claude/lib/${lib}`,
510
+ status: existsSync(target) ? 'update' : 'create'
511
+ });
512
+ }
513
+ }
514
+
515
+ // CLAUDE.md
516
+ const claudeMdPath = join(projectDir, 'CLAUDE.md');
517
+ actions.push({
518
+ type: 'rules',
519
+ path: 'CLAUDE.md',
520
+ status: existsSync(claudeMdPath) ? 'update' : 'create'
521
+ });
522
+
523
+ // Config
524
+ const configPath = join(projectDir, '.claude-prism.json');
525
+ if (!existsSync(configPath)) {
526
+ actions.push({ type: 'config', path: '.claude-prism.json', status: 'create' });
527
+ }
528
+
529
+ return { actions };
530
+ }
531
+
422
532
  // ─── internal helpers ───
423
533
 
424
534
  function injectRules(projectDir, language) {
425
535
  const claudeMdPath = join(projectDir, 'CLAUDE.md');
426
536
  const rulesFile = `rules.${language}.md`;
427
- const rulesPath = join(TEMPLATES_DIR, rulesFile);
537
+ let rulesPath = join(TEMPLATES_DIR, rulesFile);
428
538
 
539
+ // Fallback to English if requested language not available
429
540
  if (!existsSync(rulesPath)) {
430
- // Fallback to English
431
- const fallback = join(TEMPLATES_DIR, 'rules.en.md');
432
- if (!existsSync(fallback)) return;
541
+ rulesPath = join(TEMPLATES_DIR, 'rules.en.md');
542
+ if (!existsSync(rulesPath)) return;
433
543
  }
434
544
 
435
- const rules = readFileSync(existsSync(rulesPath) ? rulesPath : join(TEMPLATES_DIR, 'rules.en.md'), 'utf8');
545
+ const rules = readFileSync(rulesPath, 'utf8');
436
546
 
437
547
  let existing = '';
438
548
  if (existsSync(claudeMdPath)) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * claude-prism — Hook Message Templates
3
+ * Provides localized messages for hook output
4
+ */
5
+
6
+ const MESSAGES = {
7
+ en: {
8
+ 'commit-guard.block.failed': '🌈 Prism ✋ Commit blocked: last test run FAILED. Fix tests before committing.',
9
+ 'commit-guard.warn.no-test': '🌈 Prism > No test run detected this session. Run tests before committing.',
10
+ 'commit-guard.warn.stale': '🌈 Prism > Last test run was {minutes}min ago. Run tests before committing.',
11
+ 'debug-loop.block.divergent': '🌈 Prism ✋ Debug Loop blocked: {name} edited {count} times on same area. Discuss approach with user before continuing.',
12
+ 'debug-loop.warn.divergent': '🌈 Prism > Debug Loop: {name} edited {count} times on same area. Stop and investigate root cause.',
13
+ 'debug-loop.warn.convergent': '🌈 Prism > Debug Loop: {name} edited {count} times (different areas). Consider if this is expected.',
14
+ 'scope-guard.block': '🌈 Prism ✋ Scope Guard: {count} unique files modified without a plan. Run /prism to decompose before continuing.',
15
+ 'scope-guard.warn': '🌈 Prism > Scope Guard: {count} unique files modified. Consider running /prism to decompose the task.',
16
+ 'scope-guard.plan-detected': '🌈 Prism 📋 Plan file detected. Scope thresholds raised.',
17
+ 'test-tracker.warn.failed': '🌈 Prism 📊 Tests FAILED. Fix before committing.',
18
+ },
19
+ ko: {
20
+ 'commit-guard.block.failed': '🌈 Prism ✋ 커밋 차단: 마지막 테스트 실패. 테스트를 수정한 후 커밋하세요.',
21
+ 'commit-guard.warn.no-test': '🌈 Prism > 이 세션에서 테스트 실행 이력이 없습니다. 커밋 전에 테스트를 실행하세요.',
22
+ 'commit-guard.warn.stale': '🌈 Prism > 마지막 테스트가 {minutes}분 전입니다. 커밋 전에 테스트를 실행하세요.',
23
+ 'debug-loop.block.divergent': '🌈 Prism ✋ 디버그 루프 차단: {name}이 같은 영역에서 {count}회 수정됨. 사용자와 접근 방식을 논의하세요.',
24
+ 'debug-loop.warn.divergent': '🌈 Prism > 디버그 루프: {name}이 같은 영역에서 {count}회 수정됨. 멈추고 근본 원인을 조사하세요.',
25
+ 'debug-loop.warn.convergent': '🌈 Prism > 디버그 루프: {name}이 {count}회 수정됨 (다른 영역). 예상된 작업인지 확인하세요.',
26
+ 'scope-guard.block': '🌈 Prism ✋ 스코프 가드: 계획 없이 {count}개 고유 파일 수정됨. /prism으로 분해 후 계속하세요.',
27
+ 'scope-guard.warn': '🌈 Prism > 스코프 가드: {count}개 고유 파일 수정됨. /prism으로 작업을 분해하는 것을 고려하세요.',
28
+ 'scope-guard.plan-detected': '🌈 Prism 📋 계획 파일 감지됨. 스코프 임계값이 상향 조정되었습니다.',
29
+ 'test-tracker.warn.failed': '🌈 Prism 📊 테스트 실패. 커밋 전에 수정하세요.',
30
+ },
31
+ ja: {
32
+ 'commit-guard.block.failed': '🌈 Prism ✋ コミットブロック: 最後のテストが失敗しました。テストを修正してからコミットしてください。',
33
+ 'commit-guard.warn.no-test': '🌈 Prism > このセッションでテスト実行が検出されません。コミット前にテストを実行してください。',
34
+ 'commit-guard.warn.stale': '🌈 Prism > 最後のテスト実行は{minutes}分前です。コミット前にテストを実行してください。',
35
+ 'debug-loop.block.divergent': '🌈 Prism ✋ デバッグループブロック: {name}が同じ領域で{count}回編集されました。ユーザーとアプローチを議論してください。',
36
+ 'debug-loop.warn.divergent': '🌈 Prism > デバッグループ: {name}が同じ領域で{count}回編集されました。停止して根本原因を調査してください。',
37
+ 'debug-loop.warn.convergent': '🌈 Prism > デバッグループ: {name}が{count}回編集されました(異なる領域)。想定通りか確認してください。',
38
+ 'scope-guard.block': '🌈 Prism ✋ スコープガード: 計画なしに{count}個のファイルが変更されました。/prismで分解してから続行してください。',
39
+ 'scope-guard.warn': '🌈 Prism > スコープガード: {count}個のファイルが変更されました。/prismでタスクの分解を検討してください。',
40
+ 'scope-guard.plan-detected': '🌈 Prism 📋 計画ファイルを検出。スコープ閾値を引き上げました。',
41
+ 'test-tracker.warn.failed': '🌈 Prism 📊 テスト失敗。コミット前に修正してください。',
42
+ },
43
+ zh: {
44
+ 'commit-guard.block.failed': '🌈 Prism ✋ 提交被阻止:上次测试失败。请修复测试后再提交。',
45
+ 'commit-guard.warn.no-test': '🌈 Prism > 本次会话未检测到测试运行。请在提交前运行测试。',
46
+ 'commit-guard.warn.stale': '🌈 Prism > 上次测试运行在{minutes}分钟前。请在提交前运行测试。',
47
+ 'debug-loop.block.divergent': '🌈 Prism ✋ 调试循环阻止:{name}在同一区域被编辑了{count}次。请与用户讨论方法。',
48
+ 'debug-loop.warn.divergent': '🌈 Prism > 调试循环:{name}在同一区域被编辑了{count}次。停止并调查根本原因。',
49
+ 'debug-loop.warn.convergent': '🌈 Prism > 调试循环:{name}被编辑了{count}次(不同区域)。请确认这是否是预期行为。',
50
+ 'scope-guard.block': '🌈 Prism ✋ 范围守卫:未制定计划就修改了{count}个文件。请运行/prism分解后再继续。',
51
+ 'scope-guard.warn': '🌈 Prism > 范围守卫:已修改{count}个文件。请考虑运行/prism来分解任务。',
52
+ 'scope-guard.plan-detected': '🌈 Prism 📋 检测到计划文件。范围阈值已提高。',
53
+ 'test-tracker.warn.failed': '🌈 Prism 📊 测试失败。请在提交前修复。',
54
+ },
55
+ };
56
+
57
+ export function getMessage(lang, key, params = {}) {
58
+ const template = MESSAGES[lang]?.[key] || MESSAGES.en[key] || key;
59
+ return template.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
60
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * claude-prism — Hook Pipeline
3
+ * Runs multiple rules in a single hook invocation for reduced I/O
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { sanitizeId } from './utils.mjs';
9
+ import { loadConfig } from './config.mjs';
10
+ import { getStateDir } from './state.mjs';
11
+
12
+ const TOOL_ACTION_MAP = {
13
+ 'Edit': 'edit',
14
+ 'Write': 'write',
15
+ 'Bash': 'command',
16
+ 'Read': 'read',
17
+ 'Task': 'subagent',
18
+ };
19
+
20
+ const EVENT_PHASE_MAP = {
21
+ 'PreToolUse': 'pre',
22
+ 'PostToolUse': 'post',
23
+ 'UserPromptSubmit': 'prompt',
24
+ };
25
+
26
+ function parseInput() {
27
+ try {
28
+ return JSON.parse(readFileSync(0, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function toContext(input, hookEventName) {
35
+ const toolName = input.tool_name || '';
36
+ return {
37
+ action: TOOL_ACTION_MAP[toolName] || toolName.toLowerCase(),
38
+ phase: EVENT_PHASE_MAP[hookEventName] || 'pre',
39
+ filePath: input.tool_input?.file_path || undefined,
40
+ command: input.tool_input?.command || undefined,
41
+ oldString: input.tool_input?.old_string || undefined,
42
+ sessionId: sanitizeId(input.session_id),
43
+ agentId: sanitizeId(input.agent_id || ''),
44
+ stdout: input.tool_response?.stdout ?? undefined,
45
+ stderr: input.tool_response?.stderr ?? undefined,
46
+ interrupted: input.tool_response?.interrupted ?? false,
47
+ userPrompt: input.tool_input?.user_prompt ?? undefined,
48
+ };
49
+ }
50
+
51
+ function formatOutput(hookEventName, messages) {
52
+ if (messages.length === 0) return null;
53
+ return JSON.stringify({
54
+ hookSpecificOutput: {
55
+ hookEventName,
56
+ additionalContext: messages.join('\n')
57
+ }
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Run a pipeline of rules for a single hook event
63
+ * @param {Array<{name: string, rule: Object}>} rules - Rules with evaluate() methods
64
+ * @param {string} hookEventName - 'PreToolUse' or 'PostToolUse'
65
+ */
66
+ export function runPipeline(rules, hookEventName) {
67
+ const input = parseInput();
68
+ if (!input) process.exit(0);
69
+
70
+ // Read config ONCE
71
+ const fullConfig = loadConfig(process.cwd());
72
+
73
+ const ctx = toContext(input, hookEventName);
74
+ const stateDir = getStateDir(ctx.sessionId, ctx.agentId);
75
+
76
+ const messages = [];
77
+ let blocked = false;
78
+ let blockMessage = '';
79
+
80
+ for (const { name, rule } of rules) {
81
+ // Build hook-specific config (same as getHookConfig but without re-reading file)
82
+ const hookConfig = fullConfig.hooks?.[name] || { enabled: true };
83
+ hookConfig.language = fullConfig.language || 'en';
84
+ hookConfig.sourceExtensions = fullConfig.sourceExtensions;
85
+ hookConfig.testPatterns = fullConfig.testPatterns;
86
+
87
+ if (!hookConfig.enabled) continue;
88
+
89
+ const result = rule.evaluate(ctx, hookConfig, stateDir);
90
+
91
+ if (result.type === 'block') {
92
+ blocked = true;
93
+ blockMessage = result.message || '🌈 Prism ✋ Action blocked.';
94
+ break; // First block wins, stop pipeline
95
+ }
96
+
97
+ if (result.message) {
98
+ messages.push(result.message);
99
+ }
100
+ }
101
+
102
+ if (blocked) {
103
+ process.stderr.write(blockMessage);
104
+ process.exit(2);
105
+ }
106
+
107
+ const output = formatOutput(hookEventName, messages);
108
+ if (output) {
109
+ process.stdout.write(output);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Load custom rules from config and merge with built-in rules
115
+ * @param {Array<{name: string, rule: Object}>} builtInRules
116
+ * @param {string[]} customRulePaths - Paths relative to project root
117
+ * @returns {Promise<Array<{name: string, rule: Object}>>}
118
+ */
119
+ export async function loadCustomRules(builtInRules, customRulePaths) {
120
+ if (!customRulePaths || customRulePaths.length === 0) return builtInRules;
121
+
122
+ const rules = [...builtInRules];
123
+ for (const rulePath of customRulePaths) {
124
+ try {
125
+ const absPath = join(process.cwd(), rulePath);
126
+ const mod = await import(absPath);
127
+ const rule = mod.default || mod[Object.keys(mod)[0]];
128
+ if (rule && typeof rule.evaluate === 'function') {
129
+ rules.push({ name: rule.name || rulePath, rule });
130
+ }
131
+ } catch {
132
+ // Skip invalid rules silently — don't break the pipeline
133
+ }
134
+ }
135
+ return rules;
136
+ }
137
+
138
+ /**
139
+ * Run pipeline with custom rules support (async)
140
+ * @param {Array<{name: string, rule: Object}>} builtInRules
141
+ * @param {string} hookEventName - 'PreToolUse' or 'PostToolUse'
142
+ */
143
+ export async function runPipelineAsync(builtInRules, hookEventName) {
144
+ const input = parseInput();
145
+ if (!input) process.exit(0);
146
+
147
+ const fullConfig = loadConfig(process.cwd());
148
+ const customRulePaths = fullConfig.customRules || [];
149
+ const rules = await loadCustomRules(builtInRules, customRulePaths);
150
+
151
+ const ctx = toContext(input, hookEventName);
152
+ const stateDir = getStateDir(ctx.sessionId, ctx.agentId);
153
+
154
+ const messages = [];
155
+ let blocked = false;
156
+ let blockMessage = '';
157
+
158
+ for (const { name, rule } of rules) {
159
+ const hookConfig = fullConfig.hooks?.[name] || { enabled: true };
160
+ hookConfig.language = fullConfig.language || 'en';
161
+ hookConfig.sourceExtensions = fullConfig.sourceExtensions;
162
+ hookConfig.testPatterns = fullConfig.testPatterns;
163
+
164
+ if (hookConfig.enabled === false) continue;
165
+
166
+ const result = rule.evaluate(ctx, hookConfig, stateDir);
167
+
168
+ if (result.type === 'block') {
169
+ blocked = true;
170
+ blockMessage = result.message || '🌈 Prism ✋ Action blocked.';
171
+ break;
172
+ }
173
+
174
+ if (result.message) {
175
+ messages.push(result.message);
176
+ }
177
+ }
178
+
179
+ if (blocked) {
180
+ process.stderr.write(blockMessage);
181
+ process.exit(2);
182
+ }
183
+
184
+ const output = formatOutput(hookEventName, messages);
185
+ if (output) {
186
+ process.stdout.write(output);
187
+ }
188
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * claude-prism — Session Event Logger
3
+ * JSONL-based event recording for session analysis
4
+ */
5
+
6
+ import { readFileSync, appendFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { tmpdir } from 'os';
9
+
10
+ const SESSION_ROOT = join(tmpdir(), '.prism', 'sessions');
11
+
12
+ /**
13
+ * Get session log file path
14
+ */
15
+ export function getSessionLogPath(sessionId) {
16
+ mkdirSync(SESSION_ROOT, { recursive: true, mode: 0o700 });
17
+ return join(SESSION_ROOT, `${sessionId}.jsonl`);
18
+ }
19
+
20
+ /**
21
+ * Append an event to the session log
22
+ */
23
+ export function logEvent(sessionId, event) {
24
+ const logPath = getSessionLogPath(sessionId);
25
+ const entry = {
26
+ ts: Date.now(),
27
+ ...event
28
+ };
29
+ appendFileSync(logPath, JSON.stringify(entry) + '\n', { mode: 0o600 });
30
+ }
31
+
32
+ /**
33
+ * Read all events from a session log
34
+ */
35
+ export function readSessionLog(sessionId) {
36
+ const logPath = getSessionLogPath(sessionId);
37
+ if (!existsSync(logPath)) return [];
38
+ try {
39
+ const content = readFileSync(logPath, 'utf8').trim();
40
+ if (!content) return [];
41
+ return content.split('\n').map(line => {
42
+ try { return JSON.parse(line); } catch { return null; }
43
+ }).filter(Boolean);
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Get session summary from event log
51
+ */
52
+ export function getSessionSummary(sessionId) {
53
+ const events = readSessionLog(sessionId);
54
+ if (events.length === 0) return null;
55
+
56
+ const summary = {
57
+ sessionId,
58
+ totalEvents: events.length,
59
+ turns: 0,
60
+ filesCreated: 0,
61
+ filesModified: 0,
62
+ testsRun: 0,
63
+ testsPassed: 0,
64
+ testsFailed: 0,
65
+ blocks: 0,
66
+ warnings: 0,
67
+ startedAt: events[0]?.ts || null,
68
+ lastEventAt: events[events.length - 1]?.ts || null,
69
+ };
70
+
71
+ for (const event of events) {
72
+ switch (event.type) {
73
+ case 'turn':
74
+ summary.turns++;
75
+ break;
76
+ case 'file-edit':
77
+ summary.filesModified++;
78
+ break;
79
+ case 'file-create':
80
+ summary.filesCreated++;
81
+ break;
82
+ case 'test-run':
83
+ summary.testsRun++;
84
+ if (event.passed) summary.testsPassed++;
85
+ else summary.testsFailed++;
86
+ break;
87
+ case 'block':
88
+ summary.blocks++;
89
+ break;
90
+ case 'warn':
91
+ summary.warnings++;
92
+ break;
93
+ }
94
+ }
95
+
96
+ return summary;
97
+ }
98
+
99
+ /**
100
+ * List all session log files
101
+ */
102
+ export function listSessions() {
103
+ if (!existsSync(SESSION_ROOT)) return [];
104
+ return readdirSync(SESSION_ROOT)
105
+ .filter(f => f.endsWith('.jsonl'))
106
+ .map(f => f.replace('.jsonl', ''))
107
+ .sort();
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-prism",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "AI coding problem decomposition tool — Understand, Decompose, Execute, Checkpoint.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,14 @@
36
36
  "README.md",
37
37
  "README.ko.md"
38
38
  ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/lazysaturday91/claude-prism.git"
42
+ },
43
+ "homepage": "https://github.com/lazysaturday91/claude-prism#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/lazysaturday91/claude-prism/issues"
46
+ },
39
47
  "author": "lazysaturday91",
40
48
  "license": "MIT"
41
49
  }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { runPipelineAsync } from '../lib/pipeline.mjs';
3
+ import { debugLoop } from '../rules/debug-loop.mjs';
4
+ import { scopeGuard } from '../rules/scope-guard.mjs';
5
+ import { testTracker } from '../rules/test-tracker.mjs';
6
+ import { alignment } from '../rules/alignment.mjs';
7
+
8
+ await runPipelineAsync([
9
+ { name: 'debug-loop', rule: debugLoop },
10
+ { name: 'scope-guard', rule: scopeGuard },
11
+ { name: 'test-tracker', rule: testTracker },
12
+ { name: 'alignment', rule: alignment },
13
+ ], 'PostToolUse');
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { runPipelineAsync } from '../lib/pipeline.mjs';
3
+ import { commitGuard } from '../rules/commit-guard.mjs';
4
+ import { alignment } from '../rules/alignment.mjs';
5
+
6
+ await runPipelineAsync([
7
+ { name: 'commit-guard', rule: commitGuard },
8
+ { name: 'alignment', rule: alignment },
9
+ ], 'PreToolUse');
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runPipelineAsync } from '../lib/pipeline.mjs';
3
+ import { turnReporter } from '../rules/turn-reporter.mjs';
4
+
5
+ await runPipelineAsync([
6
+ { name: 'turn-reporter', rule: turnReporter },
7
+ ], 'UserPromptSubmit');
@@ -6,40 +6,31 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node .claude/hooks/commit-guard.mjs",
10
- "timeout": 5
9
+ "command": "node .claude/hooks/pre-tool.mjs",
10
+ "timeout": 5000
11
11
  }
12
12
  ]
13
13
  }
14
14
  ],
15
15
  "PostToolUse": [
16
16
  {
17
- "matcher": "Edit|Write",
17
+ "matcher": "Edit|Write|Bash",
18
18
  "hooks": [
19
19
  {
20
20
  "type": "command",
21
- "command": "node .claude/hooks/debug-loop.mjs",
22
- "timeout": 5
21
+ "command": "node .claude/hooks/post-tool.mjs",
22
+ "timeout": 5000
23
23
  }
24
24
  ]
25
- },
26
- {
27
- "matcher": "Edit|Write",
28
- "hooks": [
29
- {
30
- "type": "command",
31
- "command": "node .claude/hooks/scope-guard.mjs",
32
- "timeout": 5
33
- }
34
- ]
35
- },
25
+ }
26
+ ],
27
+ "UserPromptSubmit": [
36
28
  {
37
- "matcher": "Bash",
38
29
  "hooks": [
39
30
  {
40
31
  "type": "command",
41
- "command": "node .claude/hooks/test-tracker.mjs",
42
- "timeout": 5
32
+ "command": "node .claude/hooks/user-prompt.mjs",
33
+ "timeout": 5000
43
34
  }
44
35
  ]
45
36
  }