delimit-cli 3.11.11 → 3.12.1

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,823 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * LED-202: Cross-Model Hook System
5
+ *
6
+ * Detects installed AI coding assistants (Claude Code, Codex, Gemini CLI)
7
+ * and installs Delimit governance hooks into each one's native config format.
8
+ *
9
+ * Hook commands:
10
+ * delimit hook session-start -- ledger context + gov health
11
+ * delimit hook pre-tool <name> -- lint/test checks before edits
12
+ * delimit hook pre-commit -- repo diagnostics before commits
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execSync } = require('child_process');
18
+ const os = require('os');
19
+
20
+ // Use process.env.HOME to allow test overrides; fall back to os.homedir()
21
+ function getHome() { return process.env.HOME || os.homedir(); }
22
+ function getDelimitHome() { return path.join(getHome(), '.delimit'); }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Hook configuration (user-overridable via delimit.yml)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function loadHookConfig() {
29
+ const defaults = {
30
+ session_start: true,
31
+ pre_tool: true,
32
+ pre_commit: true,
33
+ deliberate_on_commit: false,
34
+ show_strategy_items: true,
35
+ };
36
+
37
+ // Check project-level delimit.yml, then global
38
+ const candidates = [
39
+ path.join(process.cwd(), 'delimit.yml'),
40
+ path.join(process.cwd(), '.delimit.yml'),
41
+ path.join(getDelimitHome(), 'delimit.yml'),
42
+ ];
43
+
44
+ for (const candidate of candidates) {
45
+ if (fs.existsSync(candidate)) {
46
+ try {
47
+ const yaml = require('js-yaml');
48
+ const doc = yaml.load(fs.readFileSync(candidate, 'utf-8'));
49
+ if (doc && doc.hooks) {
50
+ return { ...defaults, ...doc.hooks };
51
+ }
52
+ } catch { /* ignore parse errors */ }
53
+ }
54
+ }
55
+ return defaults;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // AI tool detection
60
+ // ---------------------------------------------------------------------------
61
+
62
+ function detectAITools() {
63
+ const detected = [];
64
+
65
+ // Claude Code
66
+ const claudeSettings = path.join(getHome(), '.claude', 'settings.json');
67
+ const claudeSettingsLocal = path.join(getHome(), '.claude', 'settings.local.json');
68
+ let hasClaude = fs.existsSync(claudeSettings) || fs.existsSync(claudeSettingsLocal);
69
+ if (!hasClaude) {
70
+ try {
71
+ execSync('claude --version 2>/dev/null', { stdio: 'pipe' });
72
+ hasClaude = true;
73
+ } catch { /* not installed */ }
74
+ }
75
+ if (hasClaude) {
76
+ detected.push({
77
+ id: 'claude',
78
+ name: 'Claude Code',
79
+ configPath: claudeSettings,
80
+ format: 'claude-hooks',
81
+ });
82
+ }
83
+
84
+ // Codex CLI
85
+ const codexDir = path.join(getHome(), '.codex');
86
+ let hasCodex = fs.existsSync(codexDir);
87
+ if (!hasCodex) {
88
+ try {
89
+ execSync('codex --version 2>/dev/null', { stdio: 'pipe' });
90
+ hasCodex = true;
91
+ } catch { /* not installed */ }
92
+ }
93
+ if (hasCodex) {
94
+ detected.push({
95
+ id: 'codex',
96
+ name: 'Codex CLI',
97
+ configPath: path.join(codexDir, 'config.json'),
98
+ instructionsPath: path.join(codexDir, 'instructions.md'),
99
+ format: 'codex',
100
+ });
101
+ }
102
+
103
+ // Gemini CLI
104
+ const geminiDir = path.join(getHome(), '.gemini');
105
+ let hasGemini = fs.existsSync(geminiDir);
106
+ if (!hasGemini) {
107
+ try {
108
+ execSync('gemini --version 2>/dev/null', { stdio: 'pipe' });
109
+ hasGemini = true;
110
+ } catch { /* not installed */ }
111
+ }
112
+ if (hasGemini) {
113
+ detected.push({
114
+ id: 'gemini',
115
+ name: 'Gemini CLI',
116
+ configPath: path.join(geminiDir, 'settings.json'),
117
+ format: 'gemini-mcp',
118
+ });
119
+ }
120
+
121
+ return detected;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Hook installers per tool
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Install hooks into Claude Code's ~/.claude/settings.json
130
+ * Claude Code supports native hooks: SessionStart, PreToolUse, PostToolUse, etc.
131
+ */
132
+ function installClaudeHooks(tool, hookConfig) {
133
+ const configPath = tool.configPath;
134
+ const configDir = path.dirname(configPath);
135
+ fs.mkdirSync(configDir, { recursive: true });
136
+
137
+ let config = {};
138
+ if (fs.existsSync(configPath)) {
139
+ try {
140
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
141
+ } catch { config = {}; }
142
+ }
143
+
144
+ if (!config.hooks) {
145
+ config.hooks = {};
146
+ }
147
+
148
+ const npxCmd = 'npx delimit-cli';
149
+ const changes = [];
150
+
151
+ // SessionStart hook
152
+ if (hookConfig.session_start) {
153
+ const sessionHook = {
154
+ type: 'command',
155
+ command: `${npxCmd} hook session-start`,
156
+ };
157
+ if (!config.hooks.SessionStart) {
158
+ config.hooks.SessionStart = [];
159
+ }
160
+ // Check if already installed
161
+ const existing = config.hooks.SessionStart.find(
162
+ h => h.command && h.command.includes('delimit-cli hook session-start')
163
+ );
164
+ if (!existing) {
165
+ config.hooks.SessionStart.push(sessionHook);
166
+ changes.push('SessionStart');
167
+ }
168
+ }
169
+
170
+ // PreToolUse hook for file edits
171
+ if (hookConfig.pre_tool) {
172
+ const preToolHook = {
173
+ type: 'command',
174
+ command: `${npxCmd} hook pre-tool`,
175
+ matcher: 'Edit|Write|Bash',
176
+ };
177
+ if (!config.hooks.PreToolUse) {
178
+ config.hooks.PreToolUse = [];
179
+ }
180
+ const existing = config.hooks.PreToolUse.find(
181
+ h => h.command && h.command.includes('delimit-cli hook pre-tool')
182
+ );
183
+ if (!existing) {
184
+ config.hooks.PreToolUse.push(preToolHook);
185
+ changes.push('PreToolUse');
186
+ }
187
+ }
188
+
189
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
190
+ return changes;
191
+ }
192
+
193
+ /**
194
+ * Install hooks for Codex CLI.
195
+ * Codex uses instructions.md for session-start equivalent and config.json for settings.
196
+ * We add governance instructions and a pre-commit hook reference.
197
+ */
198
+ function installCodexHooks(tool, hookConfig) {
199
+ const changes = [];
200
+ const codexDir = path.dirname(tool.configPath);
201
+ fs.mkdirSync(codexDir, { recursive: true });
202
+
203
+ // Codex instructions.md -- acts as the session-start equivalent
204
+ if (hookConfig.session_start) {
205
+ const instructionsPath = tool.instructionsPath || path.join(codexDir, 'instructions.md');
206
+ const delimitBlock = `<!-- delimit:hooks-start -->
207
+ ## Delimit Governance
208
+
209
+ On every session start, run this command to check governance status:
210
+ \`\`\`
211
+ npx delimit-cli hook session-start
212
+ \`\`\`
213
+
214
+ Before committing changes, run:
215
+ \`\`\`
216
+ npx delimit-cli hook pre-commit
217
+ \`\`\`
218
+
219
+ After editing OpenAPI specs, run:
220
+ \`\`\`
221
+ npx delimit-cli hook pre-tool Edit
222
+ \`\`\`
223
+ <!-- delimit:hooks-end -->`;
224
+
225
+ let content = '';
226
+ if (fs.existsSync(instructionsPath)) {
227
+ content = fs.readFileSync(instructionsPath, 'utf-8');
228
+ }
229
+
230
+ if (content.includes('delimit:hooks-start')) {
231
+ // Replace existing block
232
+ content = content.replace(
233
+ /<!-- delimit:hooks-start -->[\s\S]*?<!-- delimit:hooks-end -->/,
234
+ delimitBlock
235
+ );
236
+ } else {
237
+ content = content ? content + '\n\n' + delimitBlock : delimitBlock;
238
+ }
239
+
240
+ fs.writeFileSync(instructionsPath, content);
241
+ changes.push('instructions.md');
242
+ }
243
+
244
+ // Codex config.json -- add hook commands
245
+ let config = {};
246
+ if (fs.existsSync(tool.configPath)) {
247
+ try {
248
+ config = JSON.parse(fs.readFileSync(tool.configPath, 'utf-8'));
249
+ } catch { config = {}; }
250
+ }
251
+
252
+ if (!config.hooks) {
253
+ config.hooks = {};
254
+ }
255
+
256
+ if (hookConfig.pre_commit && !config.hooks['pre-commit']) {
257
+ config.hooks['pre-commit'] = 'npx delimit-cli hook pre-commit';
258
+ changes.push('pre-commit hook');
259
+ }
260
+
261
+ fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
262
+ return changes;
263
+ }
264
+
265
+ /**
266
+ * Install hooks for Gemini CLI.
267
+ * Gemini CLI uses MCP (already handled by setup) but we add governance
268
+ * instructions to settings.json and a GEMINI.md equivalent.
269
+ */
270
+ function installGeminiHooks(tool, hookConfig) {
271
+ const changes = [];
272
+ const geminiDir = path.dirname(tool.configPath);
273
+ fs.mkdirSync(geminiDir, { recursive: true });
274
+
275
+ // Update settings.json with custom instructions
276
+ let config = {};
277
+ if (fs.existsSync(tool.configPath)) {
278
+ try {
279
+ config = JSON.parse(fs.readFileSync(tool.configPath, 'utf-8'));
280
+ } catch { config = {}; }
281
+ }
282
+
283
+ const govInstructions = [
284
+ 'Delimit governance is active.',
285
+ 'On session start, run: npx delimit-cli hook session-start',
286
+ 'Before committing, run: npx delimit-cli hook pre-commit',
287
+ 'After editing API specs, run: npx delimit-cli hook pre-tool Edit',
288
+ 'Call delimit_ledger_context and delimit_gov_health via MCP when available.',
289
+ ].join(' ');
290
+
291
+ if (!config.customInstructions || !config.customInstructions.includes('delimit-cli hook')) {
292
+ config.customInstructions = govInstructions;
293
+ changes.push('customInstructions');
294
+ }
295
+
296
+ fs.writeFileSync(tool.configPath, JSON.stringify(config, null, 2));
297
+
298
+ // Write a GEMINI.md governance file (equivalent of CLAUDE.md)
299
+ const geminiMd = path.join(geminiDir, 'GEMINI.md');
300
+ if (!fs.existsSync(geminiMd) || !fs.readFileSync(geminiMd, 'utf-8').includes('delimit')) {
301
+ const content = `# Delimit Governance
302
+
303
+ On every session start:
304
+ 1. Run \`npx delimit-cli hook session-start\` to check open tasks and governance status
305
+
306
+ After editing code:
307
+ - After editing API specs: run \`npx delimit-cli hook pre-tool Edit\`
308
+ - After editing tests: run \`npx delimit-cli hook pre-tool Edit\`
309
+
310
+ Before committing:
311
+ - Run \`npx delimit-cli hook pre-commit\` to check for issues
312
+ `;
313
+ fs.writeFileSync(geminiMd, content);
314
+ changes.push('GEMINI.md');
315
+ }
316
+
317
+ return changes;
318
+ }
319
+
320
+ /**
321
+ * Install hooks for a detected tool.
322
+ * Returns { tool, changes } describing what was installed.
323
+ */
324
+ function installHooksForTool(tool, hookConfig) {
325
+ switch (tool.id) {
326
+ case 'claude':
327
+ return { tool, changes: installClaudeHooks(tool, hookConfig) };
328
+ case 'codex':
329
+ return { tool, changes: installCodexHooks(tool, hookConfig) };
330
+ case 'gemini':
331
+ return { tool, changes: installGeminiHooks(tool, hookConfig) };
332
+ default:
333
+ return { tool, changes: [] };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Install hooks for all detected AI tools.
339
+ */
340
+ function installAllHooks(hookConfig) {
341
+ const tools = detectAITools();
342
+ const results = [];
343
+ for (const tool of tools) {
344
+ results.push(installHooksForTool(tool, hookConfig));
345
+ }
346
+ return { tools, results };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Hook removal (for uninstall)
351
+ // ---------------------------------------------------------------------------
352
+
353
+ function removeClaudeHooks() {
354
+ const configPath = path.join(getHome(), '.claude', 'settings.json');
355
+ if (!fs.existsSync(configPath)) return false;
356
+
357
+ try {
358
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
359
+ if (!config.hooks) return false;
360
+
361
+ let changed = false;
362
+
363
+ for (const event of ['SessionStart', 'PreToolUse', 'PostToolUse']) {
364
+ if (Array.isArray(config.hooks[event])) {
365
+ const before = config.hooks[event].length;
366
+ config.hooks[event] = config.hooks[event].filter(
367
+ h => !(h.command && h.command.includes('delimit-cli'))
368
+ );
369
+ if (config.hooks[event].length === 0) {
370
+ delete config.hooks[event];
371
+ }
372
+ if (config.hooks[event] === undefined || config.hooks[event].length < before) {
373
+ changed = true;
374
+ }
375
+ }
376
+ }
377
+
378
+ if (Object.keys(config.hooks).length === 0) {
379
+ delete config.hooks;
380
+ }
381
+
382
+ if (changed) {
383
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
384
+ }
385
+ return changed;
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
391
+ function removeCodexHooks() {
392
+ let changed = false;
393
+
394
+ // Remove from instructions.md
395
+ const instructionsPath = path.join(getHome(), '.codex', 'instructions.md');
396
+ if (fs.existsSync(instructionsPath)) {
397
+ let content = fs.readFileSync(instructionsPath, 'utf-8');
398
+ if (content.includes('delimit:hooks-start')) {
399
+ content = content.replace(
400
+ /\n*<!-- delimit:hooks-start -->[\s\S]*?<!-- delimit:hooks-end -->\n*/,
401
+ ''
402
+ );
403
+ fs.writeFileSync(instructionsPath, content);
404
+ changed = true;
405
+ }
406
+ }
407
+
408
+ // Remove hooks from config.json
409
+ const configPath = path.join(getHome(), '.codex', 'config.json');
410
+ if (fs.existsSync(configPath)) {
411
+ try {
412
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
413
+ if (config.hooks) {
414
+ for (const [key, val] of Object.entries(config.hooks)) {
415
+ if (typeof val === 'string' && val.includes('delimit-cli')) {
416
+ delete config.hooks[key];
417
+ changed = true;
418
+ }
419
+ }
420
+ if (Object.keys(config.hooks).length === 0) {
421
+ delete config.hooks;
422
+ }
423
+ if (changed) {
424
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
425
+ }
426
+ }
427
+ } catch { /* ignore */ }
428
+ }
429
+
430
+ return changed;
431
+ }
432
+
433
+ function removeGeminiHooks() {
434
+ let changed = false;
435
+
436
+ // Remove custom instructions referencing delimit
437
+ const configPath = path.join(getHome(), '.gemini', 'settings.json');
438
+ if (fs.existsSync(configPath)) {
439
+ try {
440
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
441
+ if (config.customInstructions && config.customInstructions.includes('delimit-cli hook')) {
442
+ delete config.customInstructions;
443
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
444
+ changed = true;
445
+ }
446
+ } catch { /* ignore */ }
447
+ }
448
+
449
+ // Remove GEMINI.md if it's ours
450
+ const geminiMd = path.join(getHome(), '.gemini', 'GEMINI.md');
451
+ if (fs.existsSync(geminiMd)) {
452
+ const content = fs.readFileSync(geminiMd, 'utf-8');
453
+ if (content.includes('Delimit Governance')) {
454
+ fs.unlinkSync(geminiMd);
455
+ changed = true;
456
+ }
457
+ }
458
+
459
+ return changed;
460
+ }
461
+
462
+ function removeAllHooks() {
463
+ const results = [];
464
+
465
+ if (removeClaudeHooks()) {
466
+ results.push('Claude Code');
467
+ }
468
+ if (removeCodexHooks()) {
469
+ results.push('Codex CLI');
470
+ }
471
+ if (removeGeminiHooks()) {
472
+ results.push('Gemini CLI');
473
+ }
474
+
475
+ return results;
476
+ }
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Deliberation helpers
480
+ // ---------------------------------------------------------------------------
481
+
482
+ /**
483
+ * Count pending strategy items in the ledger that have priority P0.
484
+ * Returns the count of open/in_progress P0 strategy items.
485
+ */
486
+ function countPendingStrategyItems() {
487
+ const ledgerDir = path.join(getDelimitHome(), 'ledger');
488
+ if (!fs.existsSync(ledgerDir)) return 0;
489
+
490
+ let count = 0;
491
+ try {
492
+ const files = fs.readdirSync(ledgerDir).filter(f => f.endsWith('.json'));
493
+ for (const f of files) {
494
+ try {
495
+ const items = JSON.parse(fs.readFileSync(path.join(ledgerDir, f), 'utf-8'));
496
+ if (!Array.isArray(items)) continue;
497
+ for (const item of items) {
498
+ const isOpen = item.status === 'open' || item.status === 'in_progress';
499
+ const isStrategy = item.category === 'strategy' || item.category === 'deliberation';
500
+ const isP0 = item.priority === 'P0' || item.priority === 0;
501
+ if (isOpen && (isStrategy || isP0)) {
502
+ count++;
503
+ }
504
+ }
505
+ } catch { /* ignore individual file parse errors */ }
506
+ }
507
+ } catch { /* ignore directory read errors */ }
508
+
509
+ return count;
510
+ }
511
+
512
+ /**
513
+ * Get the highest priority pending strategy item from the ledger.
514
+ * Returns the item object or null if none found.
515
+ */
516
+ function getTopStrategyItem() {
517
+ const ledgerDir = path.join(getDelimitHome(), 'ledger');
518
+ if (!fs.existsSync(ledgerDir)) return null;
519
+
520
+ let best = null;
521
+ const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
522
+
523
+ try {
524
+ const files = fs.readdirSync(ledgerDir).filter(f => f.endsWith('.json'));
525
+ for (const f of files) {
526
+ try {
527
+ const items = JSON.parse(fs.readFileSync(path.join(ledgerDir, f), 'utf-8'));
528
+ if (!Array.isArray(items)) continue;
529
+ for (const item of items) {
530
+ const isOpen = item.status === 'open' || item.status === 'in_progress';
531
+ const isStrategy = item.category === 'strategy' || item.category === 'deliberation';
532
+ const isP0 = item.priority === 'P0' || item.priority === 0;
533
+ if (isOpen && (isStrategy || isP0)) {
534
+ const rank = typeof item.priority === 'number' ? item.priority : (priorityOrder[item.priority] ?? 99);
535
+ if (!best || rank < (typeof best.priority === 'number' ? best.priority : (priorityOrder[best.priority] ?? 99))) {
536
+ best = item;
537
+ }
538
+ }
539
+ }
540
+ } catch { /* ignore */ }
541
+ }
542
+ } catch { /* ignore */ }
543
+
544
+ return best;
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // Hook execution commands
549
+ // ---------------------------------------------------------------------------
550
+
551
+ /**
552
+ * session-start: Show ledger context and governance health.
553
+ * Output goes to stdout for the AI tool to read.
554
+ */
555
+ async function hookSessionStart() {
556
+ const config = loadHookConfig();
557
+ if (!config.session_start) {
558
+ return;
559
+ }
560
+
561
+ const lines = [];
562
+ lines.push('[Delimit] Governance check');
563
+ lines.push('');
564
+
565
+ // Check for delimit.yml or .delimit.yml
566
+ const cwd = process.cwd();
567
+ const hasPolicy = fs.existsSync(path.join(cwd, 'delimit.yml'))
568
+ || fs.existsSync(path.join(cwd, '.delimit.yml'))
569
+ || fs.existsSync(path.join(cwd, '.delimit', 'policies.yml'));
570
+
571
+ if (hasPolicy) {
572
+ lines.push('[Delimit] Policy file found -- governance active');
573
+ } else {
574
+ lines.push('[Delimit] No policy file found -- run "delimit init" to set up governance');
575
+ }
576
+
577
+ // Check for OpenAPI specs
578
+ const specPatterns = ['openapi.yaml', 'openapi.yml', 'openapi.json', 'swagger.yaml', 'swagger.json'];
579
+ const foundSpecs = [];
580
+ for (const pattern of specPatterns) {
581
+ const specPath = path.join(cwd, pattern);
582
+ if (fs.existsSync(specPath)) {
583
+ foundSpecs.push(pattern);
584
+ }
585
+ }
586
+ // Also check api/ and specs/ directories
587
+ for (const dir of ['api', 'specs', 'spec']) {
588
+ const dirPath = path.join(cwd, dir);
589
+ if (fs.existsSync(dirPath)) {
590
+ try {
591
+ const files = fs.readdirSync(dirPath);
592
+ for (const f of files) {
593
+ if (/\.(yaml|yml|json)$/.test(f) && /openapi|swagger/i.test(f)) {
594
+ foundSpecs.push(path.join(dir, f));
595
+ }
596
+ }
597
+ } catch { /* ignore */ }
598
+ }
599
+ }
600
+
601
+ if (foundSpecs.length > 0) {
602
+ lines.push(`[Delimit] OpenAPI specs detected: ${foundSpecs.join(', ')}`);
603
+ }
604
+
605
+ // Check ledger
606
+ const ledgerDir = path.join(getDelimitHome(), 'ledger');
607
+ if (fs.existsSync(ledgerDir)) {
608
+ try {
609
+ const ledgerFiles = fs.readdirSync(ledgerDir).filter(f => f.endsWith('.json'));
610
+ let openItems = 0;
611
+ for (const f of ledgerFiles) {
612
+ try {
613
+ const items = JSON.parse(fs.readFileSync(path.join(ledgerDir, f), 'utf-8'));
614
+ if (Array.isArray(items)) {
615
+ openItems += items.filter(i => i.status === 'open' || i.status === 'in_progress').length;
616
+ }
617
+ } catch { /* ignore */ }
618
+ }
619
+ if (openItems > 0) {
620
+ lines.push(`[Delimit] Ledger: ${openItems} open item(s)`);
621
+ } else {
622
+ lines.push('[Delimit] Ledger: no open items');
623
+ }
624
+ } catch {
625
+ lines.push('[Delimit] Ledger: empty');
626
+ }
627
+ }
628
+
629
+ // Check for pending strategy items that need deliberation
630
+ if (config.show_strategy_items) {
631
+ const strategyCount = countPendingStrategyItems();
632
+ if (strategyCount > 0) {
633
+ lines.push(`[delimit] ${strategyCount} strategic decision${strategyCount === 1 ? '' : 's'} pending deliberation. Run: delimit deliberate`);
634
+ }
635
+ }
636
+
637
+ // Git branch info
638
+ try {
639
+ const branch = execSync('git branch --show-current 2>/dev/null', { encoding: 'utf-8' }).trim();
640
+ if (branch) {
641
+ lines.push(`[Delimit] Branch: ${branch}`);
642
+ }
643
+ } catch { /* not in git repo */ }
644
+
645
+ lines.push('');
646
+ process.stdout.write(lines.join('\n') + '\n');
647
+ }
648
+
649
+ /**
650
+ * pre-tool: Check before file edits.
651
+ * If editing an OpenAPI spec, run a quick lint.
652
+ * If editing a test file, note it.
653
+ */
654
+ async function hookPreTool(toolName) {
655
+ const config = loadHookConfig();
656
+ if (!config.pre_tool) {
657
+ return;
658
+ }
659
+
660
+ // The tool name comes from the AI tool (e.g., "Edit", "Write", "Bash")
661
+ // We check the DELIMIT_TOOL_INPUT env or just do lightweight checks
662
+ const cwd = process.cwd();
663
+
664
+ // Check if there are staged OpenAPI spec changes
665
+ try {
666
+ const stagedFiles = execSync('git diff --cached --name-only 2>/dev/null', {
667
+ encoding: 'utf-8',
668
+ timeout: 2000,
669
+ }).split('\n').filter(Boolean);
670
+
671
+ const specFiles = stagedFiles.filter(f =>
672
+ /openapi|swagger/i.test(f) && /\.(yaml|yml|json)$/.test(f)
673
+ );
674
+
675
+ if (specFiles.length > 0) {
676
+ process.stderr.write(`[Delimit] Warning: OpenAPI spec(s) staged for commit: ${specFiles.join(', ')}\n`);
677
+ process.stderr.write('[Delimit] Run "delimit lint" before committing to check for breaking changes.\n');
678
+ }
679
+
680
+ const testFiles = stagedFiles.filter(f =>
681
+ /\.(test|spec)\.(js|ts|py|rb)$/.test(f) || /test_.*\.py$/.test(f)
682
+ );
683
+
684
+ if (testFiles.length > 0) {
685
+ process.stderr.write(`[Delimit] Test files staged: ${testFiles.join(', ')}\n`);
686
+ process.stderr.write('[Delimit] Consider running tests before committing.\n');
687
+ }
688
+ } catch {
689
+ // Not in a git repo or no staged changes -- that is fine
690
+ }
691
+ }
692
+
693
+ /**
694
+ * pre-commit: Run repo diagnostics before committing.
695
+ */
696
+ async function hookPreCommit() {
697
+ const config = loadHookConfig();
698
+ if (!config.pre_commit) {
699
+ return;
700
+ }
701
+
702
+ const cwd = process.cwd();
703
+ const warnings = [];
704
+
705
+ // Check for staged OpenAPI spec changes
706
+ try {
707
+ const stagedFiles = execSync('git diff --cached --name-only 2>/dev/null', {
708
+ encoding: 'utf-8',
709
+ timeout: 2000,
710
+ }).split('\n').filter(Boolean);
711
+
712
+ const specFiles = stagedFiles.filter(f =>
713
+ /openapi|swagger/i.test(f) && /\.(yaml|yml|json)$/.test(f)
714
+ );
715
+
716
+ if (specFiles.length > 0) {
717
+ // Try to find a previous version to diff against
718
+ for (const specFile of specFiles) {
719
+ try {
720
+ // Get the HEAD version
721
+ const oldContent = execSync(`git show HEAD:${specFile} 2>/dev/null`, {
722
+ encoding: 'utf-8',
723
+ timeout: 3000,
724
+ });
725
+ if (oldContent) {
726
+ warnings.push(`[Delimit] OpenAPI spec changed: ${specFile}`);
727
+ warnings.push('[Delimit] Run "delimit diff <old> <new>" to review API changes before committing.');
728
+ }
729
+ } catch {
730
+ // New file, no previous version
731
+ }
732
+ }
733
+ }
734
+
735
+ // Check for secrets patterns in staged files
736
+ const sensitivePatterns = [
737
+ /password\s*[:=]\s*['"][^'"]+['"]/i,
738
+ /api[_-]?key\s*[:=]\s*['"][^'"]+['"]/i,
739
+ /secret\s*[:=]\s*['"][^'"]+['"]/i,
740
+ ];
741
+
742
+ for (const file of stagedFiles) {
743
+ if (/\.(env|key|pem|p12|pfx)$/.test(file)) {
744
+ warnings.push(`[Delimit] WARNING: Potentially sensitive file staged: ${file}`);
745
+ }
746
+ }
747
+ } catch {
748
+ // Not in git repo
749
+ }
750
+
751
+ // Check for policy file
752
+ const hasPolicy = fs.existsSync(path.join(cwd, 'delimit.yml'))
753
+ || fs.existsSync(path.join(cwd, '.delimit.yml'));
754
+
755
+ if (!hasPolicy) {
756
+ warnings.push('[Delimit] No governance policy found. Run "delimit init" to create one.');
757
+ }
758
+
759
+ // Deliberation on API spec commits (opt-in via deliberate_on_commit)
760
+ if (config.deliberate_on_commit) {
761
+ try {
762
+ const stagedFiles2 = execSync('git diff --cached --name-only 2>/dev/null', {
763
+ encoding: 'utf-8',
764
+ timeout: 2000,
765
+ }).split('\n').filter(Boolean);
766
+
767
+ const apiSpecFiles = stagedFiles2.filter(f =>
768
+ /openapi|swagger/i.test(f) && /\.(yaml|yml|json)$/.test(f)
769
+ );
770
+
771
+ if (apiSpecFiles.length > 0) {
772
+ // Auto-deliberate: call Delimit gateway directly
773
+ if (config.deliberate_on_commit === 'auto') {
774
+ process.stderr.write('[delimit] API spec change detected — running multi-model deliberation...\n');
775
+ try {
776
+ const diff = execSync(`git diff --cached -- ${apiSpecFiles.join(' ')} 2>/dev/null`, {
777
+ encoding: 'utf-8',
778
+ timeout: 5000,
779
+ maxBuffer: 50 * 1024,
780
+ }).slice(0, 2000);
781
+ const question = `This commit modifies API specs (${apiSpecFiles.join(', ')}). Is this change safe to ship? Are there breaking changes?\n\nDiff:\n${diff}`;
782
+ const result = execSync(`npx delimit-cli deliberate --question "${question.replace(/"/g, '\\"')}" --mode quick 2>/dev/null`, {
783
+ encoding: 'utf-8',
784
+ timeout: 60000,
785
+ });
786
+ process.stderr.write(result + '\n');
787
+ } catch (e) {
788
+ warnings.push(`[delimit] Deliberation failed: ${e.message?.slice(0, 100) || 'timeout'}. Proceeding with commit.`);
789
+ }
790
+ } else {
791
+ warnings.push('[delimit] This commit modifies API specs. Consider running: delimit deliberate "Is this change safe?"');
792
+ }
793
+ }
794
+ } catch { /* not in git repo */ }
795
+ }
796
+
797
+ if (warnings.length > 0) {
798
+ process.stderr.write(warnings.join('\n') + '\n');
799
+ }
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // Exports
804
+ // ---------------------------------------------------------------------------
805
+
806
+ module.exports = {
807
+ detectAITools,
808
+ installHooksForTool,
809
+ installAllHooks,
810
+ installClaudeHooks,
811
+ installCodexHooks,
812
+ installGeminiHooks,
813
+ removeAllHooks,
814
+ removeClaudeHooks,
815
+ removeCodexHooks,
816
+ removeGeminiHooks,
817
+ loadHookConfig,
818
+ hookSessionStart,
819
+ hookPreTool,
820
+ hookPreCommit,
821
+ countPendingStrategyItems,
822
+ getTopStrategyItem,
823
+ };