@litmers/cursorflow-orchestrator 0.1.18 → 0.1.20

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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +25 -7
  3. package/dist/cli/clean.js +7 -6
  4. package/dist/cli/clean.js.map +1 -1
  5. package/dist/cli/index.js +5 -1
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/init.js +7 -6
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/cli/logs.js +50 -42
  10. package/dist/cli/logs.js.map +1 -1
  11. package/dist/cli/monitor.js +15 -14
  12. package/dist/cli/monitor.js.map +1 -1
  13. package/dist/cli/prepare.js +37 -20
  14. package/dist/cli/prepare.js.map +1 -1
  15. package/dist/cli/resume.js +193 -40
  16. package/dist/cli/resume.js.map +1 -1
  17. package/dist/cli/run.js +3 -2
  18. package/dist/cli/run.js.map +1 -1
  19. package/dist/cli/signal.js +7 -7
  20. package/dist/cli/signal.js.map +1 -1
  21. package/dist/core/orchestrator.d.ts +2 -1
  22. package/dist/core/orchestrator.js +48 -91
  23. package/dist/core/orchestrator.js.map +1 -1
  24. package/dist/core/runner.js +55 -20
  25. package/dist/core/runner.js.map +1 -1
  26. package/dist/utils/config.js +7 -6
  27. package/dist/utils/config.js.map +1 -1
  28. package/dist/utils/doctor.js +7 -6
  29. package/dist/utils/doctor.js.map +1 -1
  30. package/dist/utils/enhanced-logger.js +14 -11
  31. package/dist/utils/enhanced-logger.js.map +1 -1
  32. package/dist/utils/git.js +163 -10
  33. package/dist/utils/git.js.map +1 -1
  34. package/dist/utils/log-formatter.d.ts +16 -0
  35. package/dist/utils/log-formatter.js +194 -0
  36. package/dist/utils/log-formatter.js.map +1 -0
  37. package/dist/utils/path.d.ts +19 -0
  38. package/dist/utils/path.js +77 -0
  39. package/dist/utils/path.js.map +1 -0
  40. package/dist/utils/state.d.ts +4 -1
  41. package/dist/utils/state.js +11 -8
  42. package/dist/utils/state.js.map +1 -1
  43. package/dist/utils/template.d.ts +14 -0
  44. package/dist/utils/template.js +122 -0
  45. package/dist/utils/template.js.map +1 -0
  46. package/dist/utils/types.d.ts +1 -0
  47. package/package.json +1 -1
  48. package/src/cli/clean.ts +7 -6
  49. package/src/cli/index.ts +5 -1
  50. package/src/cli/init.ts +7 -6
  51. package/src/cli/logs.ts +52 -42
  52. package/src/cli/monitor.ts +15 -14
  53. package/src/cli/prepare.ts +39 -20
  54. package/src/cli/resume.ts +810 -626
  55. package/src/cli/run.ts +3 -2
  56. package/src/cli/signal.ts +7 -6
  57. package/src/core/orchestrator.ts +62 -91
  58. package/src/core/runner.ts +58 -20
  59. package/src/utils/config.ts +7 -6
  60. package/src/utils/doctor.ts +7 -6
  61. package/src/utils/enhanced-logger.ts +14 -11
  62. package/src/utils/git.ts +145 -11
  63. package/src/utils/log-formatter.ts +162 -0
  64. package/src/utils/path.ts +45 -0
  65. package/src/utils/state.ts +16 -8
  66. package/src/utils/template.ts +92 -0
  67. package/src/utils/types.ts +1 -0
  68. package/templates/basic.json +21 -0
package/src/cli/logs.ts CHANGED
@@ -6,12 +6,14 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as logger from '../utils/logger';
8
8
  import { loadConfig } from '../utils/config';
9
+ import { safeJoin } from '../utils/path';
9
10
  import {
10
11
  readJsonLog,
11
12
  exportLogs,
12
13
  stripAnsi,
13
14
  JsonLogEntry
14
15
  } from '../utils/enhanced-logger';
