@albinocrabs/feynman 0.2.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,96 @@
1
+ #!/usr/bin/env node
2
+ // hooks/feynman-lint.js — feynman Stop-hook variant
3
+ // Reads Claude's response from stdin JSON {response, session_id, ...}
4
+ // If lint fails: emit additionalContext with corrections
5
+ // If lint passes: exit 0 silently
6
+ // ALWAYS exits 0 — never block Claude (best practice)
7
+ // Zero deps. CJS only.
8
+ 'use strict';
9
+
10
+ const { lint } = require('../lib/lint');
11
+
12
+ // Rule descriptions for actionable feedback
13
+ const RULE_DESCRIPTIONS = {
14
+ L01: 'Box closure: every ┌─...─┐ opening must have a matching └─...─┘ at the same column',
15
+ L02: 'Tree chars: last child must use └── not ├──',
16
+ L03: 'Arrow style: use only one arrow style per diagram (-->, →, ─→, or ──>)',
17
+ L04: 'Column widths: all table rows must have the same number of columns',
18
+ L05: 'Flow integrity: two [Box] tokens on the same line must have an arrow between them',
19
+ L06: 'Priority scale: if ▲ appears, ▼ must also appear (and vice versa)',
20
+ L07: 'Mermaid+ASCII mix: use either Mermaid or ASCII diagrams, not both in the same response',
21
+ L08: 'Frame width: all rows inside a ┌─ frame must have the same display width',
22
+ };
23
+
24
+ /**
25
+ * Build a concise additionalContext message from lint issues
26
+ * @param {object[]} issues
27
+ * @returns {string}
28
+ */
29
+ function buildCorrectionMessage(issues) {
30
+ if (!issues || issues.length === 0) return '';
31
+
32
+ // Group by rule
33
+ const byRule = new Map();
34
+ for (const iss of issues) {
35
+ if (!byRule.has(iss.rule)) byRule.set(iss.rule, []);
36
+ byRule.get(iss.rule).push(iss);
37
+ }
38
+
39
+ const lines = [
40
+ 'DIAGRAM LINT CORRECTIONS NEEDED:',
41
+ '',
42
+ ];
43
+
44
+ for (const [rule, ruleIssues] of byRule) {
45
+ const desc = RULE_DESCRIPTIONS[rule] || rule;
46
+ lines.push(`[${rule}] ${desc}`);
47
+ for (const iss of ruleIssues) {
48
+ lines.push(` - Line ${iss.line}: ${iss.message}`);
49
+ if (iss.suggestion) lines.push(` Fix: ${iss.suggestion}`);
50
+ }
51
+ lines.push('');
52
+ }
53
+
54
+ lines.push('Please correct the ASCII diagram(s) above before responding further.');
55
+
56
+ return lines.join('\n');
57
+ }
58
+
59
+ // Accumulate stdin
60
+ let input = '';
61
+ process.stdin.setEncoding('utf8');
62
+ process.stdin.on('data', chunk => { input += chunk; });
63
+ process.stdin.on('end', () => {
64
+ try {
65
+ const data = JSON.parse(input);
66
+ const response = data.response || data.message || '';
67
+
68
+ if (!response || typeof response !== 'string') {
69
+ process.exit(0);
70
+ }
71
+
72
+ const result = lint(response);
73
+
74
+ if (result.passed) {
75
+ // No errors — exit 0 silently
76
+ process.exit(0);
77
+ }
78
+
79
+ // Build correction message
80
+ const correctionText = buildCorrectionMessage(result.issues);
81
+
82
+ // Emit Stop-hook output
83
+ process.stdout.write(JSON.stringify({
84
+ hookSpecificOutput: {
85
+ hookEventName: 'Stop',
86
+ additionalContext: correctionText
87
+ }
88
+ }));
89
+
90
+ // Always exit 0 — never block Claude
91
+ process.exit(0);
92
+ } catch (e) {
93
+ // Silent fail — never surface hook errors
94
+ process.exit(0);
95
+ }
96
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "description": "Inject feynman ASCII diagram rules before each Claude Code prompt.",
3
+ "hooks": {
4
+ "UserPromptSubmit": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "FEYNMAN_HOME=\"$HOME/.claude\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/feynman-activate.js\"",
10
+ "timeout": 5,
11
+ "statusMessage": "Injecting diagram rules..."
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }
package/hooks.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "PLUGIN_ROOT=\"${PLUGIN_ROOT:-$CLAUDE_PLUGIN_ROOT}\"; FEYNMAN_HOME=\"$HOME/.codex\" node \"$PLUGIN_ROOT/hooks/feynman-activate.js\"",
9
+ "timeout": 5,
10
+ "statusMessage": "Injecting diagram rules..."
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
package/install.sh ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env bash
2
+ # feynman install — thin wrapper around bin/feynman.js
3
+ # Usage: bash install.sh [--force]
4
+ set -e
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+
7
+ # Node >= 18 check
8
+ if ! command -v node >/dev/null 2>&1; then
9
+ echo "Error: node not found. Install Node.js >=18: https://nodejs.org" >&2
10
+ exit 1
11
+ fi
12
+ NODE_VER=$(node -v 2>/dev/null | sed 's/v//' | cut -d. -f1)
13
+ if [ -z "$NODE_VER" ] || [ "$NODE_VER" -lt 18 ] 2>/dev/null; then
14
+ echo "Error: Node.js >=18 required (found $(node -v 2>/dev/null || echo 'unknown'))" >&2
15
+ exit 1
16
+ fi
17
+
18
+ exec node "$SCRIPT_DIR/bin/feynman.js" install "$@"
@@ -0,0 +1,100 @@
1
+ // lib/lint/index.js — diagram linter orchestrator
2
+ // lint(markdown, options) => {issues, passed}
3
+ // format(issues, mode) => string
4
+ // Zero deps. CJS only.
5
+ 'use strict';
6
+
7
+ const { parse } = require('./parser');
8
+ const rules = require('./rules');
9
+
10
+ /**
11
+ * Lint a markdown string for ASCII diagram issues.
12
+ * @param {string} markdown
13
+ * @param {{rules?: string[]}} [options] - optional rule filter
14
+ * @returns {{issues: object[], passed: boolean}}
15
+ */
16
+ function lint(markdown, options) {
17
+ if (typeof markdown !== 'string') {
18
+ return { issues: [], passed: true };
19
+ }
20
+
21
+ const enabledRules = (options && options.rules) || null;
22
+
23
+ function isEnabled(ruleId) {
24
+ if (!enabledRules) return true;
25
+ return enabledRules.includes(ruleId);
26
+ }
27
+
28
+ const ast = parse(markdown);
29
+ const allIssues = [];
30
+
31
+ // Per-node rules
32
+ const perNodeRules = [
33
+ { id: 'L01', fn: rules.L01_box_closure },
34
+ { id: 'L02', fn: rules.L02_tree_chars },
35
+ { id: 'L03', fn: rules.L03_arrow_style },
36
+ { id: 'L04', fn: rules.L04_column_widths },
37
+ { id: 'L05', fn: rules.L05_flow_integrity },
38
+ { id: 'L06', fn: rules.L06_priority_scale },
39
+ { id: 'L08', fn: rules.L08_frame_width },
40
+ ];
41
+
42
+ for (const node of ast) {
43
+ for (const { id, fn } of perNodeRules) {
44
+ if (!isEnabled(id)) continue;
45
+ try {
46
+ const nodeIssues = fn(node, markdown);
47
+ if (Array.isArray(nodeIssues)) allIssues.push(...nodeIssues);
48
+ } catch (e) {
49
+ // Rule threw — skip silently (never crash linter)
50
+ }
51
+ }
52
+ }
53
+
54
+ // Full-text rules (L07 operates once on full markdown, not per-node)
55
+ if (isEnabled('L07')) {
56
+ try {
57
+ const l07Issues = rules.L07_no_mermaid_mix(null, markdown);
58
+ if (Array.isArray(l07Issues)) allIssues.push(...l07Issues);
59
+ } catch (e) {
60
+ // Skip silently
61
+ }
62
+ }
63
+
64
+ const errorCount = allIssues.filter(i => i.severity === 'error').length;
65
+ const passed = errorCount === 0;
66
+
67
+ return { issues: allIssues, passed };
68
+ }
69
+
70
+ /**
71
+ * Format issues for output.
72
+ * @param {object[]} issues
73
+ * @param {'gcc'|'json'} mode
74
+ * @param {string} [filename] - for gcc mode
75
+ * @param {boolean} [useColor] - ANSI color for TTY
76
+ * @returns {string}
77
+ */
78
+ function format(issues, mode, filename, useColor) {
79
+ if (mode === 'json') {
80
+ return JSON.stringify(issues, null, 2);
81
+ }
82
+
83
+ // gcc mode: <file>:<line>:<col>: L0X severity message
84
+ if (!issues || issues.length === 0) return '';
85
+
86
+ const RESET = useColor ? '\x1b[0m' : '';
87
+ const RED = useColor ? '\x1b[31m' : '';
88
+ const YELLOW = useColor ? '\x1b[33m' : '';
89
+ const BOLD = useColor ? '\x1b[1m' : '';
90
+
91
+ const file = filename || '<input>';
92
+
93
+ return issues.map(iss => {
94
+ const color = iss.severity === 'error' ? RED : YELLOW;
95
+ const sev = iss.severity === 'error' ? 'error' : 'warn';
96
+ return `${file}:${iss.line}:${iss.column}: ${color}${BOLD}${iss.rule} ${sev}${RESET} ${iss.message}`;
97
+ }).join('\n');
98
+ }
99
+
100
+ module.exports = { lint, format };
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ // lib/lint/parser.js — ASCII diagram block parser
3
+ // Returns AST: [{type:'diagram', content, startLine, endLine, indent}]
4
+ // Detection: ``` fences (generic only) OR standalone blocks ≥30% diagram chars, ≥3 lines
5
+ // Zero deps. CJS only.
6
+ 'use strict';
7
+
8
+ // Characters that indicate ASCII diagram content
9
+ // Box-drawing: ┌┐└┘─│├┤┬┴┼ Tree: ├── └── Arrows: →←↑↓▲▼
10
+ // Also count: + - | < > brackets used in ASCII art context
11
+ const DIAGRAM_CHARS = new Set([
12
+ '┌', '┐', '└', '┘', '─', '│', '├', '┤', '┬', '┴', '┼',
13
+ '→', '←', '↑', '↓', '▲', '▼',
14
+ '+', '-', '|'
15
+ ]);
16
+
17
+ // Box-drawing Unicode specifically (high-confidence indicators)
18
+ const BOX_DRAWING_CHARS = new Set([
19
+ '┌', '┐', '└', '┘', '─', '│', '├', '┤', '┬', '┴', '┼',
20
+ '→', '←', '↑', '↓', '▲', '▼'
21
+ ]);
22
+
23
+ /**
24
+ * Count diagram chars in a string (non-space chars only for ratio)
25
+ * @param {string} text
26
+ * @returns {{diagramCount: number, nonSpaceCount: number, hasBoxDrawing: boolean}}
27
+ */
28
+ function countDiagramChars(text) {
29
+ let diagramCount = 0;
30
+ let nonSpaceCount = 0;
31
+ let hasBoxDrawing = false;
32
+
33
+ for (const ch of text) {
34
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') continue;
35
+ nonSpaceCount++;
36
+ if (DIAGRAM_CHARS.has(ch)) diagramCount++;
37
+ if (BOX_DRAWING_CHARS.has(ch)) hasBoxDrawing = true;
38
+ }
39
+ return { diagramCount, nonSpaceCount, hasBoxDrawing };
40
+ }
41
+
42
+ /**
43
+ * Check if a block of text looks like a diagram.
44
+ * Heuristic: box-drawing Unicode dominant OR structural pattern (flow/tree/table/priority)
45
+ * Explicitly excludes prose lines where box-chars appear incidentally in sentences.
46
+ * @param {string[]} lines
47
+ * @returns {boolean}
48
+ */
49
+ function looksLikeDiagram(lines) {
50
+ const text = lines.join('\n');
51
+ const { nonSpaceCount, hasBoxDrawing } = countDiagramChars(text);
52
+
53
+ // Must have minimum content
54
+ if (nonSpaceCount < 3) return false;
55
+
56
+ // If any line looks like natural prose (has word chars AND box chars but ratio is low)
57
+ // then exclude it from standalone detection.
58
+ // Prose check: line has alphabetic words AND box chars but < 20% diagram chars overall
59
+ const isProse = lines.every(line => {
60
+ const trimmed = line.trim();
61
+ if (!trimmed) return true;
62
+ // If the line has natural language (multiple word chars) and any box chars
63
+ // but the box chars are embedded in prose (not structural)
64
+ const wordChars = (trimmed.match(/[a-zA-Z]/g) || []).length;
65
+ const boxChars = [...trimmed].filter(ch => BOX_DRAWING_CHARS.has(ch)).length;
66
+ // Prose: more letters than box chars by a significant margin
67
+ if (wordChars > 5 && boxChars > 0 && wordChars > boxChars * 3) return true;
68
+ return !trimmed; // blank lines are neutral
69
+ });
70
+
71
+ // For standalone blocks (not fenced), require structural indicators
72
+ // Box-drawing Unicode dominant (≥40% of non-space chars are box-drawing)
73
+ const boxDrawingCount = [...text].filter(ch => BOX_DRAWING_CHARS.has(ch)).length;
74
+ const boxRatio = nonSpaceCount > 0 ? boxDrawingCount / nonSpaceCount : 0;
75
+
76
+ if (hasBoxDrawing && boxRatio >= 0.15 && !isProse) return true;
77
+
78
+ // Check for structural patterns (these override prose check)
79
+ // Tree structure: lines starting with ├── or └──
80
+ if (/^[\s│]*[├└]──/m.test(text)) return true;
81
+
82
+ // Flow diagram: [Box] connected with arrows
83
+ if (/\[[^\]]+\]/.test(text) && /-->|→|─→|──>/.test(text)) return true;
84
+
85
+ // Priority scale: ▲ or ▼ on their own
86
+ if (/^[\s]*[▲▼]/m.test(text)) return true;
87
+
88
+ // Table: multiple lines starting with |
89
+ const tableRows = lines.filter(l => /^\s*\|.*\|/.test(l));
90
+ if (tableRows.length >= 2) return true;
91
+
92
+ return false;
93
+ }
94
+
95
+ /**
96
+ * Get leading indent (number of spaces) of a line
97
+ * @param {string} line
98
+ * @returns {number}
99
+ */
100
+ function getIndent(line) {
101
+ let i = 0;
102
+ while (i < line.length && line[i] === ' ') i++;
103
+ return i;
104
+ }
105
+
106
+ /**
107
+ * Parse markdown string into AST of diagram nodes.
108
+ * @param {string} markdown
109
+ * @returns {Array<{type: 'diagram', content: string, startLine: number, endLine: number, indent: number}>}
110
+ */
111
+ function parse(markdown) {
112
+ const lines = markdown.split('\n');
113
+ const nodes = [];
114
+
115
+ let i = 0;
116
+ while (i < lines.length) {
117
+ const line = lines[i];
118
+ const trimmed = line.trim();
119
+
120
+ // Check for fenced code block: ```
121
+ const fenceMatch = trimmed.match(/^```(\w*)$/);
122
+ if (fenceMatch) {
123
+ const lang = fenceMatch[1].toLowerCase();
124
+
125
+ // Skip named language blocks (js, bash, python, mermaid, etc.)
126
+ // Only process generic ``` fences (empty lang tag)
127
+ if (lang !== '') {
128
+ // Advance to closing fence, skip this block
129
+ i++;
130
+ while (i < lines.length && lines[i].trim() !== '```') i++;
131
+ i++; // skip closing ```
132
+ continue;
133
+ }
134
+
135
+ // Generic fence — collect content
136
+ const indent = getIndent(line);
137
+ const blockLines = [];
138
+ i++; // move past opening fence
139
+ const startLine = i + 1; // 1-based line number of first content line
140
+ while (i < lines.length && lines[i].trim() !== '```') {
141
+ blockLines.push(lines[i]);
142
+ i++;
143
+ }
144
+ const endLine = i + 1; // 1-based (points to closing ```)
145
+ i++; // skip closing ```
146
+
147
+ // Check if it looks like a diagram
148
+ // Fenced blocks: also accept multiple [Box] tokens (even without arrows)
149
+ // since those are clearly intended as flow diagrams (L05 will catch missing arrows)
150
+ const hasFencedBoxPattern = blockLines.some(line => {
151
+ const boxes = [...line.matchAll(/\[[^\]]+\]/g)];
152
+ return boxes.length >= 2;
153
+ });
154
+ if (blockLines.length >= 1 && (looksLikeDiagram(blockLines) || hasFencedBoxPattern)) {
155
+ nodes.push({
156
+ type: 'diagram',
157
+ content: blockLines.join('\n'),
158
+ startLine,
159
+ endLine,
160
+ indent
161
+ });
162
+ }
163
+ continue;
164
+ }
165
+
166
+ // Standalone block detection: accumulate consecutive non-empty lines
167
+ // that collectively look like a diagram
168
+ if (trimmed.length > 0 && !trimmed.startsWith('#') && !trimmed.startsWith('<!--')) {
169
+ const startLine = i + 1; // 1-based
170
+ const indent = getIndent(line);
171
+ const blockLines = [line];
172
+ let j = i + 1;
173
+
174
+ // Extend block: include lines until we hit blank or markdown heading/fence
175
+ while (j < lines.length) {
176
+ const nextLine = lines[j];
177
+ const nextTrimmed = nextLine.trim();
178
+
179
+ // Stop at blank lines, headings, or fences
180
+ if (nextTrimmed === '' || nextTrimmed.startsWith('#') || nextTrimmed.startsWith('```')) break;
181
+ blockLines.push(nextLine);
182
+ j++;
183
+ }
184
+
185
+ // Check for structural diagram indicators (order matters — strongest first)
186
+ const text = blockLines.join('\n');
187
+ const { hasBoxDrawing } = countDiagramChars(text);
188
+ const hasBoxToken = /\[[^\]]+\]/.test(text) && /-->|→|─>|──>/.test(text);
189
+ // Multiple [Box] tokens on one line without natural language between them
190
+ // (even without arrows — L05 will catch missing arrows)
191
+ // "Natural language between" means: word chars NOT in brackets between the boxes
192
+ const hasMultiBox = blockLines.some(l => {
193
+ const matches = [...l.matchAll(/\[[^\]]+\]/g)];
194
+ if (matches.length < 2) return false;
195
+ // Check what's between the first and last box
196
+ const firstEnd = matches[0].index + matches[0][0].length;
197
+ const lastStart = matches[matches.length - 1].index;
198
+ const between = l.slice(firstEnd, lastStart);
199
+ // If "between" contains regular English words (sequences of alpha), it's prose
200
+ // Allow: spaces, arrows, punctuation, special chars
201
+ if (/[a-zA-Z]{3,}/.test(between)) return false; // prose words between boxes
202
+ return true;
203
+ });
204
+ const hasTree = /[├└]──/.test(text);
205
+ const hasPriority = /^[\s]*[▲▼]/m.test(text);
206
+ const hasTable = blockLines.filter(l => /^\s*\|.*\|/.test(l)).length >= 2;
207
+
208
+ const isDiagram = hasBoxDrawing || hasBoxToken || hasMultiBox || hasTree || hasPriority || hasTable;
209
+
210
+ if (isDiagram && (looksLikeDiagram(blockLines) || hasMultiBox || hasTree || hasPriority || hasTable)) {
211
+ nodes.push({
212
+ type: 'diagram',
213
+ content: text,
214
+ startLine,
215
+ endLine: j, // 1-based (last line index + 1)
216
+ indent
217
+ });
218
+ i = j;
219
+ continue;
220
+ }
221
+ }
222
+
223
+ i++;
224
+ }
225
+
226
+ return nodes;
227
+ }
228
+
229
+ module.exports = { parse };