@litmers/cursorflow-orchestrator 0.1.13 → 0.1.15

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +83 -2
  3. package/commands/cursorflow-clean.md +20 -6
  4. package/commands/cursorflow-prepare.md +1 -1
  5. package/commands/cursorflow-resume.md +127 -6
  6. package/commands/cursorflow-run.md +2 -2
  7. package/commands/cursorflow-signal.md +11 -4
  8. package/dist/cli/clean.js +164 -12
  9. package/dist/cli/clean.js.map +1 -1
  10. package/dist/cli/index.d.ts +1 -0
  11. package/dist/cli/index.js +6 -1
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/logs.d.ts +8 -0
  14. package/dist/cli/logs.js +759 -0
  15. package/dist/cli/logs.js.map +1 -0
  16. package/dist/cli/monitor.js +113 -30
  17. package/dist/cli/monitor.js.map +1 -1
  18. package/dist/cli/prepare.js +1 -1
  19. package/dist/cli/resume.js +367 -18
  20. package/dist/cli/resume.js.map +1 -1
  21. package/dist/cli/run.js +9 -0
  22. package/dist/cli/run.js.map +1 -1
  23. package/dist/cli/signal.js +34 -20
  24. package/dist/cli/signal.js.map +1 -1
  25. package/dist/core/orchestrator.d.ts +13 -1
  26. package/dist/core/orchestrator.js +396 -35
  27. package/dist/core/orchestrator.js.map +1 -1
  28. package/dist/core/reviewer.d.ts +2 -0
  29. package/dist/core/reviewer.js +24 -2
  30. package/dist/core/reviewer.js.map +1 -1
  31. package/dist/core/runner.d.ts +9 -3
  32. package/dist/core/runner.js +266 -61
  33. package/dist/core/runner.js.map +1 -1
  34. package/dist/utils/config.js +38 -1
  35. package/dist/utils/config.js.map +1 -1
  36. package/dist/utils/enhanced-logger.d.ts +210 -0
  37. package/dist/utils/enhanced-logger.js +1030 -0
  38. package/dist/utils/enhanced-logger.js.map +1 -0
  39. package/dist/utils/events.d.ts +59 -0
  40. package/dist/utils/events.js +37 -0
  41. package/dist/utils/events.js.map +1 -0
  42. package/dist/utils/git.d.ts +11 -0
  43. package/dist/utils/git.js +40 -0
  44. package/dist/utils/git.js.map +1 -1
  45. package/dist/utils/logger.d.ts +2 -0
  46. package/dist/utils/logger.js +4 -1
  47. package/dist/utils/logger.js.map +1 -1
  48. package/dist/utils/types.d.ts +132 -1
  49. package/dist/utils/webhook.d.ts +5 -0
  50. package/dist/utils/webhook.js +109 -0
  51. package/dist/utils/webhook.js.map +1 -0
  52. package/examples/README.md +1 -1
  53. package/package.json +2 -1
  54. package/scripts/patches/test-cursor-agent.js +1 -1
  55. package/scripts/simple-logging-test.sh +97 -0
  56. package/scripts/test-real-cursor-lifecycle.sh +289 -0
  57. package/scripts/test-real-logging.sh +289 -0
  58. package/scripts/test-streaming-multi-task.sh +247 -0
  59. package/src/cli/clean.ts +170 -13
  60. package/src/cli/index.ts +4 -1
  61. package/src/cli/logs.ts +863 -0
  62. package/src/cli/monitor.ts +123 -30
  63. package/src/cli/prepare.ts +1 -1
  64. package/src/cli/resume.ts +463 -22
  65. package/src/cli/run.ts +10 -0
  66. package/src/cli/signal.ts +43 -27
  67. package/src/core/orchestrator.ts +458 -36
  68. package/src/core/reviewer.ts +40 -4
  69. package/src/core/runner.ts +293 -60
  70. package/src/utils/config.ts +41 -1
  71. package/src/utils/enhanced-logger.ts +1166 -0
  72. package/src/utils/events.ts +117 -0
  73. package/src/utils/git.ts +40 -0
  74. package/src/utils/logger.ts +4 -1
  75. package/src/utils/types.ts +160 -1
  76. package/src/utils/webhook.ts +85 -0
