opencastle 0.10.7 → 0.12.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.
Files changed (132) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +4 -0
  3. package/dist/cli/convoy/events.d.ts +10 -0
  4. package/dist/cli/convoy/events.d.ts.map +1 -0
  5. package/dist/cli/convoy/events.js +27 -0
  6. package/dist/cli/convoy/events.js.map +1 -0
  7. package/dist/cli/convoy/events.test.d.ts +2 -0
  8. package/dist/cli/convoy/events.test.d.ts.map +1 -0
  9. package/dist/cli/convoy/events.test.js +94 -0
  10. package/dist/cli/convoy/events.test.js.map +1 -0
  11. package/dist/cli/convoy/store.d.ts +23 -0
  12. package/dist/cli/convoy/store.d.ts.map +1 -0
  13. package/dist/cli/convoy/store.js +210 -0
  14. package/dist/cli/convoy/store.js.map +1 -0
  15. package/dist/cli/convoy/store.test.d.ts +2 -0
  16. package/dist/cli/convoy/store.test.d.ts.map +1 -0
  17. package/dist/cli/convoy/store.test.js +387 -0
  18. package/dist/cli/convoy/store.test.js.map +1 -0
  19. package/dist/cli/convoy/types.d.ts +56 -0
  20. package/dist/cli/convoy/types.d.ts.map +1 -0
  21. package/dist/cli/convoy/types.js +2 -0
  22. package/dist/cli/convoy/types.js.map +1 -0
  23. package/dist/cli/dashboard.d.ts.map +1 -1
  24. package/dist/cli/dashboard.js +5 -1
  25. package/dist/cli/dashboard.js.map +1 -1
  26. package/dist/cli/init.test.js +1 -1
  27. package/dist/cli/init.test.js.map +1 -1
  28. package/dist/cli/lesson.d.ts +17 -0
  29. package/dist/cli/lesson.d.ts.map +1 -0
  30. package/dist/cli/lesson.js +294 -0
  31. package/dist/cli/lesson.js.map +1 -0
  32. package/dist/cli/log.d.ts +7 -0
  33. package/dist/cli/log.d.ts.map +1 -0
  34. package/dist/cli/log.js +131 -0
  35. package/dist/cli/log.js.map +1 -0
  36. package/dist/cli/run/executor.js.map +1 -1
  37. package/dist/cli/run/executor.test.js +1 -0
  38. package/dist/cli/run/executor.test.js.map +1 -1
  39. package/dist/cli/run/loop-executor.d.ts +3 -0
  40. package/dist/cli/run/loop-executor.d.ts.map +1 -0
  41. package/dist/cli/run/loop-executor.js +155 -0
  42. package/dist/cli/run/loop-executor.js.map +1 -0
  43. package/dist/cli/run/loop-reporter.d.ts +6 -0
  44. package/dist/cli/run/loop-reporter.d.ts.map +1 -0
  45. package/dist/cli/run/loop-reporter.js +112 -0
  46. package/dist/cli/run/loop-reporter.js.map +1 -0
  47. package/dist/cli/run/reporter.d.ts.map +1 -1
  48. package/dist/cli/run/reporter.js +28 -1
  49. package/dist/cli/run/reporter.js.map +1 -1
  50. package/dist/cli/run/schema.d.ts +4 -0
  51. package/dist/cli/run/schema.d.ts.map +1 -1
  52. package/dist/cli/run/schema.js +178 -50
  53. package/dist/cli/run/schema.js.map +1 -1
  54. package/dist/cli/run/schema.test.js +598 -1
  55. package/dist/cli/run/schema.test.js.map +1 -1
  56. package/dist/cli/run.d.ts.map +1 -1
  57. package/dist/cli/run.js +84 -3
  58. package/dist/cli/run.js.map +1 -1
  59. package/dist/cli/types.d.ts +78 -1
  60. package/dist/cli/types.d.ts.map +1 -1
  61. package/dist/cli/update.d.ts.map +1 -1
  62. package/dist/cli/update.js +54 -1
  63. package/dist/cli/update.js.map +1 -1
  64. package/package.json +3 -2
  65. package/src/cli/convoy/events.test.ts +118 -0
  66. package/src/cli/convoy/events.ts +41 -0
  67. package/src/cli/convoy/store.test.ts +446 -0
  68. package/src/cli/convoy/store.ts +308 -0
  69. package/src/cli/convoy/types.ts +68 -0
  70. package/src/cli/dashboard.ts +5 -1
  71. package/src/cli/init.test.ts +1 -1
  72. package/src/cli/lesson.ts +312 -0
  73. package/src/cli/log.ts +133 -0
  74. package/src/cli/run/executor.test.ts +1 -0
  75. package/src/cli/run/executor.ts +8 -8
  76. package/src/cli/run/loop-executor.ts +199 -0
  77. package/src/cli/run/loop-reporter.ts +125 -0
  78. package/src/cli/run/reporter.ts +30 -1
  79. package/src/cli/run/schema.test.ts +704 -3
  80. package/src/cli/run/schema.ts +206 -56
  81. package/src/cli/run.ts +82 -5
  82. package/src/cli/types.ts +87 -1
  83. package/src/cli/update.ts +62 -1
  84. package/src/dashboard/dist/index.html +14 -15
  85. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  86. package/src/dashboard/scripts/generate-seed-data.ts +23 -43
  87. package/src/dashboard/seed-data/events.ndjson +104 -0
  88. package/src/dashboard/src/pages/index.astro +14 -15
  89. package/src/orchestrator/agents/api-designer.agent.md +1 -1
  90. package/src/orchestrator/agents/architect.agent.md +1 -1
  91. package/src/orchestrator/agents/content-engineer.agent.md +1 -1
  92. package/src/orchestrator/agents/copywriter.agent.md +1 -1
  93. package/src/orchestrator/agents/data-expert.agent.md +1 -1
  94. package/src/orchestrator/agents/database-engineer.agent.md +1 -1
  95. package/src/orchestrator/agents/developer.agent.md +1 -1
  96. package/src/orchestrator/agents/devops-expert.agent.md +1 -1
  97. package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
  98. package/src/orchestrator/agents/performance-expert.agent.md +1 -1
  99. package/src/orchestrator/agents/release-manager.agent.md +1 -1
  100. package/src/orchestrator/agents/security-expert.agent.md +1 -1
  101. package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
  102. package/src/orchestrator/agents/session-guard.agent.md +9 -21
  103. package/src/orchestrator/agents/team-lead.agent.md +8 -34
  104. package/src/orchestrator/agents/testing-expert.agent.md +1 -1
  105. package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
  106. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
  107. package/src/orchestrator/customizations/DISPUTES.md +2 -2
  108. package/src/orchestrator/customizations/README.md +1 -3
  109. package/src/orchestrator/customizations/logs/README.md +66 -14
  110. package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
  111. package/src/orchestrator/instructions/general.instructions.md +35 -181
  112. package/src/orchestrator/plugins/nx/SKILL.md +1 -1
  113. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
  114. package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
  115. package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
  116. package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
  117. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
  118. package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
  119. package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
  120. package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
  121. package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
  122. package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
  123. package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
  124. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
  125. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
  126. package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
  127. package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
  128. package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
  129. package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
  130. package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
  131. package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
  132. /package/src/orchestrator/customizations/logs/{disputes.ndjson → events.ndjson} +0 -0
