@lumenflow/cli 2.9.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.
package/README.md CHANGED
@@ -134,7 +134,7 @@ This package provides CLI commands for the LumenFlow workflow framework, includi
134
134
  | Command | Description |
135
135
  | ----------------- | ------------------------------------------------------------------------------------------ |
136
136
  | `gates` | Run quality gates with support for docs-only mode, incremental linting, and tiered testing |
137
- | `lumenflow-gates` | Alias for `gates` - run quality gates |
137
+ | `lumenflow-gates` | Alias for `gates` - run quality gates with support for docs-only mode and tiered testing |
138
138
 
139
139
  ### System & Setup
140
140
 
@@ -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,
package/dist/init.js CHANGED
@@ -12,7 +12,7 @@ import * as path from 'node:path';
12
12
  import * as yaml from 'yaml';
13
13
  import { execFileSync } from 'node:child_process';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { getDefaultConfig, createWUParser, WU_OPTIONS } from '@lumenflow/core';
15
+ import { getDefaultConfig, createWUParser, WU_OPTIONS, CLAUDE_HOOKS } from '@lumenflow/core';
16
16
  // WU-1067: Import GATE_PRESETS for --preset support
17
17
  import { GATE_PRESETS } from '@lumenflow/core/dist/gates-config.js';
18
18
  // WU-1171: Import merge block utilities
