@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.
package/bin/feynman.js ADDED
@@ -0,0 +1,559 @@
1
+ #!/usr/bin/env node
2
+ // bin/feynman.js — feynman unified CLI
3
+ // Subcommands: install, uninstall, doctor, lint, version, help
4
+ // Zero runtime deps. CJS only. Node >= 18.
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
12
+
13
+ const NO_COLOR = !!process.env.NO_COLOR;
14
+ const c = {
15
+ bold: s => NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`,
16
+ green: s => NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`,
17
+ red: s => NO_COLOR ? s : `\x1b[31m${s}\x1b[0m`,
18
+ dim: s => NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`,
19
+ };
20
+
21
+ // ─── Constants ────────────────────────────────────────────────────────────────
22
+
23
+ const PKG = require('../package.json');
24
+ const VERSION = PKG.version;
25
+
26
+ // Resolve paths using os.homedir() — never tilde literal (bug #8810)
27
+ const HOME = os.homedir();
28
+
29
+ // Hook script lives relative to this file
30
+ const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'feynman-activate.js');
31
+ const RULES_PATH = path.resolve(__dirname, '..', 'rules', 'feynman-activate.md');
32
+
33
+ const DEFAULT_STATE = { enabled: true, intensity: 'full', injections: 0 };
34
+ const VALID_TARGETS = ['claude', 'codex', 'both'];
35
+
36
+ function targetConfig(name) {
37
+ const dirName = name === 'codex' ? '.codex' : '.claude';
38
+ const rootDir = path.join(HOME, dirName);
39
+ return {
40
+ name,
41
+ label: name === 'codex' ? 'Codex' : 'Claude Code',
42
+ rootDir,
43
+ settingsPath: path.join(rootDir, name === 'codex' ? 'hooks.json' : 'settings.json'),
44
+ feynmanDir: path.join(rootDir, '.feynman'),
45
+ statePath: path.join(rootDir, '.feynman', 'state.json'),
46
+ flagPath: path.join(rootDir, '.feynman-active'),
47
+ commandsDir: name === 'claude' ? path.join(rootDir, 'commands') : null,
48
+ };
49
+ }
50
+
51
+ function targetNames(target) {
52
+ return target === 'both' ? ['claude', 'codex'] : [target];
53
+ }
54
+
55
+ function parseTarget(args, fallback = 'claude') {
56
+ let target = fallback;
57
+ const keep = [];
58
+ for (let i = 0; i < args.length; i++) {
59
+ const arg = args[i];
60
+ if (arg === '--target') {
61
+ target = args[++i];
62
+ } else if (arg.startsWith('--target=')) {
63
+ target = arg.slice('--target='.length);
64
+ } else {
65
+ keep.push(arg);
66
+ }
67
+ }
68
+ if (!VALID_TARGETS.includes(target)) {
69
+ console.error(`feynman: invalid --target '${target}' (expected claude, codex, or both)`);
70
+ process.exit(2);
71
+ }
72
+ return { target, args: keep };
73
+ }
74
+
75
+ function hookCommandFor(target) {
76
+ const cfg = targetConfig(target);
77
+ return `FEYNMAN_HOME="${cfg.rootDir}" node "${HOOK_PATH}"`;
78
+ }
79
+
80
+ // ─── Help text ────────────────────────────────────────────────────────────────
81
+
82
+ const HELP = `${c.bold('feynman')} v${VERSION} — auto-inject ASCII diagram rules into Claude Code and Codex
83
+
84
+ ${c.bold('Usage:')}
85
+ feynman <command> [options]
86
+
87
+ ${c.bold('Commands:')}
88
+ install Register feynman hook
89
+ uninstall Remove feynman hook (state preserved)
90
+ doctor Check installation health
91
+ lint <file> Lint a markdown file for diagram rule violations
92
+ version Print version number
93
+ help Show this help
94
+
95
+ ${c.bold('Options:')}
96
+ --help, -h Show help for a command
97
+ --target claude | codex | both (default: claude)
98
+ --force (install) Re-register even if already installed
99
+
100
+ ${c.bold('Examples:')}
101
+ npx feynman install
102
+ npx feynman install --target codex
103
+ npx feynman install --target both
104
+ npx feynman doctor
105
+ feynman lint response.md
106
+ feynman uninstall
107
+ `;
108
+
109
+ const INSTALL_HELP = `${c.bold('feynman install')} — register feynman hook
110
+
111
+ ${c.bold('Usage:')}
112
+ feynman install [--target claude|codex|both] [--force]
113
+
114
+ ${c.bold('Options:')}
115
+ --target Install into Claude Code, Codex, or both (default: claude)
116
+ --force Re-register hook even if already installed
117
+
118
+ Claude creates:
119
+ ~/.claude/.feynman/state.json — feynman state (enabled, intensity, injections)
120
+ ~/.claude/.feynman-active — presence flag
121
+
122
+ Codex creates:
123
+ ~/.codex/hooks.json — UserPromptSubmit hook registration
124
+ ~/.codex/.feynman/state.json — feynman state (enabled, intensity, injections)
125
+ ~/.codex/.feynman-active — presence flag
126
+
127
+ Idempotent by default: skips if feynman-activate.js entry already exists.
128
+ `;
129
+
130
+ const UNINSTALL_HELP = `${c.bold('feynman uninstall')} — remove feynman hook
131
+
132
+ ${c.bold('Usage:')}
133
+ feynman uninstall [--target claude|codex|both]
134
+
135
+ Removes feynman hook entries from target config.
136
+ Preserves .feynman/state.json (user data).
137
+ Removes .feynman-active flag.
138
+
139
+ Idempotent: safe to run multiple times.
140
+ `;
141
+
142
+ const DOCTOR_HELP = `${c.bold('feynman doctor')} — check feynman installation health
143
+
144
+ ${c.bold('Usage:')}
145
+ feynman doctor [--target claude|codex]
146
+
147
+ Checks:
148
+ 1. target hook config present
149
+ 2. UserPromptSubmit hook references feynman-activate.js
150
+ 3. Hook script file exists and is readable
151
+ 4. Rules file exists and is non-empty
152
+ 5. state.json valid JSON with enabled field
153
+ 6. .feynman-active flag present
154
+ 7. (INFO) lint hook registered (optional)
155
+
156
+ Exit code: always 0 (advisory only).
157
+ `;
158
+
159
+ const LINT_HELP = `${c.bold('feynman lint')} — lint a markdown file for ASCII diagram rule violations
160
+
161
+ ${c.bold('Usage:')}
162
+ feynman lint <file.md>
163
+ feynman lint - (read from stdin)
164
+ feynman lint --json <file>
165
+ feynman lint --strict <file>
166
+
167
+ ${c.bold('Options:')}
168
+ --json Output issues as JSON
169
+ --strict Treat warnings as errors (exit 1 on any issue)
170
+
171
+ Delegates to bin/feynman-lint.js. See feynman-lint --help for full docs.
172
+
173
+ Exit codes:
174
+ 0 No errors
175
+ 1 Lint failure
176
+ 2 Usage error
177
+ `;
178
+
179
+ const VERSION_HELP = `${c.bold('feynman version')} — print version
180
+
181
+ ${c.bold('Usage:')}
182
+ feynman version
183
+
184
+ Prints: ${VERSION}
185
+ `;
186
+
187
+ // ─── Settings helpers ─────────────────────────────────────────────────────────
188
+
189
+ function readSettings(target) {
190
+ const cfg = targetConfig(target);
191
+ try {
192
+ return JSON.parse(fs.readFileSync(cfg.settingsPath, 'utf8'));
193
+ } catch (_) {
194
+ return {};
195
+ }
196
+ }
197
+
198
+ function writeSettings(target, settings) {
199
+ const cfg = targetConfig(target);
200
+ fs.mkdirSync(cfg.rootDir, { recursive: true });
201
+ fs.writeFileSync(cfg.settingsPath, JSON.stringify(settings, null, 2) + '\n');
202
+ }
203
+
204
+ function hasFeynmanHook(settings) {
205
+ return ((settings.hooks && settings.hooks.UserPromptSubmit) || []).some(g =>
206
+ g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-activate.js'))
207
+ );
208
+ }
209
+
210
+ function removeFeynmanHooks(settings) {
211
+ if (!settings.hooks || !Array.isArray(settings.hooks.UserPromptSubmit)) return settings;
212
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(g =>
213
+ !(g.hooks && g.hooks.some(h =>
214
+ h.command && (
215
+ h.command.includes('feynman-activate.js') ||
216
+ h.command.includes('feynman-lint.js')
217
+ )
218
+ ))
219
+ );
220
+ if (settings.hooks.UserPromptSubmit.length === 0) {
221
+ delete settings.hooks.UserPromptSubmit;
222
+ }
223
+ if (Object.keys(settings.hooks).length === 0) {
224
+ delete settings.hooks;
225
+ }
226
+ return settings;
227
+ }
228
+
229
+ function bootstrapState(target) {
230
+ const cfg = targetConfig(target);
231
+ fs.mkdirSync(cfg.feynmanDir, { recursive: true });
232
+ if (!fs.existsSync(cfg.statePath)) {
233
+ fs.writeFileSync(cfg.statePath, JSON.stringify(DEFAULT_STATE, null, 2) + '\n');
234
+ }
235
+ fs.writeFileSync(cfg.flagPath, DEFAULT_STATE.intensity);
236
+ }
237
+
238
+ function installClaudeCommand() {
239
+ const cfg = targetConfig('claude');
240
+ const skillSrc = path.resolve(__dirname, '..', 'skills', 'feynman', 'SKILL.md');
241
+ const commandDest = path.join(cfg.commandsDir, 'feynman.md');
242
+ if (fs.existsSync(skillSrc)) {
243
+ fs.mkdirSync(cfg.commandsDir, { recursive: true });
244
+ if (!fs.existsSync(commandDest)) {
245
+ fs.copyFileSync(skillSrc, commandDest);
246
+ }
247
+ }
248
+ }
249
+
250
+ // ─── Install ──────────────────────────────────────────────────────────────────
251
+
252
+ function installOne(target, opts) {
253
+ const force = opts.force || false;
254
+ const tc = targetConfig(target);
255
+
256
+ // Read or create settings
257
+ const cfg = readSettings(target);
258
+ cfg.hooks = cfg.hooks || {};
259
+ cfg.hooks.UserPromptSubmit = cfg.hooks.UserPromptSubmit || [];
260
+
261
+ // Idempotency check
262
+ const already = hasFeynmanHook(cfg);
263
+
264
+ if (already && !force) {
265
+ bootstrapState(target);
266
+ if (target === 'claude') installClaudeCommand();
267
+ return { target, already: true };
268
+ }
269
+
270
+ // If force + already, remove old entry first
271
+ if (already && force) {
272
+ removeFeynmanHooks(cfg);
273
+ cfg.hooks = cfg.hooks || {};
274
+ cfg.hooks.UserPromptSubmit = cfg.hooks.UserPromptSubmit || [];
275
+ }
276
+
277
+ // Append hook entry
278
+ cfg.hooks.UserPromptSubmit.push({
279
+ hooks: [{
280
+ type: 'command',
281
+ command: hookCommandFor(target),
282
+ timeout: 5,
283
+ statusMessage: 'Injecting diagram rules...',
284
+ }]
285
+ });
286
+
287
+ // Write settings
288
+ writeSettings(target, cfg);
289
+
290
+ // Bootstrap state dir + state.json
291
+ bootstrapState(target);
292
+
293
+ // Install /feynman command to ~/.claude/commands/ (preserves user's existing skill)
294
+ if (target === 'claude') installClaudeCommand();
295
+
296
+ return { target, already: false, tc };
297
+ }
298
+
299
+ function cmdInstall(opts) {
300
+ const results = targetNames(opts.target).map(t => installOne(t, opts));
301
+
302
+ if (results.every(r => r.already)) {
303
+ const labels = results.map(r => targetConfig(r.target).label).join(' + ');
304
+ console.log(`hook: already installed (${labels})`);
305
+ process.exit(0);
306
+ }
307
+
308
+ // Print status frame
309
+ console.log('');
310
+ console.log('┌─ feynman installed ──────────────────────────────────────────┐');
311
+ console.log(`│ hook: ${HOOK_PATH}`);
312
+ for (const result of results) {
313
+ const tc = targetConfig(result.target);
314
+ console.log(`│ target: ${tc.label}${result.already ? ' (already installed)' : ''}`);
315
+ console.log(`│ config: ${tc.settingsPath.replace(HOME, '~')}`);
316
+ console.log(`│ state: ${tc.statePath.replace(HOME, '~')}`);
317
+ console.log(`│ flag: ${tc.flagPath.replace(HOME, '~')}`);
318
+ }
319
+ console.log('└──────────────────────────────────────────────────────────────┘');
320
+ console.log('');
321
+ console.log('Restart Claude Code or Codex to activate feynman.');
322
+
323
+ process.exit(0);
324
+ }
325
+
326
+ // ─── Uninstall ────────────────────────────────────────────────────────────────
327
+
328
+ function uninstallOne(target) {
329
+ const tc = targetConfig(target);
330
+ if (!fs.existsSync(tc.settingsPath)) {
331
+ if (fs.existsSync(tc.flagPath)) fs.unlinkSync(tc.flagPath);
332
+ return { target, missing: true };
333
+ }
334
+
335
+ const cfg = readSettings(target);
336
+ const hadHook = hasFeynmanHook(cfg);
337
+ removeFeynmanHooks(cfg);
338
+ writeSettings(target, cfg);
339
+
340
+ // Remove flag file (NOT state.json — user data per D-11)
341
+ if (fs.existsSync(tc.flagPath)) {
342
+ fs.unlinkSync(tc.flagPath);
343
+ }
344
+
345
+ return { target, missing: false, hadHook };
346
+ }
347
+
348
+ function cmdUninstall(opts) {
349
+ const results = targetNames(opts.target).map(uninstallOne);
350
+ const labels = results.map(r => targetConfig(r.target).label).join(' + ');
351
+ if (results.every(r => r.missing || !r.hadHook)) {
352
+ console.log(`feynman: no hook found for ${labels} — nothing to uninstall.`);
353
+ } else {
354
+ console.log(`feynman disabled for ${labels}. State preserved. Re-enable: npx feynman install --target ${opts.target}`);
355
+ }
356
+ process.exit(0);
357
+ }
358
+
359
+ // ─── Doctor ───────────────────────────────────────────────────────────────────
360
+
361
+ function cmdDoctor(opts = {}) {
362
+ const target = opts.target || 'claude';
363
+ if (target === 'both') {
364
+ targetNames(target).forEach(t => cmdDoctor({ target: t, noExit: true }));
365
+ process.exit(0);
366
+ }
367
+ const tc = targetConfig(target);
368
+ const checks = [];
369
+ let failCount = 0;
370
+
371
+ function check(label, pass, info = false) {
372
+ const marker = info ? '[INFO]' : (pass ? '[OK] ' : '[FAIL]');
373
+ const colorFn = info ? c.dim : (pass ? c.green : c.red);
374
+ checks.push(colorFn(`${marker} ${label}`));
375
+ if (!info && !pass) failCount++;
376
+ }
377
+
378
+ // 1. settings/hooks config exists
379
+ const settingsExists = fs.existsSync(tc.settingsPath);
380
+ check(`${tc.settingsPath.replace(HOME, '~')} present`, settingsExists);
381
+
382
+ // 2. UserPromptSubmit hook references feynman-activate.js
383
+ let hookRegistered = false;
384
+ let hookAbsPath = null;
385
+ if (settingsExists) {
386
+ const cfg = readSettings(target);
387
+ const entries = (cfg.hooks && cfg.hooks.UserPromptSubmit) || [];
388
+ const feynmanEntry = entries.find(g =>
389
+ g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-activate.js'))
390
+ );
391
+ hookRegistered = !!feynmanEntry;
392
+ if (feynmanEntry) {
393
+ // Extract the path from command: node "/abs/path/to/feynman-activate.js"
394
+ const hookCmd = feynmanEntry.hooks.find(h => h.command && h.command.includes('feynman-activate.js')).command;
395
+ const match = hookCmd.match(/"([^"]+feynman-activate\.js)"/);
396
+ if (match) hookAbsPath = match[1];
397
+ }
398
+ }
399
+ check('hook registered (feynman-activate.js in UserPromptSubmit)', hookRegistered);
400
+
401
+ // 3. Hook script file exists + readable
402
+ let hookFileOk = false;
403
+ if (hookAbsPath) {
404
+ try {
405
+ fs.accessSync(hookAbsPath, fs.constants.R_OK);
406
+ hookFileOk = true;
407
+ } catch (_) {}
408
+ } else if (hookRegistered) {
409
+ // Hook registered but path extraction failed — check default location
410
+ try {
411
+ fs.accessSync(HOOK_PATH, fs.constants.R_OK);
412
+ hookFileOk = true;
413
+ } catch (_) {}
414
+ }
415
+ check('hook script file exists and is readable', hookFileOk);
416
+
417
+ // 4. Rules file exists + non-empty
418
+ let rulesOk = false;
419
+ try {
420
+ const stat = fs.statSync(RULES_PATH);
421
+ rulesOk = stat.size > 0;
422
+ } catch (_) {}
423
+ check('rules/feynman-activate.md exists and non-empty', rulesOk);
424
+
425
+ // 5. state.json valid JSON + has enabled field
426
+ let stateOk = false;
427
+ try {
428
+ const state = JSON.parse(fs.readFileSync(tc.statePath, 'utf8'));
429
+ stateOk = 'enabled' in state;
430
+ } catch (_) {}
431
+ check('state.json valid (has enabled field)', stateOk);
432
+
433
+ // 6. .feynman-active flag present
434
+ const flagPresent = fs.existsSync(tc.flagPath);
435
+ check('.feynman-active flag present', flagPresent);
436
+
437
+ // 7. (INFO) lint hook registered
438
+ let lintHookRegistered = false;
439
+ if (settingsExists) {
440
+ const cfg = readSettings(target);
441
+ // Check Stop hooks too
442
+ const allHookGroups = [
443
+ ...((cfg.hooks && cfg.hooks.UserPromptSubmit) || []),
444
+ ...((cfg.hooks && cfg.hooks.Stop) || []),
445
+ ];
446
+ lintHookRegistered = allHookGroups.some(g =>
447
+ g.hooks && g.hooks.some(h => h.command && h.command.includes('feynman-lint.js'))
448
+ );
449
+ }
450
+ const lintStatus = lintHookRegistered ? 'registered' : 'not registered (optional)';
451
+ check(`lint hook: ${lintStatus}`, true, true); // always INFO
452
+
453
+ // Compute frame width from longest stripped line (innerW = max content + 2 for "│ " and " │")
454
+ const strippedLines = checks.map(l => l.replace(/\x1b\[[0-9;]*m/g, ''));
455
+ const maxLen = Math.max(...strippedLines.map(l => l.length));
456
+ const innerW = Math.max(maxLen + 2, 48); // +2 for one space on each side; min 48
457
+ const titlePart = `feynman doctor ${target} `;
458
+ const border = '─'.repeat(innerW);
459
+ const topDashes = '─'.repeat(innerW - titlePart.length - 1);
460
+ console.log(`┌─ ${titlePart}${topDashes}┐`);
461
+ for (let i = 0; i < checks.length; i++) {
462
+ const stripped = strippedLines[i];
463
+ const pad = innerW - 1 - stripped.length; // 1 for leading space
464
+ console.log(`│ ${checks[i]}${' '.repeat(Math.max(0, pad))}│`);
465
+ }
466
+ console.log(`└${border}┘`);
467
+
468
+ const status = failCount === 0
469
+ ? c.green('Status: OK')
470
+ : c.red(`Status: ISSUES (${failCount})`);
471
+ console.log(status);
472
+
473
+ if (!opts.noExit) process.exit(0);
474
+ }
475
+
476
+ // ─── Lint (delegate to feynman-lint.js) ──────────────────────────────────────
477
+
478
+ function cmdLint(args) {
479
+ // Re-invoke feynman-lint.js in-process by overriding process.argv
480
+ // and calling it via require (D-04: delegate by require, NOT spawn)
481
+ const lintArgs = args.filter(a => a !== '--help');
482
+ if (args.includes('--help') || lintArgs.length === 0) {
483
+ console.log(LINT_HELP);
484
+ process.exit(0);
485
+ }
486
+
487
+ // Override argv and invoke lint module
488
+ process.argv = ['node', 'feynman-lint.js', ...lintArgs];
489
+ require('./feynman-lint.js');
490
+ }
491
+
492
+ // ─── Version ──────────────────────────────────────────────────────────────────
493
+
494
+ function cmdVersion(args) {
495
+ if (args.includes('--help')) {
496
+ console.log(VERSION_HELP);
497
+ process.exit(0);
498
+ }
499
+ console.log(VERSION);
500
+ process.exit(0);
501
+ }
502
+
503
+ // ─── Help ─────────────────────────────────────────────────────────────────────
504
+
505
+ function cmdHelp() {
506
+ console.log(HELP);
507
+ process.exit(0);
508
+ }
509
+
510
+ // ─── Dispatch ─────────────────────────────────────────────────────────────────
511
+
512
+ const argv = process.argv.slice(2);
513
+ const sub = argv[0];
514
+ const rest = argv.slice(1);
515
+
516
+ // Top-level --help / -h / no args
517
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
518
+ if (!sub) {
519
+ // No args → help + exit 2
520
+ console.log(HELP);
521
+ process.exit(2);
522
+ }
523
+ cmdHelp();
524
+ }
525
+
526
+ switch (sub) {
527
+ case 'install': {
528
+ if (rest.includes('--help')) { console.log(INSTALL_HELP); process.exit(0); }
529
+ const parsed = parseTarget(rest);
530
+ const force = parsed.args.includes('--force');
531
+ cmdInstall({ force, target: parsed.target });
532
+ break;
533
+ }
534
+ case 'uninstall': {
535
+ if (rest.includes('--help')) { console.log(UNINSTALL_HELP); process.exit(0); }
536
+ const parsed = parseTarget(rest);
537
+ cmdUninstall({ target: parsed.target });
538
+ break;
539
+ }
540
+ case 'doctor': {
541
+ if (rest.includes('--help')) { console.log(DOCTOR_HELP); process.exit(0); }
542
+ const parsed = parseTarget(rest);
543
+ cmdDoctor({ target: parsed.target });
544
+ break;
545
+ }
546
+ case 'lint': {
547
+ cmdLint(rest);
548
+ break;
549
+ }
550
+ case 'version': {
551
+ cmdVersion(rest);
552
+ break;
553
+ }
554
+ default: {
555
+ console.error(`feynman: unknown subcommand '${sub}'`);
556
+ console.log(HELP);
557
+ process.exit(2);
558
+ }
559
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // feynman — UserPromptSubmit hook — injects ASCII diagram rules on every prompt
3
+ // Zero dependencies. CJS only (no ESM — CommonJS for zero-dep portability).
4
+ // Bug workarounds: #13912 (JSON stdout), #35713 (flag file), #8810 (os.homedir), #10225 (no hooks in plugin.json)
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ // Path constants — use os.homedir(), never tilde strings (bug #8810).
12
+ // FEYNMAN_HOME lets the same hook serve Claude Code (~/.claude) and Codex (~/.codex).
13
+ const HOME = os.homedir();
14
+ const CLIENT_HOME = process.env.FEYNMAN_HOME || path.join(HOME, '.claude');
15
+ const FEYNMAN_DIR = path.join(CLIENT_HOME, '.feynman');
16
+ const STATE_PATH = path.join(FEYNMAN_DIR, 'state.json');
17
+ const FLAG_PATH = path.join(CLIENT_HOME, '.feynman-active');
18
+ const RULES_PATH = path.join(__dirname, '..', 'rules', 'feynman-activate.md');
19
+
20
+ const DEFAULT_STATE = { enabled: true, intensity: 'full', injections: 0 };
21
+
22
+ // --- stdin accumulator: buffer all input before processing ---
23
+ let input = '';
24
+ process.stdin.on('data', chunk => { input += chunk; });
25
+ process.stdin.on('end', () => {
26
+ try {
27
+ const data = JSON.parse(input);
28
+
29
+ // Step 1: session_id path-traversal guard (verified from gsd-context-monitor.js)
30
+ const sessionId = data.session_id || '';
31
+ if (sessionId && /[/\\]|\.\./.test(sessionId)) process.exit(0);
32
+
33
+ // Step 2: flag file + first-run bootstrap (D-05, D-07, bug #35713)
34
+ // True first run: neither flag nor state exists → bootstrap both, then fall through
35
+ // Intentionally disabled: flag absent but state exists → exit 0 (user ran /feynman off)
36
+ const flagExists = fs.existsSync(FLAG_PATH);
37
+ const stateExists = fs.existsSync(STATE_PATH);
38
+ if (!flagExists) {
39
+ if (!stateExists) {
40
+ // First install — bootstrap everything and activate
41
+ fs.mkdirSync(FEYNMAN_DIR, { recursive: true });
42
+ fs.writeFileSync(STATE_PATH, JSON.stringify(DEFAULT_STATE, null, 2));
43
+ fs.writeFileSync(FLAG_PATH, DEFAULT_STATE.intensity);
44
+ } else {
45
+ process.exit(0); // disabled intentionally by user
46
+ }
47
+ }
48
+
49
+ // Step 3: read state.json
50
+ let state;
51
+ try {
52
+ state = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
53
+ } catch (e) {
54
+ // Corrupt state — fail safe per D-07
55
+ process.exit(0);
56
+ }
57
+
58
+ // Step 4: enabled check
59
+ if (!state.enabled) process.exit(0);
60
+
61
+ // Step 5: read rules file and extract the correct intensity variant (D-01, D-02, HOOK-04)
62
+ let rulesText;
63
+ try {
64
+ const rulesContent = fs.readFileSync(RULES_PATH, 'utf8');
65
+ const validIntensities = ['lite', 'full', 'ultra'];
66
+ const intensity = validIntensities.includes(state.intensity) ? state.intensity : 'full';
67
+ const openMarker = '<!-- ' + intensity + ' -->';
68
+ const closeMarker = '<!-- /' + intensity + ' -->';
69
+ const i1 = rulesContent.indexOf(openMarker);
70
+ const i2 = rulesContent.indexOf(closeMarker, i1);
71
+ if (i1 === -1 || i2 === -1) process.exit(0); // marker not found — malformed rules file
72
+ rulesText = rulesContent.slice(i1 + openMarker.length, i2).trim();
73
+ } catch (e) {
74
+ // Rules file missing — self-heal silently (pitfall 6 in RESEARCH.md)
75
+ process.exit(0);
76
+ }
77
+
78
+ if (!rulesText) process.exit(0);
79
+
80
+ // Step 6: increment injection counter and write state back (HOOK-05)
81
+ // Backward-compat: read legacy count field on first migration cycle
82
+ state.injections = (state.injections ?? state.count ?? 0) + 1;
83
+ delete state.count;
84
+ try {
85
+ fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
86
+ } catch (e) {
87
+ // Counter write failure is non-fatal — still inject rules
88
+ }
89
+
90
+ // Step 7: output rules as additionalContext (bug #13912 — JSON only, no trailing newline)
91
+ // Plain text stdout triggers "UserPromptSubmit hook error" red banner in Claude Code UI
92
+ process.stdout.write(JSON.stringify({
93
+ hookSpecificOutput: {
94
+ hookEventName: 'UserPromptSubmit',
95
+ additionalContext: rulesText
96
+ }
97
+ }));
98
+
99
+ } catch (e) {
100
+ // Silent fail — never surface hook errors in Claude Code UI
101
+ process.exit(0);
102
+ }
103
+ });