@@ -0,0 +1,199 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { spawn } from 'node:child_process'
3
+ import type { ChildProcess } from 'node:child_process'
4
+ import { resolve } from 'node:path'
5
+ import { formatDuration } from './executor.js'
6
+ import { parseTimeout } from './schema.js'
7
+ import type {
8
+ TaskSpec,
9
+ LoopRunReport,
10
+ LoopIterationResult,
11
+ BackpressureResult,
12
+ AgentAdapter,
13
+ LoopReporter,
14
+ LoopExecutor,
15
+ Task,
16
+ } from '../types.js'
17
+
18
+ interface ActiveState {
19
+ task: Task | null
20
+ bpChild: ChildProcess | null
21
+ }
22
+
23
+ async function runBackpressureCommand(
24
+ command: string,
25
+ active: ActiveState,
26
+ timeoutMs: number,
27
+ ): Promise<BackpressureResult> {
28
+ return new Promise((res) => {
29
+ const child = spawn('sh', ['-c', command])
30
+ active.bpChild = child
31
+ let output = ''
32
+ let killed = false
33
+
34
+ const timer = setTimeout(() => {
35
+ killed = true
36
+ child.kill('SIGTERM')
37
+ }, timeoutMs)
38
+
39
+ child.stdout.on('data', (data: Buffer) => { output += data.toString() })
40
+ child.stderr.on('data', (data: Buffer) => { output += data.toString() })
41
+
42
+ child.on('close', (code: number | null) => {
43
+ clearTimeout(timer)
44
+ active.bpChild = null
45
+ const exitCode = code ?? 1
46
+ if (output.length > 5000) output = output.slice(0, 5000)
47
+ const timedOut = killed
48
+ res({
49
+ command,
50
+ exitCode: timedOut ? -1 : exitCode,
51
+ output: timedOut ? `Command timed out after ${timeoutMs}ms` : output,
52
+ passed: !timedOut && exitCode === 0,
53
+ })
54
+ })
55
+ })
56
+ }
57
+
58
+ async function runBackpressure(
59
+ commands: string[],
60
+ reporter: LoopReporter,
61
+ active: ActiveState,
62
+ timeoutMs: number,
63
+ ): Promise<{ passed: boolean; results: BackpressureResult[] }> {
64
+ const results: BackpressureResult[] = []
65
+ for (const command of commands) {
66
+ reporter.onBackpressureStart(command)
67
+ const result = await runBackpressureCommand(command, active, timeoutMs)
68
+ reporter.onBackpressureResult(result)
69
+ results.push(result)
70
+ if (!result.passed) return { passed: false, results }
71
+ }
72
+ return { passed: true, results }
73
+ }
74
+
75
+ export function createLoopExecutor(
76
+ spec: TaskSpec,
77
+ adapter: AgentAdapter,
78
+ reporter: LoopReporter,
79
+ ): LoopExecutor {
80
+ return {
81
+ async run(): Promise<LoopRunReport> {
82
+ const loop = spec.loop!
83
+ const startedAt = new Date()
84
+ const iterations: LoopIterationResult[] = []
85
+ let aborted = false
86
+ const active: ActiveState = { task: null, bpChild: null }
87
+ const timeoutMs = parseTimeout(loop.timeout)
88
+
89
+ const sigintHandler = () => {
90
+ aborted = true
91
+ if (active.task && typeof adapter.kill === 'function') {
92
+ adapter.kill(active.task)
93
+ }
94
+ if (active.bpChild && !active.bpChild.killed) {
95
+ active.bpChild.kill('SIGTERM')
96
+ }
97
+ }
98
+ process.on('SIGINT', sigintHandler)
99
+
100
+ let stoppedReason: LoopRunReport['stoppedReason'] = 'max-iterations'
101
+
102
+ try {
103
+ for (let i = 1; i <= loop.max_iterations; i++) {
104
+ if (aborted) {
105
+ stoppedReason = 'user-abort'
106
+ break
107
+ }
108
+
109
+ reporter.onIterationStart(i, loop.max_iterations)
110
+
111
+ // Re-read prompt from disk each iteration for latest content
112
+ const promptContent = await readFile(resolve(process.cwd(), loop.prompt), 'utf8')
113
+
114
+ const syntheticTask: Task = {
115
+ id: `loop-${i}`,
116
+ prompt: promptContent,
117
+ agent: 'autonomous',
118
+ timeout: loop.timeout,
119
+ depends_on: [],
120
+ files: [],
121
+ description: `Loop iteration ${i}`,
122
+ max_retries: 1,
123
+ }
124
+
125
+ const iterStart = Date.now()
126
+ active.task = syntheticTask
127
+ const adapterResult = await adapter.execute(syntheticTask, { verbose: spec._verbose })
128
+ active.task = null
129
+
130
+ if (!adapterResult.success) {
131
+ const duration = Date.now() - iterStart
132
+ const iterResult: LoopIterationResult = {
133
+ iteration: i,
134
+ status: 'failed',
135
+ duration,
136
+ output: adapterResult.output,
137
+ backpressureResults: [],
138
+ }
139
+ iterations.push(iterResult)
140
+ reporter.onIterationDone(i, iterResult)
141
+ stoppedReason = 'error'
142
+ break
143
+ }
144
+
145
+ let backpressureResults: BackpressureResult[] = []
146
+ if (loop.backpressure && loop.backpressure.length > 0) {
147
+ const bp = await runBackpressure(loop.backpressure, reporter, active, timeoutMs)
148
+ backpressureResults = bp.results
149
+ if (!bp.passed) {
150
+ const duration = Date.now() - iterStart
151
+ const iterResult: LoopIterationResult = {
152
+ iteration: i,
153
+ status: 'backpressure-fail',
154
+ duration,
155
+ output: adapterResult.output,
156
+ backpressureResults,
157
+ }
158
+ iterations.push(iterResult)
159
+ reporter.onIterationDone(i, iterResult)
160
+ stoppedReason = 'backpressure-fail'
161
+ break
162
+ }
163
+ }
164
+
165
+ const duration = Date.now() - iterStart
166
+ const iterResult: LoopIterationResult = {
167
+ iteration: i,
168
+ status: 'done',
169
+ duration,
170
+ output: adapterResult.output,
171
+ backpressureResults,
172
+ }
173
+ iterations.push(iterResult)
174
+ reporter.onIterationDone(i, iterResult)
175
+ }
176
+ } finally {
177
+ process.off('SIGINT', sigintHandler)
178
+ }
179
+
180
+ const completedAt = new Date()
181
+ const completedIterations = iterations.filter((it) => it.status === 'done').length
182
+
183
+ const report: LoopRunReport = {
184
+ name: spec.name,
185
+ mode: 'loop',
186
+ startedAt: startedAt.toISOString(),
187
+ completedAt: completedAt.toISOString(),
188
+ duration: formatDuration(completedAt.getTime() - startedAt.getTime()),
189
+ totalIterations: iterations.length,
190
+ completedIterations,
191
+ stoppedReason,
192
+ iterations,
193
+ }
194
+
195
+ await reporter.onComplete(report)
196
+ return report
197
+ },
198
+ }
199
+ }
@@ -0,0 +1,125 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+ import { formatDuration } from './executor.js'
4
+ import { c } from '../prompt.js'
5
+ import { appendEvent } from '../log.js'
6
+ import { appendLesson } from '../lesson.js'
7
+ import type {
8
+ LoopRunReport,
9
+ LoopIterationResult,
10
+ BackpressureResult,
11
+ LoopReporter,
12
+ } from '../types.js'
13
+
14
+ const STOPPED_LABELS: Record<LoopRunReport['stoppedReason'], string> = {
15
+ 'max-iterations': 'reached max iterations',
16
+ 'plan-empty': 'plan exhausted',
17
+ 'backpressure-fail': 'backpressure check failed',
18
+ 'user-abort': 'aborted by user',
19
+ error: 'agent error',
20
+ }
21
+
22
+ export function createLoopReporter(
23
+ specName: string,
24
+ options: { reportDir?: string; verbose?: boolean } = {},
25
+ ): LoopReporter {
26
+ const reportDir = options.reportDir ?? resolve(process.cwd(), '.opencastle', 'runs')
27
+ const verbose = options.verbose ?? false
28
+
29
+ return {
30
+ onIterationStart(i: number, max: number): void {
31
+ console.log(`\n \u21bb Iteration ${i}/${max}`)
32
+ },
33
+
34
+ onIterationDone(_i: number, result: LoopIterationResult): void {
35
+ const dur = formatDuration(result.duration)
36
+ if (result.status === 'done') {
37
+ console.log(` ${c.green('\u2713')} Completed (${dur})`)
38
+ } else if (result.status === 'backpressure-fail') {
39
+ console.log(` ${c.yellow('\u26a0')} Backpressure failed (${dur})`)
40
+ } else {
41
+ console.log(` ${c.red('\u2717')} Failed (${dur})`)
42
+ if (result.output) {
43
+ const lines = result.output.split('\n').slice(0, 5)
44
+ for (const line of lines) console.log(` ${line}`)
45
+ if (result.output.split('\n').length > 5) console.log(` ... (truncated)`)
46
+ }
47
+ }
48
+ if (verbose && result.output && result.status === 'done') {
49
+ console.log(` Output: ${result.output.slice(0, 500)}`)
50
+ }
51
+
52
+ appendEvent({
53
+ type: 'delegation',
54
+ timestamp: new Date().toISOString(),
55
+ session_id: specName,
56
+ agent: 'autonomous',
57
+ task: `Loop iteration ${_i}`,
58
+ mechanism: 'run-loop',
59
+ outcome: result.status === 'done' ? 'success' : result.status,
60
+ duration_sec: Math.round(result.duration / 1000),
61
+ }).catch(() => {})
62
+ },
63
+
64
+ onBackpressureStart(cmd: string): void {
65
+ console.log(` \u23ce Running: ${cmd}`)
66
+ },
67
+
68
+ onBackpressureResult(result: BackpressureResult): void {
69
+ if (result.passed) {
70
+ console.log(` ${c.green('\u2713')} Exit ${result.exitCode}`)
71
+ } else {
72
+ console.log(` ${c.red('\u2717')} Exit ${result.exitCode}`)
73
+ if (result.output) {
74
+ const lines = result.output.split('\n').slice(0, 5)
75
+ for (const line of lines) console.log(` ${line}`)
76
+ }
77
+ }
78
+ },
79
+
80
+ async onComplete(report: LoopRunReport): Promise<void> {
81
+ console.log(`\n ${c.dim('\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500')}`)
82
+ console.log(` ${c.bold('Loop complete:')} ${report.name}`)
83
+ console.log(` Duration: ${report.duration}`)
84
+ console.log()
85
+ console.log(
86
+ ` Iterations: ${report.totalIterations} total \u2014 ` +
87
+ `${c.green(String(report.completedIterations))} completed`,
88
+ )
89
+ console.log(` Stopped: ${STOPPED_LABELS[report.stoppedReason]}`)
90
+
91
+ try {
92
+ await mkdir(reportDir, { recursive: true })
93
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
94
+ const reportPath = resolve(reportDir, `loop-${specName}-${timestamp}.json`)
95
+ await writeFile(reportPath, JSON.stringify(report, null, 2), 'utf8')
96
+ console.log(` Report: ${reportPath}`)
97
+ } catch (err: unknown) {
98
+ console.log(` \u2717 Could not write report: ${(err as Error).message}`)
99
+ }
100
+ await appendEvent({
101
+ type: 'session',
102
+ timestamp: new Date().toISOString(),
103
+ agent: 'opencastle-run',
104
+ task: `Loop: ${report.name}`,
105
+ outcome: report.stoppedReason === 'max-iterations' || report.stoppedReason === 'plan-empty' ? 'success' : 'failure',
106
+ duration_min: Math.round((new Date(report.completedAt).getTime() - new Date(report.startedAt).getTime()) / 60000),
107
+ mode: 'loop',
108
+ total_iterations: report.totalIterations,
109
+ completed_iterations: report.completedIterations,
110
+ stopped_reason: report.stoppedReason,
111
+ }).catch(() => {})
112
+
113
+ if (report.stoppedReason === 'error' || report.stoppedReason === 'backpressure-fail') {
114
+ const lastIter = report.iterations[report.iterations.length - 1]
115
+ await appendLesson({
116
+ title: `Run loop "${report.name}" stopped: ${report.stoppedReason}`,
117
+ category: 'general',
118
+ severity: 'medium',
119
+ problem: `Loop stopped after ${report.totalIterations} iterations due to ${report.stoppedReason}.${lastIter?.output ? ` Last output: ${lastIter.output.slice(0, 200)}` : ''}`,
120
+ }).catch(() => {})
121
+ }
122
+ console.log()
123
+ },
124
+ }
125
+ }
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
2
2
  import { resolve } from 'node:path'