16
+ import { formatPotentialJsonMessage } from '../utils/log-formatter';
15
17
 
16
18
  interface LogsOptions {
17
19
  runDir?: string;
@@ -114,15 +116,15 @@ function parseArgs(args: string[]): LogsOptions {
114
116
  * Find the latest run directory
115
117
  */
116
118
  function findLatestRunDir(logsDir: string): string | null {
117
- const runsDir = path.join(logsDir, 'runs');
119
+ const runsDir = safeJoin(logsDir, 'runs');
118
120
  if (!fs.existsSync(runsDir)) return null;
119
121
 
120
122
  const runs = fs.readdirSync(runsDir)
121
123
  .filter(d => d.startsWith('run-'))
122
124
  .map(d => ({
123
125
  name: d,
124
- path: path.join(runsDir, d),
125
- mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime()
126
+ path: safeJoin(runsDir, d),
127
+ mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime()
126
128
  }))
127
129
  .sort((a, b) => b.mtime - a.mtime);
128
130
 
@@ -133,11 +135,11 @@ function findLatestRunDir(logsDir: string): string | null {
133
135
  * List lanes in a run directory
134
136
  */
135
137
  function listLanes(runDir: string): string[] {
136
- const lanesDir = path.join(runDir, 'lanes');
138
+ const lanesDir = safeJoin(runDir, 'lanes');
137
139
  if (!fs.existsSync(lanesDir)) return [];
138
140
 
139
141
  return fs.readdirSync(lanesDir)
140
- .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory());
142
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory());
141
143
  }
142
144
 
143
145
  /**
@@ -148,9 +150,9 @@ function displayTextLogs(
148
150
  options: LogsOptions
149
151
  ): void {
150
152
  let logFile: string;
151
- const readableLog = path.join(laneDir, 'terminal-readable.log');
152
- const rawLog = path.join(laneDir, 'terminal-raw.log');
153
- const cleanLog = path.join(laneDir, 'terminal.log');
153
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
154
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
155
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
154
156
 
155
157
  if (options.raw) {
156
158
  logFile = rawLog;
@@ -171,10 +173,10 @@ function displayTextLogs(
171
173
  let content = fs.readFileSync(logFile, 'utf8');
172
174
  let lines = content.split('\n');
173
175
 
174
- // Apply filter (escape to prevent regex injection)
176
+ // Apply filter (case-insensitive string match to avoid ReDoS)
175
177
  if (options.filter) {
176
- const regex = new RegExp(escapeRegex(options.filter), 'i');
177
- lines = lines.filter(line => regex.test(line));
178
+ const filterLower = options.filter.toLowerCase();
179
+ lines = lines.filter(line => line.toLowerCase().includes(filterLower));
178
180
  }
179
181
 
180
182
  // Apply tail
@@ -197,7 +199,7 @@ function displayJsonLogs(
197
199
  laneDir: string,
198
200
  options: LogsOptions
199
201
  ): void {
200
- const logFile = path.join(laneDir, 'terminal.jsonl');
202
+ const logFile = safeJoin(laneDir, 'terminal.jsonl');
201
203
 
202
204
  if (!fs.existsSync(logFile)) {
203
205
  console.log('No JSON log file found.');
@@ -211,10 +213,13 @@ function displayJsonLogs(
211
213
  entries = entries.filter(e => e.level === options.level);
212
214
  }
213
215
 
214
- // Apply regex filter (escape to prevent regex injection)
216
+ // Apply filter (case-insensitive string match to avoid ReDoS)
215
217
  if (options.filter) {
216
- const regex = new RegExp(escapeRegex(options.filter), 'i');
217
- entries = entries.filter(e => regex.test(e.message) || regex.test(e.task || ''));
218
+ const filterLower = options.filter.toLowerCase();
219
+ entries = entries.filter(e =>
220
+ e.message.toLowerCase().includes(filterLower) ||
221
+ (e.task && e.task.toLowerCase().includes(filterLower))
222
+ );
218
223
  }
219
224
 
220
225
  // Apply tail
@@ -229,7 +234,8 @@ function displayJsonLogs(
229
234
  for (const entry of entries) {
230
235
  const levelColor = getLevelColor(entry.level);
231
236
  const ts = new Date(entry.timestamp).toLocaleTimeString();
232
- console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${entry.message}`);
237
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
238
+ console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${formattedMsg}`);
233
239
  }
234
240
  }
235
241
  }
@@ -290,8 +296,8 @@ function readAllLaneLogs(runDir: string): MergedLogEntry[] {
290
296
  const allEntries: MergedLogEntry[] = [];
291
297
 
292
298
  lanes.forEach((laneName, index) => {
293
- const laneDir = path.join(runDir, 'lanes', laneName);
294
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
299
+ const laneDir = safeJoin(runDir, 'lanes', laneName);
300
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
295
301
 
296
302
  if (fs.existsSync(jsonLogPath)) {
297
303
  const entries = readJsonLog(jsonLogPath);
@@ -333,13 +339,13 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
333
339
  entries = entries.filter(e => e.level === options.level);
334
340
  }
335
341
 
336
- // Apply regex filter (escape to prevent regex injection)
342
+ // Apply filter (case-insensitive string match to avoid ReDoS)
337
343
  if (options.filter) {
338
- const regex = new RegExp(escapeRegex(options.filter), 'i');
344
+ const filterLower = options.filter.toLowerCase();
339
345
  entries = entries.filter(e =>
340
- regex.test(e.message) ||
341
- regex.test(e.task || '') ||
342
- regex.test(e.laneName)
346
+ e.message.toLowerCase().includes(filterLower) ||
347
+ (e.task && e.task.toLowerCase().includes(filterLower)) ||
348
+ e.laneName.toLowerCase().includes(filterLower)
343
349
  );
344
350
  }
345
351
 
@@ -387,7 +393,8 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
387
393
  continue;
388
394
  }
389
395
 
390
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
396
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
397
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
391
398
  }
392
399
 
393
400
  console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
@@ -426,8 +433,8 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
426
433
  const newEntries: MergedLogEntry[] = [];
427
434
 
428
435
  for (const lane of lanes) {
429
- const laneDir = path.join(runDir, 'lanes', lane);
430
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
436
+ const laneDir = safeJoin(runDir, 'lanes', lane);
437
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
431
438
 
432
439
  try {
433
440
  // Use statSync directly to avoid TOCTOU race condition
@@ -473,10 +480,12 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
473
480
  // Apply level filter
474
481
  if (options.level && entry.level !== options.level) continue;
475
482
 
476
- // Apply regex filter (escape to prevent regex injection)
483
+ // Apply filter (case-insensitive string match to avoid ReDoS)
477
484
  if (options.filter) {
478
- const regex = new RegExp(escapeRegex(options.filter), 'i');
479
- if (!regex.test(entry.message) && !regex.test(entry.task || '') && !regex.test(entry.laneName)) {
485
+ const filterLower = options.filter.toLowerCase();
486
+ if (!entry.message.toLowerCase().includes(filterLower) &&
487
+ !(entry.task && entry.task.toLowerCase().includes(filterLower)) &&
488
+ !entry.laneName.toLowerCase().includes(filterLower)) {
480
489
  continue;
481
490
  }
482
491
  }
@@ -496,7 +505,8 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
496
505
  continue;
497
506
  }
498
507
 
499
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
508
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
509
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
500
510
  }
501
511
  }, 100);
502
512
 
@@ -644,9 +654,9 @@ function escapeHtml(text: string): string {
644
654
  */
645
655
  function followLogs(laneDir: string, options: LogsOptions): void {
646
656
  let logFile: string;
647
- const readableLog = path.join(laneDir, 'terminal-readable.log');
648
- const rawLog = path.join(laneDir, 'terminal-raw.log');
649
- const cleanLog = path.join(laneDir, 'terminal.log');
657
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
658
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
659
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
650
660
 
651
661
  if (options.raw) {
652
662
  logFile = rawLog;
@@ -687,11 +697,11 @@ function followLogs(laneDir: string, options: LogsOptions): void {
687
697
 
688
698
  let content = buffer.toString();
689
699
 
690
- // Apply filter (escape to prevent regex injection)
700
+ // Apply filter (case-insensitive string match to avoid ReDoS)
691
701
  if (options.filter) {
692
- const regex = new RegExp(escapeRegex(options.filter), 'i');
702
+ const filterLower = options.filter.toLowerCase();
693
703
  const lines = content.split('\n');
694
- content = lines.filter(line => regex.test(line)).join('\n');
704
+ content = lines.filter(line => line.toLowerCase().includes(filterLower)).join('\n');
695
705
  }
696
706
 
697
707
  // Clean ANSI if needed (unless raw mode)
@@ -734,11 +744,11 @@ function displaySummary(runDir: string): void {
734
744
  }
735
745
 
736
746
  for (const lane of lanes) {
737
- const laneDir = path.join(runDir, 'lanes', lane);
738
- const cleanLog = path.join(laneDir, 'terminal.log');
739
- const rawLog = path.join(laneDir, 'terminal-raw.log');
740
- const jsonLog = path.join(laneDir, 'terminal.jsonl');
741
- const readableLog = path.join(laneDir, 'terminal-readable.log');
747
+ const laneDir = safeJoin(runDir, 'lanes', lane);
748
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
749
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
750
+ const jsonLog = safeJoin(laneDir, 'terminal.jsonl');
751
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
742
752
 
743
753
  console.log(` ${logger.COLORS.green}📁 ${lane}${logger.COLORS.reset}`);
744
754
 
@@ -839,7 +849,7 @@ async function logs(args: string[]): Promise<void> {
839
849
  }
840
850
 
841
851
  // Find lane directory
842
- const laneDir = path.join(runDir, 'lanes', options.lane);
852
+ const laneDir = safeJoin(runDir, 'lanes', options.lane);
843
853
  if (!fs.existsSync(laneDir)) {
844
854
  const lanes = listLanes(runDir);
845
855
  throw new Error(`Lane not found: ${options.lane}\nAvailable lanes: ${lanes.join(', ')}`);
@@ -9,6 +9,7 @@ import * as logger from '../utils/logger';
9
9
  import { loadState, readLog } from '../utils/state';
10
10
  import { LaneState, ConversationEntry } from '../utils/types';
11
11
  import { loadConfig } from '../utils/config';
12
+ import { safeJoin } from '../utils/path';
12
13
 
13
14
  interface LaneWithDeps {
14
15
  name: string;
@@ -339,11 +340,11 @@ class InteractiveMonitor {
339
340
  if (!lane) return;
340
341
 
341
342
  try {
342
- const interventionPath = path.join(lane.path, 'intervention.txt');
343
+ const interventionPath = safeJoin(lane.path, 'intervention.txt');
343
344
  fs.writeFileSync(interventionPath, message, 'utf8');
344
345
 
345
346
  // Also log it to the conversation
346
- const convoPath = path.join(lane.path, 'conversation.jsonl');
347
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
347
348
  const entry = {
348
349
  timestamp: new Date().toISOString(),
349
350
  role: 'user',
@@ -372,7 +373,7 @@ class InteractiveMonitor {
372
373
  return;
373
374
  }
374
375
 
375
- const timeoutPath = path.join(lane.path, 'timeout.txt');
376
+ const timeoutPath = safeJoin(lane.path, 'timeout.txt');
376
377
  fs.writeFileSync(timeoutPath, String(timeoutMs), 'utf8');
377
378
 
378
379
  this.showNotification(`Timeout updated to ${Math.round(timeoutMs/1000)}s`, 'success');
@@ -385,7 +386,7 @@ class InteractiveMonitor {
385
386
  if (!this.selectedLaneName) return;
386
387
  const lane = this.lanes.find(l => l.name === this.selectedLaneName);
387
388
  if (!lane) return;
388
- const convoPath = path.join(lane.path, 'conversation.jsonl');
389
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
389
390
  this.currentLogs = readLog<ConversationEntry>(convoPath);
390
391
  // Keep selection in bounds after refresh
391
392
  if (this.selectedMessageIndex >= this.currentLogs.length) {
@@ -537,7 +538,7 @@ class InteractiveMonitor {
537
538
  }
538
539
 
539
540
  const status = this.getLaneStatus(lane.path, lane.name);
540
- const logPath = path.join(lane.path, 'terminal.log');
541
+ const logPath = safeJoin(lane.path, 'terminal.log');
541
542
  let liveLog = '(No live terminal output)';
542
543
  if (fs.existsSync(logPath)) {
543
544
  const content = fs.readFileSync(logPath, 'utf8');
@@ -680,7 +681,7 @@ class InteractiveMonitor {
680
681
  return;
681
682
  }
682
683
 
683
- const logPath = path.join(lane.path, 'terminal.log');
684
+ const logPath = safeJoin(lane.path, 'terminal.log');
684
685
  let logLines: string[] = [];
685
686
  if (fs.existsSync(logPath)) {
686
687
  const content = fs.readFileSync(logPath, 'utf8');
@@ -761,21 +762,21 @@ class InteractiveMonitor {
761
762
  }
762
763
 
763
764
  private listLanesWithDeps(runDir: string): LaneWithDeps[] {
764
- const lanesDir = path.join(runDir, 'lanes');
765
+ const lanesDir = safeJoin(runDir, 'lanes');
765
766
  if (!fs.existsSync(lanesDir)) return [];
766
767
 
767
768
  const config = loadConfig();
768
- const tasksDir = path.join(config.projectRoot, config.tasksDir);
769
+ const tasksDir = safeJoin(config.projectRoot, config.tasksDir);
769
770
 
770
771
  const laneConfigs = this.listLaneFilesFromDir(tasksDir);
771
772
 
772
773
  return fs.readdirSync(lanesDir)
773
- .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory())
774
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory())
774
775
  .map(name => {
775
776
  const config = laneConfigs.find(c => c.name === name);
776
777
  return {
777
778
  name,
778
- path: path.join(lanesDir, name),
779
+ path: safeJoin(lanesDir, name),
779
780
  dependsOn: config?.dependsOn || [],
780
781
  };
781
782
  });
@@ -786,7 +787,7 @@ class InteractiveMonitor {
786
787
  return fs.readdirSync(tasksDir)
787
788
  .filter(f => f.endsWith('.json'))
788
789
  .map(f => {
789
- const filePath = path.join(tasksDir, f);
790
+ const filePath = safeJoin(tasksDir, f);
790
791
  try {
791
792
  const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
792
793
  return { name: path.basename(f, '.json'), dependsOn: config.dependsOn || [] };
@@ -797,7 +798,7 @@ class InteractiveMonitor {
797
798
  }
798
799
 
799
800
  private getLaneStatus(lanePath: string, laneName: string) {
800
- const statePath = path.join(lanePath, 'state.json');
801
+ const statePath = safeJoin(lanePath, 'state.json');
801
802
  const state = loadState<LaneState & { chatId?: string }>(statePath);
802
803
 
803
804
  const laneInfo = this.lanes.find(l => l.name === laneName);
@@ -857,11 +858,11 @@ class InteractiveMonitor {
857
858
  * Find the latest run directory
858
859
  */
859
860
  function findLatestRunDir(logsDir: string): string | null {
860
- const runsDir = path.join(logsDir, 'runs');
861
+ const runsDir = safeJoin(logsDir, 'runs');
861
862
  if (!fs.existsSync(runsDir)) return null;
862
863
  const runs = fs.readdirSync(runsDir)
863
864
  .filter(d => d.startsWith('run-'))
864
- .map(d => ({ name: d, path: path.join(runsDir, d), mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime() }))
865
+ .map(d => ({ name: d, path: safeJoin(runsDir, d), mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime() }))
865
866
  .sort((a, b) => b.mtime - a.mtime);
866
867
  return runs.length > 0 ? runs[0]!.path : null;
867
868
  }
@@ -9,6 +9,8 @@ import * as path from 'path';
9
9
  import * as logger from '../utils/logger';
10
10
  import { loadConfig, getTasksDir } from '../utils/config';
11
11
  import { Task, RunnerConfig } from '../utils/types';
12
+ import { safeJoin } from '../utils/path';
13
+ import { resolveTemplate } from '../utils/template';
12
14
 
13
15
  // Preset template types
14
16
  type PresetType = 'complex' | 'simple' | 'merge';
@@ -113,8 +115,8 @@ Prepare task files for a new feature - Terminal-first workflow.
113
115
  --add-task <file> Append task(s) to existing lane JSON file
114
116
 
115
117
  Advanced:
116
- --template <path> Custom template JSON file
117
- --force Overwrite existing files
118
+ --template <path|url|name> External template JSON file, URL, or built-in name
119
+ --force Overwrite existing files
118
120
 
119
121
  ═══════════════════════════════════════════════════════════════════════════════
120
122
 
@@ -612,7 +614,7 @@ function getFeatureNameFromDir(taskDir: string): string {
612
614
  }
613
615
 
614
616
  async function addLaneToDir(options: PrepareOptions): Promise<void> {
615
- const taskDir = path.resolve(process.cwd(), options.addLane!);
617
+ const taskDir = path.resolve(process.cwd(), options.addLane!); // nosemgrep
616
618
 
617
619
  if (!fs.existsSync(taskDir)) {
618
620
  throw new Error(`Task directory not found: ${taskDir}`);
@@ -622,17 +624,38 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
622
624
  const laneNumber = getNextLaneNumber(taskDir);
623
625
  const laneName = `lane-${laneNumber}`;
624
626
  const fileName = `${laneNumber.toString().padStart(2, '0')}-${laneName}.json`;
625
- const filePath = path.join(taskDir, fileName);
627
+ const filePath = safeJoin(taskDir, fileName);
626
628
 
627
629
  const hasDependencies = options.dependsOnLanes.length > 0;
628
630
 
629
- // Build tasks from options (auto-detects merge preset if has dependencies)
630
- const tasks = buildTasksFromOptions(options, laneNumber, featureName, hasDependencies);
631
- const config = getDefaultConfig(laneNumber, featureName, tasks);
631
+ // Load template if provided
632
+ let template = null;
633
+ if (options.template) {
634
+ template = await resolveTemplate(options.template);
635
+ }
636
+
637
+ let taskConfig;
638
+ let effectivePreset: EffectivePresetType = options.preset || (hasDependencies ? 'merge' : 'complex');
639
+
640
+ if (template) {
641
+ taskConfig = { ...template, laneNumber, devPort: 3000 + laneNumber };
642
+ effectivePreset = 'custom';
643
+ } else {
644
+ // Build tasks from options (auto-detects merge preset if has dependencies)
645
+ const tasks = buildTasksFromOptions(options, laneNumber, featureName, hasDependencies);
646
+ taskConfig = getDefaultConfig(laneNumber, featureName, tasks);
647
+ }
648
+
649
+ // Replace placeholders
650
+ const processedConfig = replacePlaceholders(taskConfig, {
651
+ featureName,
652
+ laneNumber,
653
+ devPort: 3000 + laneNumber,
654
+ });
632
655
 
633
656
  // Add dependencies if specified
634
657
  const finalConfig = {
635
- ...config,
658
+ ...processedConfig,
636
659
  ...(hasDependencies ? { dependsOn: options.dependsOnLanes } : {}),
637
660
  };
638
661
 
@@ -647,9 +670,10 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
647
670
  throw err;
648
671
  }
649
672
 
650
- const taskSummary = tasks.map(t => t.name).join(' → ');
673
+ const tasksList = finalConfig.tasks || [];
674
+ const taskSummary = tasksList.map((t: any) => t.name).join(' → ');
651
675
  const depsInfo = hasDependencies ? ` (depends: ${options.dependsOnLanes.join(', ')})` : '';
652
- const presetInfo = options.preset ? ` [${options.preset}]` : (hasDependencies ? ' [merge]' : '');
676
+ const presetInfo = options.preset ? ` [${options.preset}]` : (hasDependencies ? ' [merge]' : (template ? ' [template]' : ''));
653
677
 
654
678
  logger.success(`Added lane: ${fileName} [${taskSummary}]${presetInfo}${depsInfo}`);
655
679
  logger.info(`Directory: ${taskDir}`);
@@ -660,7 +684,7 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
660
684
  }
661
685
 
662
686
  async function addTaskToLane(options: PrepareOptions): Promise<void> {
663
- const laneFile = path.resolve(process.cwd(), options.addTask!);
687
+ const laneFile = path.resolve(process.cwd(), options.addTask!); // nosemgrep
664
688
 
665
689
  if (options.taskSpecs.length === 0) {
666
690
  throw new Error('No task specified. Use --task "name|model|prompt|criteria" to define a task.');
@@ -708,7 +732,7 @@ async function createNewFeature(options: PrepareOptions): Promise<void> {
708
732
  const now = new Date();
709
733
  const timestamp = now.toISOString().replace(/[-T:]/g, '').substring(2, 12);
710
734
  const taskDirName = `${timestamp}_${options.featureName}`;
711
- const taskDir = path.join(tasksBaseDir, taskDirName);
735
+ const taskDir = safeJoin(tasksBaseDir, taskDirName);
712
736
 
713
737
  if (fs.existsSync(taskDir) && !options.force) {
714
738
  throw new Error(`Task directory already exists: ${taskDir}. Use --force to overwrite.`);
@@ -723,12 +747,7 @@ async function createNewFeature(options: PrepareOptions): Promise<void> {
723
747
  // Load template if provided (overrides --prompt/--task/--preset)
724
748
  let template = null;
725
749
  if (options.template) {
726
- const templatePath = path.resolve(process.cwd(), options.template);
727
- if (!fs.existsSync(templatePath)) {
728
- throw new Error(`Template file not found: ${templatePath}`);
729
- }
730
- template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
731
- logger.info(`Using template: ${options.template}`);
750
+ template = await resolveTemplate(options.template);
732
751
  }
733
752
 
734
753
  // Calculate dependencies
@@ -741,7 +760,7 @@ async function createNewFeature(options: PrepareOptions): Promise<void> {
741
760
  for (let i = 1; i <= options.lanes; i++) {
742
761
  const laneName = `lane-${i}`;
743
762
  const fileName = `${i.toString().padStart(2, '0')}-${laneName}.json`;
744
- const filePath = path.join(taskDir, fileName);
763
+ const filePath = safeJoin(taskDir, fileName);
745
764
 
746
765
  const depNums = dependencyMap.get(i) || [];
747
766
  const dependsOn = depNums.map(n => {
@@ -788,7 +807,7 @@ async function createNewFeature(options: PrepareOptions): Promise<void> {
788
807
  }
789
808
 
790
809
  // Create README
791
- const readmePath = path.join(taskDir, 'README.md');
810
+ const readmePath = safeJoin(taskDir, 'README.md');
792
811
  const readme = `# Task: ${options.featureName}
793
812
 
794
813
  Prepared at: ${now.toISOString()}