@lumenflow/cli 2.8.0 → 2.10.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.
@@ -5,6 +5,9 @@
5
5
  * This module generates hook configurations that can be written to
6
6
  * .claude/settings.json to enforce LumenFlow workflow compliance.
7
7
  */
8
+ import { CLAUDE_HOOKS, getHookCommand } from '@lumenflow/core';
9
+ // Re-export for backwards compatibility (WU-1394)
10
+ export const HOOK_SCRIPTS = CLAUDE_HOOKS.SCRIPTS;
8
11
  /**
9
12
  * Generate enforcement hooks based on configuration.
10
13
  *
@@ -20,18 +23,18 @@ export function generateEnforcementHooks(config) {
20
23
  if (config.block_outside_worktree) {
21
24
  writeEditHooks.push({
22
25
  type: 'command',
23
- command: '$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.sh',
26
+ command: getHookCommand(HOOK_SCRIPTS.ENFORCE_WORKTREE),
24
27
  });
25
28
  }
26
29
  if (config.require_wu_for_edits) {
27
30
  writeEditHooks.push({
28
31
  type: 'command',
29
- command: '$CLAUDE_PROJECT_DIR/.claude/hooks/require-wu.sh',
32
+ command: getHookCommand(HOOK_SCRIPTS.REQUIRE_WU),
30
33
  });
31
34
  }
32
35
  if (writeEditHooks.length > 0) {
33
36
  preToolUseHooks.push({
34
- matcher: 'Write|Edit',
37
+ matcher: CLAUDE_HOOKS.MATCHERS.WRITE_EDIT,
35
38
  hooks: writeEditHooks,
36
39
  });
37
40
  }
@@ -43,16 +46,58 @@ export function generateEnforcementHooks(config) {
43
46
  if (config.warn_on_stop_without_wu_done) {
44
47
  hooks.stop = [
45
48
  {
46
- matcher: '.*',
49
+ matcher: CLAUDE_HOOKS.MATCHERS.ALL,
47
50
  hooks: [
48
51
  {
49
52
  type: 'command',
50
- command: '$CLAUDE_PROJECT_DIR/.claude/hooks/warn-incomplete.sh',
53
+ command: getHookCommand(HOOK_SCRIPTS.WARN_INCOMPLETE),
51
54
  },
52
55
  ],
53
56
  },
54
57
  ];
55
58
  }
59
+ // Always generate PreCompact and SessionStart recovery hooks (WU-1394)
60
+ // These enable durable context recovery after compaction
61
+ hooks.preCompact = [
62
+ {
63
+ matcher: CLAUDE_HOOKS.MATCHERS.ALL,
64
+ hooks: [
65
+ {
66
+ type: 'command',
67
+ command: getHookCommand(HOOK_SCRIPTS.PRE_COMPACT_CHECKPOINT),
68
+ },
69
+ ],
70
+ },
71
+ ];
72
+ hooks.sessionStart = [
73
+ {
74
+ matcher: CLAUDE_HOOKS.MATCHERS.COMPACT,
75
+ hooks: [
76
+ {
77
+ type: 'command',
78
+ command: getHookCommand(HOOK_SCRIPTS.SESSION_START_RECOVERY),
79
+ },
80
+ ],
81
+ },
82
+ {
83
+ matcher: CLAUDE_HOOKS.MATCHERS.RESUME,
84
+ hooks: [
85
+ {
86
+ type: 'command',
87
+ command: getHookCommand(HOOK_SCRIPTS.SESSION_START_RECOVERY),
88
+ },
89
+ ],
90
+ },
91
+ {
92
+ matcher: CLAUDE_HOOKS.MATCHERS.CLEAR,
93
+ hooks: [
94
+ {
95
+ type: 'command',
96
+ command: getHookCommand(HOOK_SCRIPTS.SESSION_START_RECOVERY),
97
+ },
98
+ ],
99
+ },
100
+ ];
56
101
  return hooks;
57
102
  }
58
103
  /**
@@ -363,3 +408,209 @@ exit 0
363
408
  `;
364
409
  /* eslint-enable no-useless-escape */
365
410
  }