@@ -2648,7 +2648,34 @@ async function scaffoldClientFiles(targetDir, options, result, tokens, client) {
2648
2648
  await createFile(path.join(targetDir, 'CLAUDE.md'), processTemplate(CLAUDE_MD_TEMPLATE, tokens), fileMode, result, targetDir);
2649
2649
  await createDirectory(path.join(targetDir, CLAUDE_AGENTS_DIR), result, targetDir);
2650
2650
  await createFile(path.join(targetDir, CLAUDE_AGENTS_DIR, '.gitkeep'), '', options.force ? 'force' : 'skip', result, targetDir);
2651
- await createFile(path.join(targetDir, CLAUDE_DIR, 'settings.json'), CLAUDE_SETTINGS_TEMPLATE, options.force ? 'force' : 'skip', result, targetDir);
2651
+ // WU-1394: Load settings.json from template (includes PreCompact/SessionStart hooks)
2652
+ let settingsContent;
2653
+ try {
2654
+ settingsContent = loadTemplate(CLAUDE_HOOKS.TEMPLATES.SETTINGS);
2655
+ }
2656
+ catch {
2657
+ settingsContent = CLAUDE_SETTINGS_TEMPLATE;
2658
+ }
2659
+ await createFile(path.join(targetDir, CLAUDE_DIR, 'settings.json'), settingsContent, options.force ? 'force' : 'skip', result, targetDir);
2660
+ // WU-1394: Scaffold recovery hook scripts with executable permissions
2661
+ const hooksDir = path.join(targetDir, CLAUDE_DIR, 'hooks');
2662
+ await createDirectory(hooksDir, result, targetDir);
2663
+ // Load and write pre-compact-checkpoint.sh
2664
+ try {
2665
+ const preCompactScript = loadTemplate(CLAUDE_HOOKS.TEMPLATES.PRE_COMPACT);
2666
+ await createExecutableScript(path.join(hooksDir, CLAUDE_HOOKS.SCRIPTS.PRE_COMPACT_CHECKPOINT), preCompactScript, options.force ? 'force' : 'skip', result, targetDir);
2667
+ }
2668
+ catch {
2669
+ // Template not found - hook won't be scaffolded
2670
+ }
2671
+ // Load and write session-start-recovery.sh
2672
+ try {
2673
+ const sessionStartScript = loadTemplate(CLAUDE_HOOKS.TEMPLATES.SESSION_START);
2674
+ await createExecutableScript(path.join(hooksDir, CLAUDE_HOOKS.SCRIPTS.SESSION_START_RECOVERY), sessionStartScript, options.force ? 'force' : 'skip', result, targetDir);
2675
+ }
2676
+ catch {
2677
+ // Template not found - hook won't be scaffolded
2678
+ }
2652
2679
  // WU-1083: Scaffold Claude skills
2653
2680
  await scaffoldClaudeSkills(targetDir, options, result, tokens);
2654
2681
  // WU-1083: Scaffold agent onboarding docs for Claude vendor (even without --full)
@@ -2774,6 +2801,28 @@ function writeNewFile(filePath, content, result, relativePath) {
2774
2801
  fs.writeFileSync(filePath, content);
2775
2802
  result.created.push(relativePath);
2776
2803
  }
2804
+ /**
2805
+ * WU-1394: Create an executable script file with proper permissions
2806
+ * Similar to createFile but sets 0o755 mode for shell scripts
2807
+ */
2808
+ async function createExecutableScript(filePath, content, mode, result, targetDir) {
2809
+ const relativePath = getRelativePath(targetDir, filePath);
2810
+ const resolvedMode = resolveBooleanToFileMode(mode);
2811
+ result.merged = result.merged ?? [];
2812
+ result.warnings = result.warnings ?? [];
2813
+ const fileExists = fs.existsSync(filePath);
2814
+ if (fileExists && resolvedMode === 'skip') {
2815
+ result.skipped.push(relativePath);
2816
+ return;
2817
+ }
2818
+ // Write file with executable permissions
2819
+ const parentDir = path.dirname(filePath);
2820
+ if (!fs.existsSync(parentDir)) {
2821
+ fs.mkdirSync(parentDir, { recursive: true });
2822
+ }
2823
+ fs.writeFileSync(filePath, content, { mode: 0o755 });
2824
+ result.created.push(relativePath);
2825
+ }
2777
2826
  /**
2778
2827
  * CLI entry point
2779
2828
  * WU-1085: Updated to use parseInitOptions for proper --help support
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Memory Recover CLI (WU-1390)
4
+ *
5
+ * Generate post-compaction recovery context for agents that have lost
6
+ * their LumenFlow instructions due to context compaction.
7
+ *
8
+ * Usage:
9
+ * pnpm mem:recover --wu WU-XXXX [options]
10
+ *
11
+ * Options:
12
+ * --max-size <bytes> Maximum output size in bytes (default: 2048)
13
+ * --format <json|human> Output format (default: human)
14
+ * --quiet Suppress header/footer output
15
+ *
16
+ * The recovery context includes:
17
+ * - Last checkpoint for the WU
18
+ * - Compact constraints (7 rules)
19
+ * - Essential CLI commands
20
+ * - Guidance to spawn fresh agent
21
+ *
22
+ * @see {@link packages/@lumenflow/memory/src/mem-recover-core.ts} - Core logic
23
+ * @see {@link packages/@lumenflow/memory/__tests__/mem-recover-core.test.ts} - Tests
24
+ */
25
+ import fs from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { generateRecoveryContext } from '@lumenflow/memory/dist/mem-recover-core.js';
28
+ import { createWUParser, WU_OPTIONS } from '@lumenflow/core/dist/arg-parser.js';
29
+ import { EXIT_CODES, LUMENFLOW_PATHS } from '@lumenflow/core/dist/wu-constants.js';
30
+ /**
31
+ * Log prefix for mem:recover output
32
+ */
33
+ const LOG_PREFIX = '[mem:recover]';
34
+ /**
35
+ * Tool name for audit logging
36
+ */
37
+ const TOOL_NAME = 'mem:recover';
38
+ /**
39
+ * Valid output formats
40
+ */
41
+ const VALID_FORMATS = ['json', 'human'];
42
+ /**
43
+ * CLI argument options specific to mem:recover
44
+ */
45
+ const CLI_OPTIONS = {
46
+ maxSize: {
47
+ name: 'maxSize',
48
+ flags: '-m, --max-size <bytes>',
49
+ description: 'Maximum output size in bytes (default: 2048)',
50
+ },
51
+ format: {
52
+ name: 'format',
53
+ flags: '-f, --format <format>',
54
+ description: 'Output format: json or human (default: human)',
55
+ },
56
+ baseDir: {
57
+ name: 'baseDir',
58
+ flags: '-d, --base-dir <path>',
59
+ description: 'Base directory (defaults to current directory)',
60
+ },
61
+ quiet: {
62
+ name: 'quiet',
63
+ flags: '-q, --quiet',
64
+ description: 'Suppress header/footer output, only show recovery context',
65
+ },
66
+ };
67
+ /**
68
+ * Write audit log entry for tool execution
69
+ */
70
+ async function writeAuditLog(baseDir, entry) {
71
+ try {
72
+ const logPath = path.join(baseDir, LUMENFLOW_PATHS.AUDIT_LOG);
73
+ const logDir = path.dirname(logPath);
74
+ await fs.mkdir(logDir, { recursive: true });
75
+ const line = `${JSON.stringify(entry)}\n`;
76
+ await fs.appendFile(logPath, line, 'utf-8');
77
+ }
78
+ catch {
79
+ // Audit logging is non-fatal - silently ignore errors
80
+ }
81
+ }
82
+ /**
83
+ * Validate and parse a positive integer argument
84
+ */
85
+ function parsePositiveInt(value, optionName) {
86
+ if (!value) {
87
+ return undefined;
88
+ }
89
+ const parsed = parseInt(value, 10);
90
+ if (isNaN(parsed) || parsed <= 0) {
91
+ throw new Error(`Invalid ${optionName} value: "${value}". Must be a positive integer.`);
92
+ }
93
+ return parsed;
94
+ }
95
+ /**
96
+ * Validate format argument
97
+ */
98
+ function validateFormat(format) {
99
+ if (!format) {
100
+ return 'human';
101
+ }
102
+ if (!VALID_FORMATS.includes(format)) {
103
+ throw new Error(`Invalid --format value: "${format}". Valid formats: ${VALID_FORMATS.join(', ')}`);
104
+ }
105
+ return format;
106
+ }
107
+ /**
108
+ * Print output in human-readable format
109
+ */
110
+ function printHumanFormat(result, wuId, quiet) {
111
+ if (!quiet) {
112
+ console.log(`${LOG_PREFIX} Recovery context for ${wuId}:`);
113
+ console.log('');
114
+ }
115
+ console.log(result.context);
116
+ if (!quiet) {
117
+ console.log('');
118
+ console.log(`${LOG_PREFIX} ${result.size} bytes${result.truncated ? ' (truncated)' : ''}`);
119
+ }
120
+ }
121
+ /**
122
+ * Print output in JSON format
123
+ */
124
+ function printJsonFormat(result, wuId) {
125
+ const output = {
126
+ wuId,
127
+ context: result.context,
128
+ size: result.size,
129
+ truncated: result.truncated,
130
+ };
131
+ console.log(JSON.stringify(output, null, 2));
132
+ }
133
+ /**
134
+ * Main CLI entry point
135
+ */
136
+ async function main() {
137
+ const args = createWUParser({
138
+ name: 'mem-recover',
139
+ description: 'Generate post-compaction recovery context for agents',
140
+ options: [
141
+ WU_OPTIONS.wu,
142
+ CLI_OPTIONS.maxSize,
143
+ CLI_OPTIONS.format,
144
+ CLI_OPTIONS.baseDir,
145
+ CLI_OPTIONS.quiet,
146
+ ],
147
+ required: ['wu'],
148
+ });
149
+ const baseDir = args.baseDir || process.cwd();
150
+ const startedAt = new Date().toISOString();
151
+ const startTime = Date.now();
152
+ let maxSize;
153
+ let format;
154
+ // Validate arguments
155
+ try {
156
+ maxSize = parsePositiveInt(args.maxSize, '--max-size');
157
+ format = validateFormat(args.format);
158
+ }
159
+ catch (err) {
160
+ const error = err;
161
+ console.error(`${LOG_PREFIX} Error: ${error.message}`);
162
+ process.exit(EXIT_CODES.ERROR);
163
+ }
164
+ let result;
165
+ let error = null;
166
+ try {
167
+ result = await generateRecoveryContext({
168
+ wuId: args.wu,
169
+ baseDir,
170
+ maxSize,
171
+ });
172
+ }
173
+ catch (err) {
174
+ const e = err;
175
+ error = e.message;
176
+ result = {
177
+ success: false,
178
+ context: '',
179
+ size: 0,
180
+ truncated: false,
181
+ };
182
+ }
183
+ const durationMs = Date.now() - startTime;
184
+ // Write audit log entry
185
+ await writeAuditLog(baseDir, {
186
+ tool: TOOL_NAME,
187
+ status: error ? 'failed' : 'success',
188
+ startedAt,
189
+ completedAt: new Date().toISOString(),
190
+ durationMs,
191
+ input: {
192
+ baseDir,
193
+ wuId: args.wu,
194
+ maxSize,
195
+ format,
196
+ quiet: args.quiet,
197
+ },
198
+ output: result.success
199
+ ? {
200
+ contextSize: result.size,
201
+ truncated: result.truncated,
202
+ }
203
+ : null,
204
+ error: error ? { message: error } : null,
205
+ });
206
+ if (error) {
207
+ console.error(`${LOG_PREFIX} Error: ${error}`);
208
+ process.exit(EXIT_CODES.ERROR);
209
+ }
210
+ // Print output based on format
211
+ if (format === 'json') {
212
+ printJsonFormat(result, args.wu);
213
+ }
214
+ else {
215
+ printHumanFormat(result, args.wu, !!args.quiet);
216
+ }
217
+ }
218
+ main().catch((e) => {
219
+ console.error(`${LOG_PREFIX} ${e.message}`);
220
+ process.exit(EXIT_CODES.ERROR);
221
+ });