claude-prism 0.3.1 → 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.
@@ -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.1",
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
  }