411
+ /**
412
+ * Generate the pre-compact-checkpoint.sh hook script content.
413
+ *
414
+ * This PreCompact hook saves a checkpoint and writes a durable recovery file
415
+ * before context compaction. The recovery file survives compaction and is
416
+ * read by session-start-recovery.sh on the next session start.
417
+ *
418
+ * Part of WU-1394: Durable recovery pattern for context preservation.
419
+ */
420
+ export function generatePreCompactCheckpointScript() {
421
+ // Note: Shell variable escapes (\$, \") are intentional for the generated bash script
422
+ /* eslint-disable no-useless-escape */
423
+ return `#!/bin/bash
424
+ #
425
+ # pre-compact-checkpoint.sh
426
+ #
427
+ # PreCompact hook - auto-checkpoint + durable recovery marker (WU-1390)
428
+ #
429
+ # Fires before context compaction to:
430
+ # 1. Save a checkpoint with the current WU progress
431
+ # 2. Write a durable recovery file that survives compaction
432
+ #
433
+ # The recovery file is read by session-start-recovery.sh on the next
434
+ # session start (after compact, resume, or clear) to restore context.
435
+ #
436
+ # Exit codes:
437
+ # 0 = Always allow (cannot block compaction)
438
+ #
439
+ # Uses python3 for JSON parsing (consistent with other hooks)
440
+ #
441
+
442
+ set -euo pipefail
443
+
444
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
445
+
446
+ # Derive repo paths from CLAUDE_PROJECT_DIR
447
+ if [[ -n "\${CLAUDE_PROJECT_DIR:-}" ]]; then
448
+ REPO_PATH="\$CLAUDE_PROJECT_DIR"
449
+ else
450
+ REPO_PATH=\$(git rev-parse --show-toplevel 2>/dev/null || echo "")
451
+ if [[ -z "\$REPO_PATH" ]]; then
452
+ exit 0
453
+ fi
454
+ fi
455
+
456
+ # Read JSON input from stdin
457
+ INPUT=\$(cat)
458
+
459
+ # Parse trigger from hook input (defensive - default to "auto")
460
+ # PreCompact provides: { "trigger": "manual" | "auto" }
461
+ TRIGGER=\$(python3 -c "
462
+ import json
463
+ import sys
464
+ try:
465
+ data = json.loads('''\$INPUT''')
466
+ trigger = data.get('trigger', 'auto')
467
+ print(trigger if trigger else 'auto')
468
+ except:
469
+ print('auto')
470
+ " 2>/dev/null || echo "auto")
471
+
472
+ # Get WU ID from worktree context (wu:status --json)
473
+ # Location.worktreeWuId is set when in a worktree
474
+ WU_ID=\$(pnpm wu:status --json 2>/dev/null | python3 -c "
475
+ import json
476
+ import sys
477
+ try:
478
+ data = json.load(sys.stdin)
479
+ location = data.get('location', {})
480
+ wu_id = location.get('worktreeWuId') or ''
481
+ print(wu_id)
482
+ except:
483
+ print('')
484
+ " 2>/dev/null || echo "")
485
+
486
+ # Only proceed if we have a WU ID (working in a worktree)
487
+ if [[ -n "\$WU_ID" ]]; then
488
+ # Save checkpoint with pre-compact trigger
489
+ # Note: This may fail if CLI not built, but that's OK - recovery file is more important
490
+ pnpm mem:checkpoint "Auto: pre-\${TRIGGER}-compaction" --wu "\$WU_ID" --trigger "pre-compact" --quiet 2>/dev/null || true
491
+
492
+ # Write durable recovery marker (survives compaction)
493
+ # This is the key mechanism - file persists and is read by session-start-recovery.sh
494
+ RECOVERY_DIR="\${REPO_PATH}/.lumenflow/state"
495
+ RECOVERY_FILE="\${RECOVERY_DIR}/recovery-pending-\${WU_ID}.md"
496
+
497
+ mkdir -p "\$RECOVERY_DIR"
498
+
499
+ # Generate recovery context using mem:recover
500
+ # The --quiet flag outputs only the recovery context without headers
501
+ pnpm mem:recover --wu "\$WU_ID" --quiet > "\$RECOVERY_FILE" 2>/dev/null || {
502
+ # Fallback minimal recovery if mem:recover fails
503
+ cat > "\$RECOVERY_FILE" << EOF
504
+ # POST-COMPACTION RECOVERY
505
+
506
+ You are resuming work after context compaction. Your previous context was lost.
507
+ **WU:** \${WU_ID}
508
+
509
+ ## Next Action
510
+ Run \\\`pnpm wu:spawn --id \${WU_ID}\\\` to spawn a fresh agent with full context.
511
+ EOF
512
+ }
513
+
514
+ # Output brief warning to stderr (may be compacted away, but recovery file persists)
515
+ echo "" >&2
516
+ echo "═══════════════════════════════════════════════════════" >&2
517
+ echo "⚠️ COMPACTION: Checkpoint saved for \${WU_ID}" >&2
518
+ echo "Recovery context: \${RECOVERY_FILE}" >&2
519
+ echo "Next: pnpm wu:spawn --id \${WU_ID}" >&2
520
+ echo "═══════════════════════════════════════════════════════" >&2
521
+ fi
522
+
523
+ # Always exit 0 - cannot block compaction
524
+ exit 0
525
+ `;
526
+ /* eslint-enable no-useless-escape */
527
+ }
528
+ /**
529
+ * Generate the session-start-recovery.sh hook script content.
530
+ *
531
+ * This SessionStart hook checks for pending recovery files written by
532
+ * pre-compact-checkpoint.sh and displays the recovery context to the agent.
533
+ * After displaying, the recovery file is deleted (one-time recovery).
534
+ *
535
+ * Part of WU-1394: Durable recovery pattern for context preservation.
536
+ */
537
+ export function generateSessionStartRecoveryScript() {
538
+ // Note: Shell variable escapes (\$, \") are intentional for the generated bash script
539
+ /* eslint-disable no-useless-escape */
540
+ return `#!/bin/bash
541
+ #
542
+ # session-start-recovery.sh
543
+ #
544
+ # SessionStart hook - check for pending recovery and inject context (WU-1390)
545
+ #
546
+ # Fires after session start (on compact, resume, or clear) to:
547
+ # 1. Check for recovery-pending-*.md files written by pre-compact-checkpoint.sh
548
+ # 2. Display the recovery context to the agent
549
+ # 3. Remove the recovery file (one-time recovery)
550
+ #
551
+ # This completes the durable recovery pattern:
552
+ # PreCompact writes file → SessionStart reads and deletes it
553
+ #
554
+ # Exit codes:
555
+ # 0 = Always allow (informational hook)
556
+ #
557
+
558
+ set -euo pipefail
559
+
560
+ # Derive repo paths from CLAUDE_PROJECT_DIR
561
+ if [[ -n "\${CLAUDE_PROJECT_DIR:-}" ]]; then
562
+ REPO_PATH="\$CLAUDE_PROJECT_DIR"
563
+ else
564
+ REPO_PATH=\$(git rev-parse --show-toplevel 2>/dev/null || echo "")
565
+ if [[ -z "\$REPO_PATH" ]]; then
566
+ exit 0
567
+ fi
568
+ fi
569
+
570
+ RECOVERY_DIR="\${REPO_PATH}/.lumenflow/state"
571
+
572
+ # Check if recovery directory exists
573
+ if [[ ! -d "\$RECOVERY_DIR" ]]; then
574
+ exit 0
575
+ fi
576
+
577
+ # Find any pending recovery files
578
+ FOUND_RECOVERY=false
579
+
580
+ for recovery_file in "\$RECOVERY_DIR"/recovery-pending-*.md; do
581
+ # Check if glob matched any files (bash glob returns literal pattern if no match)
582
+ [[ -f "\$recovery_file" ]] || continue
583
+
584
+ FOUND_RECOVERY=true
585
+
586
+ # Extract WU ID from filename for display
587
+ WU_ID=\$(basename "\$recovery_file" | sed 's/recovery-pending-\\(.*\\)\\.md/\\1/')
588
+
589
+ echo "" >&2
590
+ echo "═══════════════════════════════════════════════════════" >&2
591
+ echo "⚠️ POST-COMPACTION RECOVERY DETECTED" >&2
592
+ echo "═══════════════════════════════════════════════════════" >&2
593
+ echo "" >&2
594
+
595
+ # Display the recovery context
596
+ cat "\$recovery_file" >&2
597
+
598
+ echo "" >&2
599
+ echo "═══════════════════════════════════════════════════════" >&2
600
+ echo "" >&2
601
+
602
+ # Remove after displaying (one-time recovery)
603
+ rm -f "\$recovery_file"
604
+ done
605
+
606
+ # Additional context if recovery was displayed
607
+ if [[ "\$FOUND_RECOVERY" == "true" ]]; then
608
+ echo "IMPORTANT: Your context was compacted. Review the recovery info above." >&2
609
+ echo "Recommended: Run 'pnpm wu:spawn --id \$WU_ID' for fresh full context." >&2
610
+ echo "" >&2
611
+ fi
612
+
613
+ exit 0
614
+ `;
615
+ /* eslint-enable no-useless-escape */
616
+ }
@@ -10,7 +10,7 @@
10
10
  import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import * as yaml from 'yaml';
