claude-prism 0.1.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,67 @@
1
+ /**
2
+ * claude-prism — Debug Loop Detector
3
+ * Warns on repeated edits to same file, blocks at threshold
4
+ */
5
+
6
+ import { createHash } from 'crypto';
7
+ import { readState, writeState, readJsonState, writeJsonState } from '../lib/state.mjs';
8
+
9
+ export const debugLoop = {
10
+ name: 'debug-loop',
11
+
12
+ evaluate(ctx, config, stateDir) {
13
+ const filePath = ctx.filePath;
14
+ if (!filePath) return { type: 'pass' };
15
+
16
+ // Skip non-source files
17
+ if (!/\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h)$/.test(filePath)) return { type: 'pass' };
18
+
19
+ const hash = createHash('md5').update(filePath).digest('hex').slice(0, 8);
20
+ const countKey = `edit-count-${hash}`;
21
+ const logKey = `edit-log-${hash}`;
22
+
23
+ // Increment counter
24
+ const prev = parseInt(readState(stateDir, countKey) || '0', 10) || 0;
25
+ const count = prev + 1;
26
+ writeState(stateDir, countKey, String(count));
27
+
28
+ // Track edit snippets for pattern analysis
29
+ const oldStr = ctx.oldString || '';
30
+ let editLog = readJsonState(stateDir, logKey) || [];
31
+ const snippet = oldStr.slice(0, 80).replace(/\n/g, '\\n');
32
+ editLog.push({ edit: count, snippet, ts: Date.now() });
33
+ if (editLog.length > 10) editLog = editLog.slice(-10);
34
+ writeJsonState(stateDir, logKey, editLog);
35
+
36
+ const name = filePath.split('/').pop();
37
+
38
+ if (count === config.warnAt) {
39
+ const pattern = analyzePattern(editLog);
40
+ const hint = pattern === 'divergent'
41
+ ? ' Pattern: DIVERGENT — same area edited repeatedly.'
42
+ : '';
43
+ return {
44
+ type: 'warn',
45
+ message: `🌈 Prism > Debug Loop: ${name} edited ${count} times.${hint} Stop and investigate root cause.`
46
+ };
47
+ }
48
+
49
+ if (count >= config.blockAt) {
50
+ return {
51
+ type: 'block',
52
+ message: `🌈 Prism ✋ Debug Loop blocked: ${name} edited ${count} times. Discuss approach with user before continuing.`
53
+ };
54
+ }
55
+
56
+ return { type: 'pass' };
57
+ }
58
+ };
59
+
60
+ function analyzePattern(log) {
61
+ if (log.length < 3) return null;
62
+ const recent = log.slice(-3).map(e => e.snippet);
63
+ const uniqueSnippets = new Set(recent).size;
64
+ if (uniqueSnippets === 1) return 'divergent';
65
+ const overlap = recent.filter(s => recent[0] && s.includes(recent[0].slice(0, 20))).length;
66
+ return overlap >= 2 ? 'divergent' : 'convergent';
67
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * claude-prism — Scope Guard
3
+ * Warns when too many unique files are modified without a plan
4
+ */
5
+
6
+ import { readJsonState, writeJsonState } from '../lib/state.mjs';
7
+
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
+ const PLAN_PATTERN = /(?:^|\/)docs\/plans\/.*\.md$|(?:^|\/).*plan.*\.md$/i;
11
+
12
+ export const scopeGuard = {
13
+ name: 'scope-guard',
14
+
15
+ evaluate(ctx, config, stateDir) {
16
+ const filePath = ctx.filePath;
17
+ if (!filePath) return { type: 'pass' };
18
+
19
+ // Plan file created → reset scope (user is decomposing properly)
20
+ if (PLAN_PATTERN.test(filePath)) {
21
+ writeJsonState(stateDir, 'scope-files', []);
22
+ return { type: 'pass', message: '🌈 Prism 📋 Plan file detected. Scope counter reset.' };
23
+ }
24
+
25
+ // Only track source files
26
+ if (!SOURCE_PATTERN.test(filePath)) return { type: 'pass' };
27
+
28
+ // Don't count test files toward scope
29
+ if (TEST_PATTERN.test(filePath)) return { type: 'pass' };
30
+
31
+ // Track unique files
32
+ let files = readJsonState(stateDir, 'scope-files') || [];
33
+ if (!files.includes(filePath)) {
34
+ files.push(filePath);
35
+ writeJsonState(stateDir, 'scope-files', files);
36
+ }
37
+
38
+ const count = files.length;
39
+
40
+ // Agent-aware thresholds: sub-agents get higher limits
41
+ const isAgent = ctx.agentId && ctx.agentId !== '' && ctx.agentId !== 'default';
42
+ const warnAt = isAgent ? (config.agentWarnAt || 8) : (config.warnAt || 4);
43
+ const blockAt = isAgent ? (config.agentBlockAt || 12) : (config.blockAt || 7);
44
+
45
+ if (count >= blockAt) {
46
+ return {
47
+ type: 'block',
48
+ message: `🌈 Prism ✋ Scope Guard: ${count} unique files modified without a plan. Run /prism to decompose before continuing.`
49
+ };
50
+ }
51
+
52
+ if (count >= warnAt) {
53
+ return {
54
+ type: 'warn',
55
+ message: `🌈 Prism > Scope Guard: ${count} unique files modified. Consider running /prism to decompose the task.`
56
+ };
57
+ }
58
+
59
+ return { type: 'pass' };
60
+ }
61
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * claude-prism — Test Tracker
3
+ * Detects test commands and records results for commit-guard
4
+ */
5
+
6
+ import { writeState } from '../lib/state.mjs';
7
+
8
+ const TEST_PATTERNS = [
9
+ /\bnpm\s+test\b/,
10
+ /\bnode\s+--test\b/,
11
+ /\bjest\b/,
12
+ /\bvitest\b/,
13
+ /\bpytest\b/,
14
+ /\bmocha\b/,
15
+ /\bcargo\s+test\b/,
16
+ /\bgo\s+test\b/,
17
+ /\bmake\s+test\b/,
18
+ /\bnpx\s+(jest|vitest|mocha)\b/,
19
+ ];
20
+
21
+ export const testTracker = {
22
+ name: 'test-tracker',
23
+
24
+ evaluate(ctx, config, stateDir) {
25
+ const command = ctx.command || '';
26
+
27
+ const isTestCommand = TEST_PATTERNS.some(p => p.test(command));
28
+ if (!isTestCommand) return { type: 'pass' };
29
+
30
+ // Record timestamp
31
+ const now = Math.floor(Date.now() / 1000);
32
+ writeState(stateDir, 'last-test-run', String(now));
33
+
34
+ // Record result — Claude Code does not provide exitCode,
35
+ // so we infer pass/fail from stdout and interrupted flag
36
+ let passed;
37
+ if (ctx.interrupted) {
38
+ passed = false;
39
+ } 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;
46
+ } else {
47
+ passed = true;
48
+ }
49
+ }
50
+ writeState(stateDir, 'last-test-result', passed ? 'pass' : 'fail');
51
+
52
+ if (!passed) {
53
+ return {
54
+ type: 'warn',
55
+ message: '🌈 Prism 📊 Tests FAILED. Fix before committing.'
56
+ };
57
+ }
58
+
59
+ return { type: 'pass' };
60
+ }
61
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * claude-prism — Claude Code Adapter
3
+ * Translates between Claude Code hook protocol and prism rules
4
+ */
5
+
6
+ import { readFileSync } from 'fs';
7
+ import { sanitizeId } from './utils.mjs';
8
+ import { getHookConfig } from './config.mjs';
9
+ import { getStateDir } from './state.mjs';
10
+
11
+ const TOOL_ACTION_MAP = {
12
+ 'Edit': 'edit',
13
+ 'Write': 'write',
14
+ 'Bash': 'command',
15
+ 'Read': 'read',
16
+ 'Task': 'subagent',
17
+ };
18
+
19
+ const EVENT_PHASE_MAP = {
20
+ 'PreToolUse': 'pre',
21
+ 'PostToolUse': 'post',
22
+ };
23
+
24
+ export function parseInput() {
25
+ try {
26
+ return JSON.parse(readFileSync(0, 'utf8'));
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function toContext(input, hookEventName) {
33
+ const toolName = input.tool_name || '';
34
+ return {
35
+ action: TOOL_ACTION_MAP[toolName] || toolName.toLowerCase(),
36
+ phase: EVENT_PHASE_MAP[hookEventName] || 'pre',
37
+ filePath: input.tool_input?.file_path || undefined,
38
+ command: input.tool_input?.command || undefined,
39
+ oldString: input.tool_input?.old_string || undefined,
40
+ sessionId: sanitizeId(input.session_id),
41
+ agentId: sanitizeId(input.agent_id || ''),
42
+ stdout: input.tool_response?.stdout ?? undefined,
43
+ stderr: input.tool_response?.stderr ?? undefined,
44
+ interrupted: input.tool_response?.interrupted ?? false,
45
+ };
46
+ }
47
+
48
+ export function formatOutput(hookEventName, result) {
49
+ if (result.type === 'pass' && !result.message) return null;
50
+
51
+ const parts = [];
52
+ if (result.message) parts.push(result.message);
53
+ if (parts.length === 0) return null;
54
+
55
+ return JSON.stringify({
56
+ hookSpecificOutput: {
57
+ hookEventName,
58
+ additionalContext: parts.join('\n')
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Run a hook rule via Claude Code protocol
65
+ * @param {string} ruleName
66
+ * @param {string} hookEventName
67
+ * @param {Object} rule - Rule with evaluate() method
68
+ */
69
+ export function runHook(ruleName, hookEventName, rule) {
70
+ const config = getHookConfig(ruleName, process.cwd());
71
+ if (!config.enabled) process.exit(0);
72
+
73
+ const input = parseInput();
74
+ if (!input) process.exit(0);
75
+
76
+ const ctx = toContext(input, hookEventName);
77
+ const stateDir = getStateDir(ctx.sessionId, ctx.agentId);
78
+ const result = rule.evaluate(ctx, config, stateDir);
79
+
80
+ if (result.type === 'block') {
81
+ process.stderr.write(result.message || '🌈 Prism ✋ Action blocked.');
82
+ process.exit(2);
83
+ }
84
+
85
+ const output = formatOutput(hookEventName, result);
86
+ if (output) {
87
+ process.stdout.write(output);
88
+ }
89
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * claude-prism — Configuration Loader
3
+ * Reads .claude-prism.json from project root, with defaults
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+
9
+ const DEFAULTS = {
10
+ language: 'en',
11
+ hooks: {
12
+ 'commit-guard': { enabled: true, maxTestAge: 300 },
13
+ 'debug-loop': { enabled: true, warnAt: 3, blockAt: 5 },
14
+ 'test-tracker': { enabled: true },
15
+ 'scope-guard': { enabled: true, warnAt: 4, blockAt: 7, agentWarnAt: 8, agentBlockAt: 12 }
16
+ }
17
+ };
18
+
19
+ export function loadConfig(projectRoot) {
20
+ const configPath = join(projectRoot, '.claude-prism.json');
21
+
22
+ if (!existsSync(configPath)) {
23
+ return JSON.parse(JSON.stringify(DEFAULTS));
24
+ }
25
+
26
+ try {
27
+ const userConfig = JSON.parse(readFileSync(configPath, 'utf8'));
28
+ return deepMerge(DEFAULTS, userConfig);
29
+ } catch {
30
+ return JSON.parse(JSON.stringify(DEFAULTS));
31
+ }
32
+ }
33
+
34
+ export function getHookConfig(hookName, projectRoot) {
35
+ const config = loadConfig(projectRoot);
36
+ return config.hooks?.[hookName] || DEFAULTS.hooks[hookName] || { enabled: true };
37
+ }
38
+
39
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
40
+
41
+ function deepMerge(target, source) {
42
+ const result = { ...target };
43
+ for (const key of Object.keys(source)) {
44
+ if (DANGEROUS_KEYS.has(key)) continue;
45
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
46
+ result[key] = deepMerge(target[key] || {}, source[key]);
47
+ } else {
48
+ result[key] = source[key];
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+
54
+ export { DEFAULTS };