3
3
  import { formatDuration } from './executor.js'
4
4
  import { c } from '../prompt.js'
5
+ import { appendEvent } from '../log.js'
5
6
  import type {
6
7
  TaskSpec,
7
8
  Task,
@@ -56,6 +57,17 @@ export function createReporter(spec: TaskSpec, options: ReporterOptions = {}): R
56
57
  if (verbose && result.output && result.status === 'done') {
57
58
  console.log(` Output: ${result.output.slice(0, 500)}`)
58
59
  }
60
+
61
+ appendEvent({
62
+ type: 'delegation',
63
+ timestamp: new Date().toISOString(),
64
+ session_id: spec.name,
65
+ agent: task.agent,
66
+ task: `${task.id}: ${task.description}`,
67
+ mechanism: 'run-task',
68
+ outcome: result.status === 'done' ? 'success' : result.status,
69
+ duration_sec: Math.round(result.duration / 1000),
70
+ }).catch(() => {})
59
71
  },
60
72
 
61
73
  onTaskSkipped(task: Task, reason: string): void {
@@ -95,6 +107,23 @@ export function createReporter(spec: TaskSpec, options: ReporterOptions = {}): R
95
107
  console.log(` ${ICONS.failed} Could not write report: ${(err as Error).message}`)
96
108
  }