13
- import { generateEnforcementHooks, generateEnforceWorktreeScript, generateRequireWuScript, generateWarnIncompleteScript, } from './enforcement-generator.js';
13
+ import { generateEnforcementHooks, generateEnforceWorktreeScript, generateRequireWuScript, generateWarnIncompleteScript, generatePreCompactCheckpointScript, generateSessionStartRecoveryScript, HOOK_SCRIPTS, } from './enforcement-generator.js';
14
14
  /**
15
15
  * Read LumenFlow configuration from .lumenflow.config.yaml
16
16
  *
@@ -161,6 +161,48 @@ function mergeHooksIntoSettings(existing, generated) {
161
161
  }
162
162
  }
163
163
  }
164
+ // Merge PreCompact hooks (WU-1394)
165
+ if (generated.preCompact) {
166
+ if (!result.hooks.PreCompact) {
167
+ result.hooks.PreCompact = [];
168
+ }
169
+ for (const newHook of generated.preCompact) {
170
+ const existingIndex = result.hooks.PreCompact.findIndex((h) => h.matcher === newHook.matcher);
171
+ if (existingIndex >= 0) {
172
+ const existing = result.hooks.PreCompact[existingIndex];
173
+ for (const hook of newHook.hooks) {
174
+ const isDuplicate = existing.hooks.some((h) => h.command === hook.command);
175
+ if (!isDuplicate) {
176
+ existing.hooks.push(hook);
177
+ }
178
+ }
179
+ }
180
+ else {
181
+ result.hooks.PreCompact.push(newHook);
182
+ }
183
+ }
184
+ }
185
+ // Merge SessionStart hooks (WU-1394)
186
+ if (generated.sessionStart) {
187
+ if (!result.hooks.SessionStart) {
188
+ result.hooks.SessionStart = [];
189
+ }
190
+ for (const newHook of generated.sessionStart) {
191
+ const existingIndex = result.hooks.SessionStart.findIndex((h) => h.matcher === newHook.matcher);
192
+ if (existingIndex >= 0) {
193
+ const existing = result.hooks.SessionStart[existingIndex];
194
+ for (const hook of newHook.hooks) {
195
+ const isDuplicate = existing.hooks.some((h) => h.command === hook.command);
196
+ if (!isDuplicate) {
197
+ existing.hooks.push(hook);
198
+ }
199
+ }
200
+ }
201
+ else {
202
+ result.hooks.SessionStart.push(newHook);
203
+ }
204
+ }
205
+ }
164
206
  return result;
165
207
  }
166
208
  /**
@@ -191,14 +233,18 @@ export async function syncEnforcementHooks(projectDir) {
191
233
  });
192
234
  // Write hook scripts
193
235
  if (enforcement.block_outside_worktree) {
194
- writeHookScript(projectDir, 'enforce-worktree.sh', generateEnforceWorktreeScript());
236
+ writeHookScript(projectDir, HOOK_SCRIPTS.ENFORCE_WORKTREE, generateEnforceWorktreeScript());
195
237
  }
196
238
  if (enforcement.require_wu_for_edits) {
197
- writeHookScript(projectDir, 'require-wu.sh', generateRequireWuScript());
239
+ writeHookScript(projectDir, HOOK_SCRIPTS.REQUIRE_WU, generateRequireWuScript());
198
240
  }
199
241
  if (enforcement.warn_on_stop_without_wu_done) {
200
- writeHookScript(projectDir, 'warn-incomplete.sh', generateWarnIncompleteScript());
242
+ writeHookScript(projectDir, HOOK_SCRIPTS.WARN_INCOMPLETE, generateWarnIncompleteScript());
201
243
  }
244
+ // Always write recovery hook scripts when enforcement.hooks is enabled (WU-1394)
245
+ // These enable durable context recovery after compaction
246
+ writeHookScript(projectDir, HOOK_SCRIPTS.PRE_COMPACT_CHECKPOINT, generatePreCompactCheckpointScript());
247
+ writeHookScript(projectDir, HOOK_SCRIPTS.SESSION_START_RECOVERY, generateSessionStartRecoveryScript());
202
248
  // Update settings.json
203
249
  const existingSettings = readClaudeSettings(projectDir);
204
250
  const updatedSettings = mergeHooksIntoSettings(existingSettings, generatedHooks);
@@ -215,8 +261,8 @@ export async function removeEnforcementHooks(projectDir) {
215
261
  if (!settings.hooks) {
216
262
  return;
217
263
  }
218
- // Remove enforcement-related hooks
219
- const enforcementCommands = ['enforce-worktree.sh', 'require-wu.sh', 'warn-incomplete.sh'];
264
+ // Remove enforcement-related hooks (includes all LumenFlow hook scripts)
265
+ const enforcementCommands = Object.values(HOOK_SCRIPTS);
220
266
  if (settings.hooks.PreToolUse) {
221
267
  settings.hooks.PreToolUse = settings.hooks.PreToolUse.map((entry) => ({
222
268
  ...entry,