@@ -0,0 +1,247 @@
1
+ #!/bin/bash
2
+ #
3
+ # Comprehensive Streaming Output Test with Multi-Task Dependencies
4
+ #
5
+ # This test verifies:
6
+ # 1. Streaming output from cursor-agent (not just final JSON)
7
+ # 2. Multiple tasks execution in sequence
8
+ # 3. Dependency handling between tasks
9
+ # 4. Raw log capture and parsed log quality
10
+ #
11
+
12
+ set -e
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
16
+
17
+ # Colors
18
+ RED='\033[0;31m'
19
+ GREEN='\033[0;32m'
20
+ YELLOW='\033[1;33m'
21
+ CYAN='\033[0;36m'
22
+ MAGENTA='\033[0;35m'
23
+ NC='\033[0m'
24
+
25
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
26
+ echo -e "${CYAN} 🧪 Streaming Multi-Task Test${NC}"
27
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
28
+ echo ""
29
+
30
+ # Check cursor-agent
31
+ if ! command -v cursor-agent &> /dev/null; then
32
+ echo -e "${RED}❌ cursor-agent not found${NC}"
33
+ exit 1
34
+ fi
35
+
36
+ # Check auth
37
+ echo -e "${YELLOW}Checking cursor-agent authentication...${NC}"
38
+ if ! cursor-agent create-chat &> /dev/null; then
39
+ echo -e "${RED}❌ cursor-agent authentication failed${NC}"
40
+ exit 1
41
+ fi
42
+ echo -e "${GREEN}✓ cursor-agent authenticated${NC}"
43
+
44
+ cd "$PROJECT_ROOT"
45
+
46
+ # Build
47
+ echo -e "${YELLOW}Building project...${NC}"
48
+ npm run build > /dev/null 2>&1
49
+ echo -e "${GREEN}✓ Build complete${NC}"
50
+
51
+ # Setup test directory
52
+ TEST_DIR="$PROJECT_ROOT/_test-streaming"
53
+ rm -rf "$TEST_DIR"
54
+ mkdir -p "$TEST_DIR/lane"
55
+
56
+ # Create multi-task configuration with dependencies
57
+ cat > "$TEST_DIR/multi-task.json" << 'EOF'
58
+ {
59
+ "baseBranch": "main",
60
+ "branchPrefix": "test/stream-",
61
+ "timeout": 120000,
62
+ "dependencyPolicy": {
63
+ "allowDependencyChange": false,
64
+ "lockfileReadOnly": true
65
+ },
66
+ "tasks": [
67
+ {
68
+ "name": "task-1-analyze",
69
+ "prompt": "Analyze this codebase. List the top 3 most important files and briefly explain what each does. Keep your response under 200 words. Do not make any file changes.",
70
+ "model": "sonnet-4.5"
71
+ },
72
+ {
73
+ "name": "task-2-summary",
74
+ "prompt": "Based on your previous analysis, write a 2-sentence summary of what this project does. Do not make any file changes.",
75
+ "model": "sonnet-4.5"
76
+ }
77
+ ]
78
+ }
79
+ EOF
80
+
81
+ echo -e "${GREEN}✓ Test configuration created (2 tasks)${NC}"
82
+ echo ""
83
+
84
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
85
+ echo -e "${CYAN} Running orchestration with STREAMING enabled...${NC}"
86
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
87
+ echo ""
88
+
89
+ LANE_DIR="$TEST_DIR/lane"
90
+
91
+ # Run spawnLane directly to test streaming
92
+ node -e "
93
+ const { spawnLane, waitChild } = require('./dist/core/orchestrator');
94
+ const fs = require('fs');
95
+ const path = require('path');
96
+
97
+ const tasksFile = path.join('$TEST_DIR', 'multi-task.json');
98
+ const laneRunDir = '$LANE_DIR';
99
+
100
+ console.log('Spawning lane with streaming output enabled...');
101
+ console.log('');
102
+
103
+ const result = spawnLane({
104
+ laneName: 'stream-test',
105
+ tasksFile,
106
+ laneRunDir,
107
+ executor: 'cursor-agent',
108
+ startIndex: 0,
109
+ enhancedLogConfig: {
110
+ enabled: true,
111
+ stripAnsi: true,
112
+ streamOutput: true, // Enable streaming!
113
+ addTimestamps: true,
114
+ keepRawLogs: true,
115
+ writeJsonLog: true,
116
+ timestampFormat: 'iso',
117
+ },
118
+ });
119
+
120
+ console.log('Lane spawned, PID:', result.child.pid);
121
+ console.log('Waiting for completion (may take 2-3 minutes)...');
122
+ console.log('');
123
+
124
+ const timeout = setTimeout(() => {
125
+ console.log('\\nTimeout reached (3 min)');
126
+ result.child.kill('SIGTERM');
127
+ }, 180000);
128
+
129
+ waitChild(result.child).then((code) => {
130
+ clearTimeout(timeout);
131
+ console.log('');
132
+ console.log('Lane completed with exit code:', code);
133
+ result.logManager?.close();
134
+ process.exit(code === 0 ? 0 : 1);
135
+ }).catch(e => {
136
+ clearTimeout(timeout);
137
+ console.error('Error:', e);
138
+ process.exit(1);
139
+ });
140
+ " 2>&1
141
+
142
+ EXIT_CODE=$?
143
+
144
+ echo ""
145
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
146
+ echo -e "${CYAN} 📋 Log File Analysis${NC}"
147
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
148
+ echo ""
149
+
150
+ # Check files
151
+ echo -e "${YELLOW}Log files created:${NC}"
152
+ ls -la "$LANE_DIR"/*.log "$LANE_DIR"/*.jsonl 2>/dev/null || echo "No log files found"
153
+ echo ""
154
+
155
+ # Count streaming entries in JSON log
156
+ if [ -f "$LANE_DIR/terminal.jsonl" ]; then
157
+ TOTAL_ENTRIES=$(wc -l < "$LANE_DIR/terminal.jsonl")
158
+ STDOUT_ENTRIES=$(grep -c '"level":"stdout"' "$LANE_DIR/terminal.jsonl" || echo "0")
159
+ STREAMING_ENTRIES=$(grep -c '"type":' "$LANE_DIR/terminal.jsonl" || echo "0")
160
+
161
+ echo -e "${YELLOW}JSON Log Statistics:${NC}"
162
+ echo " Total entries: $TOTAL_ENTRIES"
163
+ echo " stdout entries: $STDOUT_ENTRIES"
164
+ echo " Entries with streaming 'type': $STREAMING_ENTRIES"
165
+ echo ""
166
+ fi
167
+
168
+ # Check for streaming content markers in raw log
169
+ if [ -f "$LANE_DIR/terminal-raw.log" ]; then
170
+ RAW_SIZE=$(wc -c < "$LANE_DIR/terminal-raw.log")
171
+ RAW_LINES=$(wc -l < "$LANE_DIR/terminal-raw.log")
172
+
173
+ # Look for streaming indicators
174
+ HAS_TYPE_TEXT=$(grep -c '"type":"text"' "$LANE_DIR/terminal-raw.log" 2>/dev/null || echo "0")
175
+ HAS_TYPE_RESULT=$(grep -c '"type":"result"' "$LANE_DIR/terminal-raw.log" 2>/dev/null || echo "0")
176
+ HAS_CONTENT=$(grep -c '"content":' "$LANE_DIR/terminal-raw.log" 2>/dev/null || echo "0")
177
+
178
+ echo -e "${YELLOW}Raw Log Statistics:${NC}"
179
+ echo " Size: $RAW_SIZE bytes"
180
+ echo " Lines: $RAW_LINES"
181
+ echo ""
182
+ echo -e "${YELLOW}Streaming Markers Found:${NC}"
183
+ echo " type:'text' entries: $HAS_TYPE_TEXT"
184
+ echo " type:'result' entries: $HAS_TYPE_RESULT"
185
+ echo " content entries: $HAS_CONTENT"
186
+ echo ""
187
+ fi
188
+
189
+ # Show sample of streaming output
190
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
191
+ echo -e "${CYAN} 📝 Sample Streaming Output (first 30 lines of stdout)${NC}"
192
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
193
+ echo ""
194
+
195
+ if [ -f "$LANE_DIR/terminal.log" ]; then
196
+ # Show lines containing cursor-agent output (streaming JSON)
197
+ echo -e "${MAGENTA}Looking for streaming JSON output...${NC}"
198
+ grep -E '^\[.*\] \{"type":' "$LANE_DIR/terminal.log" | head -20 || echo "No streaming JSON found"
199
+ echo ""
200
+ fi
201
+
202
+ # Test the logs CLI command
203
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
204
+ echo -e "${CYAN} 🔍 Testing cursorflow logs command${NC}"
205
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
206
+ echo ""
207
+
208
+ # Create a fake runs directory structure for the CLI
209
+ FAKE_RUN_DIR="$TEST_DIR/runs/run-test"
210
+ mkdir -p "$FAKE_RUN_DIR/lanes"
211
+ ln -sf "$LANE_DIR" "$FAKE_RUN_DIR/lanes/stream-test"
212
+
213
+ echo -e "${YELLOW}Testing: cursorflow logs --tail 15${NC}"
214
+ node dist/cli/index.js logs "$FAKE_RUN_DIR" --tail 15 2>&1 || true
215
+
216
+ echo ""
217
+ echo -e "${YELLOW}Testing: cursorflow logs --filter 'type.*text'${NC}"
218
+ node dist/cli/index.js logs "$FAKE_RUN_DIR" --filter '"type":"text"' --tail 10 2>&1 || true
219
+
220
+ echo ""
221
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
222
+ echo -e "${CYAN} 📊 Test Summary${NC}"
223
+ echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
224
+ echo ""
225
+
226
+ # Determine if streaming worked
227
+ if [ "$HAS_TYPE_TEXT" -gt "0" ] || [ "$HAS_CONTENT" -gt "10" ]; then
228
+ echo -e "${GREEN}✅ Streaming output captured successfully!${NC}"
229
+ echo " Found $HAS_TYPE_TEXT text stream entries"
230
+ echo " Found $HAS_CONTENT content entries"
231
+ else
232
+ echo -e "${YELLOW}⚠️ Limited streaming content detected${NC}"
233
+ echo " This may indicate:"
234
+ echo " - stream-json format not outputting expected data"
235
+ echo " - Or cursor-agent only provides final results"
236
+ fi
237
+
238
+ echo ""
239
+ echo "Log files saved at: $LANE_DIR"
240
+ echo ""
241
+
242
+ # Cleanup git
243
+ git worktree remove _cursorflow/worktrees/test/stream-* --force 2>/dev/null || true
244
+ git branch -D $(git branch | grep "test/stream-") 2>/dev/null || true
245
+
246
+ exit $EXIT_CODE
247
+
package/src/cli/clean.ts CHANGED
@@ -8,7 +8,7 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as logger from '../utils/logger';
10
10
  import * as git from '../utils/git';
11
- import { loadConfig, getLogsDir } from '../utils/config';
11
+ import { loadConfig, getLogsDir, getTasksDir } from '../utils/config';
12
12
 
13
13
  interface CleanOptions {
14
14
  type?: string;
@@ -17,6 +17,8 @@ interface CleanOptions {
17
17
  force: boolean;
18
18
  all: boolean;
19
19
  help: boolean;
20
+ keepLatest: boolean;
21
+ includeLatest: boolean;
20
22
  }
21
23
 
22
24
  function printHelp(): void {
@@ -29,26 +31,43 @@ Types:
29
31
  branches Remove local feature branches
30
32
  worktrees Remove temporary Git worktrees
31
33
  logs Clear log directories
34
+ tasks Clear task directories
32
35
  all Remove all of the above (default)
33
36
 
34
37
  Options:
35
38
  --dry-run Show what would be removed without deleting
36
39
  --force Force removal (ignore uncommitted changes)
40
+ --include-latest Also remove the most recent item (by default, latest is kept)
37
41
  --help, -h Show help
38
42
  `);
39
43
  }
40
44
 
41
45
  function parseArgs(args: string[]): CleanOptions {
46
+ const includeLatest = args.includes('--include-latest');
42
47
  return {
43
- type: args.find(a => ['branches', 'worktrees', 'logs', 'all'].includes(a)),
48
+ type: args.find(a => ['branches', 'worktrees', 'logs', 'tasks', 'all'].includes(a)),
44
49
  pattern: null,
45
50
  dryRun: args.includes('--dry-run'),
46
51
  force: args.includes('--force'),
47
52
  all: args.includes('--all'),
48
53
  help: args.includes('--help') || args.includes('-h'),
54
+ keepLatest: !includeLatest, // Default: keep latest, unless --include-latest is specified
55
+ includeLatest,
49
56
  };
50
57
  }
51
58
 
59
+ /**
60
+ * Get the modification time of a path (directory or file)
61
+ */
62
+ function getModTime(targetPath: string): number {
63
+ try {
64
+ const stat = fs.statSync(targetPath);
65
+ return stat.mtimeMs;
66
+ } catch {
67
+ return 0;
68
+ }
69
+ }
70
+
52
71
  async function clean(args: string[]): Promise<void> {
53
72
  const options = parseArgs(args);
54
73
 
@@ -68,12 +87,15 @@ async function clean(args: string[]): Promise<void> {
68
87
  await cleanWorktrees(config, repoRoot, options);
69
88
  await cleanBranches(config, repoRoot, options);
70
89
  await cleanLogs(config, options);
90
+ await cleanTasks(config, options);
71
91
  } else if (type === 'worktrees') {
72
92
  await cleanWorktrees(config, repoRoot, options);
73
93
  } else if (type === 'branches') {
74
94
  await cleanBranches(config, repoRoot, options);
75
95
  } else if (type === 'logs') {
76
96
  await cleanLogs(config, options);
97
+ } else if (type === 'tasks') {
98
+ await cleanTasks(config, options);
77
99
  }
78
100
 
79
101
  logger.success('\n✨ Cleaning complete!');
@@ -84,7 +106,7 @@ async function cleanWorktrees(config: any, repoRoot: string, options: CleanOptio
84
106
  const worktrees = git.listWorktrees(repoRoot);
85
107
 
86
108
  const worktreeRoot = path.join(repoRoot, config.worktreeRoot || '_cursorflow/worktrees');
87
- const toRemove = worktrees.filter(wt => {
109
+ let toRemove = worktrees.filter(wt => {
88
110
  // Skip main worktree
89
111
  if (wt.path === repoRoot) return false;
90
112
 
@@ -99,6 +121,15 @@ async function cleanWorktrees(config: any, repoRoot: string, options: CleanOptio
99
121
  return;
100
122
  }
101
123
 
124
+ // If keepLatest is set, keep the most recent worktree
125
+ if (options.keepLatest && toRemove.length > 1) {
126
+ // Sort by modification time (newest first)
127
+ toRemove.sort((a, b) => getModTime(b.path) - getModTime(a.path));
128
+ const kept = toRemove[0];
129
+ toRemove = toRemove.slice(1);
130
+ logger.info(` Keeping latest worktree: ${kept.path} (${kept.branch || 'no branch'})`);
131
+ }
132
+
102
133
  for (const wt of toRemove) {
103
134
  if (options.dryRun) {
104
135
  logger.info(` [DRY RUN] Would remove worktree: ${wt.path} (${wt.branch || 'no branch'})`);
@@ -123,6 +154,21 @@ async function cleanWorktrees(config: any, repoRoot: string, options: CleanOptio
123
154
  }
124
155
  }
125
156
 
157
+ /**
158
+ * Get the commit timestamp of a branch
159
+ */
160
+ function getBranchCommitTime(branch: string, repoRoot: string): number {
161
+ try {
162
+ const result = git.runGitResult(['log', '-1', '--format=%ct', branch], { cwd: repoRoot });
163
+ if (result.success && result.stdout.trim()) {
164
+ return parseInt(result.stdout.trim(), 10) * 1000; // Convert to milliseconds
165
+ }
166
+ } catch {
167
+ // Ignore errors
168
+ }
169
+ return 0;
170
+ }
171
+
126
172
  async function cleanBranches(config: any, repoRoot: string, options: CleanOptions) {
127
173
  logger.info('\nChecking branches...');
128
174
 
@@ -136,13 +182,22 @@ async function cleanBranches(config: any, repoRoot: string, options: CleanOption
136
182
  .filter(b => b && b !== 'main' && b !== 'master');
137
183
 
138
184
  const prefix = config.branchPrefix || 'feature/';
139
- const toDelete = branches.filter(b => b.startsWith(prefix));
185
+ let toDelete = branches.filter(b => b.startsWith(prefix));
140
186
 
141
187
  if (toDelete.length === 0) {
142
188
  logger.info(' No branches found to clean.');
143
189
  return;
144
190
  }
145
191
 
192
+ // If keepLatest is set, keep the most recent branch
193
+ if (options.keepLatest && toDelete.length > 1) {
194
+ // Sort by commit time (newest first)
195
+ toDelete.sort((a, b) => getBranchCommitTime(b, repoRoot) - getBranchCommitTime(a, repoRoot));
196
+ const kept = toDelete[0];
197
+ toDelete = toDelete.slice(1);
198
+ logger.info(` Keeping latest branch: ${kept}`);
199
+ }
200
+
146
201
  for (const branch of toDelete) {
147
202
  if (options.dryRun) {
148
203
  logger.info(` [DRY RUN] Would delete branch: ${branch}`);
@@ -166,16 +221,118 @@ async function cleanLogs(config: any, options: CleanOptions) {
166
221
  return;
167
222
  }
168
223
 
169
- if (options.dryRun) {
170
- logger.info(` [DRY RUN] Would remove logs directory: ${logsDir}`);
224
+ // If keepLatest is set, keep the most recent log directory/file
225
+ if (options.keepLatest) {
226
+ const entries = fs.readdirSync(logsDir, { withFileTypes: true });
227
+ let items = entries.map(entry => ({
228
+ name: entry.name,
229
+ path: path.join(logsDir, entry.name),
230
+ isDir: entry.isDirectory(),
231
+ mtime: getModTime(path.join(logsDir, entry.name))
232
+ }));
233
+
234
+ if (items.length <= 1) {
235
+ logger.info(' Only one or no log entries found, nothing to clean.');
236
+ return;
237
+ }
238
+
239
+ // Sort by modification time (newest first)
240
+ items.sort((a, b) => b.mtime - a.mtime);
241
+ const kept = items[0];
242
+ const toRemove = items.slice(1);
243
+
244
+ logger.info(` Keeping latest log: ${kept.name}`);
245
+
246
+ for (const item of toRemove) {
247
+ if (options.dryRun) {
248
+ logger.info(` [DRY RUN] Would remove log: ${item.name}`);
249
+ } else {
250
+ try {
251
+ logger.info(` Removing log: ${item.name}...`);
252
+ fs.rmSync(item.path, { recursive: true, force: true });
253
+ } catch (e: any) {
254
+ logger.error(` Failed to remove log ${item.name}: ${e.message}`);
255
+ }
256
+ }
257
+ }
171
258
  } else {
172
- try {
173
- logger.info(` Removing logs...`);
174
- fs.rmSync(logsDir, { recursive: true, force: true });
175
- fs.mkdirSync(logsDir, { recursive: true });
176
- logger.info(` Logs cleared.`);
177
- } catch (e: any) {
178
- logger.error(` Failed to clean logs: ${e.message}`);
259
+ if (options.dryRun) {
260
+ logger.info(` [DRY RUN] Would remove logs directory: ${logsDir}`);
261
+ } else {
262
+ try {
263
+ logger.info(` Removing logs...`);
264
+ fs.rmSync(logsDir, { recursive: true, force: true });
265
+ fs.mkdirSync(logsDir, { recursive: true });
266
+ logger.info(` Logs cleared.`);
267
+ } catch (e: any) {
268
+ logger.error(` Failed to clean logs: ${e.message}`);
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ async function cleanTasks(config: any, options: CleanOptions) {
275
+ const tasksDir = getTasksDir(config);
276
+ logger.info(`\nChecking tasks in ${tasksDir}...`);
277
+
278
+ if (!fs.existsSync(tasksDir)) {
279
+ logger.info(' Tasks directory does not exist.');
280
+ return;
281
+ }
282
+
283
+ // If keepLatest is set, keep the most recent task directory/file
284
+ if (options.keepLatest) {
285
+ const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
286
+ // Skip example task if it exists and there are other tasks
287
+ let items = entries
288
+ .filter(entry => entry.name !== 'example')
289
+ .map(entry => ({
290
+ name: entry.name,
291
+ path: path.join(tasksDir, entry.name),
292
+ isDir: entry.isDirectory(),
293
+ mtime: getModTime(path.join(tasksDir, entry.name))
294
+ }));
295
+
296
+ if (items.length <= 1) {
297
+ logger.info(' Only one or no user task entries found, nothing to clean.');
298
+ return;
299
+ }
300
+
301
+ // Sort by modification time (newest first)
302
+ items.sort((a, b) => b.mtime - a.mtime);
303
+ const kept = items[0];
304
+ const toRemove = items.slice(1);
305
+
306
+ logger.info(` Keeping latest task: ${kept.name}`);
307
+
308
+ for (const item of toRemove) {
309
+ if (options.dryRun) {
310
+ logger.info(` [DRY RUN] Would remove task: ${item.name}`);
311
+ } else {
312
+ try {
313
+ logger.info(` Removing task: ${item.name}...`);
314
+ fs.rmSync(item.path, { recursive: true, force: true });
315
+ } catch (e: any) {
316
+ logger.error(` Failed to remove task ${item.name}: ${e.message}`);
317
+ }
318
+ }
319
+ }
320
+ } else {
321
+ if (options.dryRun) {
322
+ logger.info(` [DRY RUN] Would remove tasks in directory: ${tasksDir} (except example)`);
323
+ } else {
324
+ try {
325
+ const entries = fs.readdirSync(tasksDir, { withFileTypes: true });
326
+ for (const entry of entries) {
327
+ if (entry.name === 'example') continue;
328
+ const itemPath = path.join(tasksDir, entry.name);
329
+ logger.info(` Removing task: ${entry.name}...`);
330
+ fs.rmSync(itemPath, { recursive: true, force: true });
331
+ }
332
+ logger.info(` Tasks cleared.`);
333
+ } catch (e: any) {
334
+ logger.error(` Failed to clean tasks: ${e.message}`);
335
+ }
179
336
  }
180
337
  }
181
338
  }
package/src/cli/index.ts CHANGED
@@ -20,6 +20,7 @@ const COMMANDS: Record<string, CommandFn> = {
20
20
  doctor: require('./doctor'),
21
21
  signal: require('./signal'),
22
22
  models: require('./models'),
23
+ logs: require('./logs'),
23
24
  setup: require('./setup-commands').main,
24
25
  'setup-commands': require('./setup-commands').main,
25
26
  };
@@ -37,10 +38,11 @@ function printHelp(): void {
37
38
  \x1b[33mrun\x1b[0m <tasks-dir> [options] Run orchestration (DAG-based)
38
39
  \x1b[33mmonitor\x1b[0m [run-dir] [options] \x1b[36mInteractive\x1b[0m lane dashboard
39
40
  \x1b[33mclean\x1b[0m <type> [options] Clean branches/worktrees/logs
40
- \x1b[33mresume\x1b[0m <lane> [options] Resume interrupted lane
41
+ \x1b[33mresume\x1b[0m [lane] [options] Resume lane(s) - use --all for batch resume
41
42
  \x1b[33mdoctor\x1b[0m [options] Check environment and preflight
42
43
  \x1b[33msignal\x1b[0m <lane> <msg> Directly intervene in a running lane
43
44
  \x1b[33mmodels\x1b[0m [options] List available AI models
45
+ \x1b[33mlogs\x1b[0m [run-dir] [options] View, export, and follow logs
44
46
 
45
47
  \x1b[1mGLOBAL OPTIONS\x1b[0m
46
48
  --config <path> Config file path
@@ -114,3 +116,4 @@ if (require.main === module) {
114
116
 
115
117
  export default main;
116
118
  export { main };
119
+ export { events } from '../utils/events';