97
109
 
110
+ await appendEvent({
111
+ type: 'session',
112
+ timestamp: new Date().toISOString(),
113
+ agent: 'opencastle-run',
114
+ model: spec.adapter,
115
+ task: `Run: ${report.name}`,
116
+ outcome: report.summary.failed > 0 || report.summary['timed-out'] > 0 ? 'failure' : 'success',
117
+ duration_min: Math.round((new Date(report.completedAt).getTime() - new Date(report.startedAt).getTime()) / 60000),
118
+ files_changed: 0,
119
+ retries: 0,
120
+ mode: 'tasks',
121
+ tasks_total: report.summary.total,
122
+ tasks_done: report.summary.done,
123
+ tasks_failed: report.summary.failed,
124
+ tasks_skipped: report.summary.skipped,
125
+ }).catch(() => {})
126
+
98
127
  console.log()
99
128
  },
100
129
  }
@@ -108,7 +137,7 @@ export function printExecutionPlan(spec: TaskSpec, phases: Task[][]): void {
108
137
  console.log(` ${c.dim('Adapter:')} ${c.cyan(spec.adapter)}`)
109
138
  console.log(` ${c.dim('Concurrency:')} ${c.yellow(String(spec.concurrency))}`)
110
139
  console.log(` ${c.dim('On failure:')} ${c.yellow(spec.on_failure)}`)
111
- console.log(` ${c.dim('Tasks:')} ${c.yellow(String(spec.tasks.length))}`)
140
+ console.log(` ${c.dim('Tasks:')} ${c.yellow(String(spec.tasks?.length ?? 0))}`)
112
141
  console.log(` ${c.dim('──────────────────────────────────')}`)
113
142
 
114
143
  for (let i = 0; i < phases.length; i++) {