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
@@ -39,6 +39,12 @@ interface RawSpec {
39
39
  on_failure?: unknown
40
40
  adapter?: unknown
41
41
  tasks?: unknown
42
+ mode?: unknown
43
+ loop?: unknown
44
+ version?: unknown
45
+ defaults?: unknown
46
+ gates?: unknown
47
+ branch?: unknown
42
48
  }
43
49
 
44
50
  interface RawTask {
@@ -49,6 +55,8 @@ interface RawTask {
49
55
  depends_on?: unknown
50
56
  files?: unknown
51
57
  description?: unknown
58
+ model?: unknown
59
+ max_retries?: unknown
52
60
  }
53
61
 
54
62
  /**
@@ -90,72 +98,180 @@ export function validateSpec(spec: unknown): ValidationResult {
90
98
  errors.push('`adapter` must be a string')
91
99
  }
92
100
 
93
- // Tasks
94
- if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
95
- errors.push('`tasks` is required and must be a non-empty array')
96
- return { valid: false, errors }
101
+ // version
102
+ if (s.version !== undefined) {
103
+ if (typeof s.version !== 'number' || !Number.isInteger(s.version) || s.version !== 1) {
104
+ errors.push('`version` must be 1')
105
+ }
97
106
  }
98
107
 
99
- const taskIds = new Set<string>()
100
- const tasks = s.tasks as RawTask[]
101
-
102
- for (let i = 0; i < tasks.length; i++) {
103
- const task = tasks[i]
104
- const prefix = `tasks[${i}]`
108
+ // defaults
109
+ if (s.defaults !== undefined) {
110
+ if (!s.defaults || typeof s.defaults !== 'object' || Array.isArray(s.defaults)) {
111
+ errors.push('`defaults` must be an object')
112
+ } else {
113
+ const d = s.defaults as Record<string, unknown>
114
+ if (d.timeout !== undefined && isNaN(parseTimeout(d.timeout as string))) {
115
+ errors.push(
116
+ '`defaults.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
117
+ )
118
+ }
119
+ if (d.model !== undefined && typeof d.model !== 'string') {
120
+ errors.push('`defaults.model` must be a string')
121
+ }
122
+ if (d.max_retries !== undefined) {
123
+ const mr = Number(d.max_retries)
124
+ if (!Number.isInteger(mr) || mr < 0) {
125
+ errors.push('`defaults.max_retries` must be a non-negative integer')
126
+ }
127
+ }
128
+ if (d.agent !== undefined && typeof d.agent !== 'string') {
129
+ errors.push('`defaults.agent` must be a string')
130
+ }
131
+ }
132
+ }
105
133
 
106
- if (!task || typeof task !== 'object') {
107
- errors.push(`${prefix}: must be an object`)
108
- continue
134
+ // gates
135
+ if (s.gates !== undefined) {
136
+ if (
137
+ !Array.isArray(s.gates) ||
138
+ !(s.gates as unknown[]).every((g) => typeof g === 'string')
139
+ ) {
140
+ errors.push('`gates` must be an array of strings')
109
141
  }
142
+ }
143
+
144
+ // branch
145
+ if (s.branch !== undefined && typeof s.branch !== 'string') {
146
+ errors.push('`branch` must be a string')
147
+ }
148
+
149
+ // mode
150
+ const mode = s.mode !== undefined ? s.mode : 'tasks'
151
+ if (mode !== 'tasks' && mode !== 'loop') {
152
+ errors.push('`mode` must be one of: tasks, loop')
153
+ }
110
154
 
111
- // id
112
- if (!task.id || typeof task.id !== 'string') {
113
- errors.push(`${prefix}: \`id\` is required and must be a string`)
114
- } else if (taskIds.has(task.id)) {
115
- errors.push(`${prefix}: duplicate task id "${task.id}"`)
155
+ if (mode === 'loop') {
156
+ // Loop validation tasks array is NOT required
157
+ const loop = s.loop as Record<string, unknown> | undefined
158
+ if (!loop || typeof loop !== 'object') {
159
+ errors.push('`loop` is required when mode is "loop"')
116
160
  } else {
117
- taskIds.add(task.id)
161
+ if (!loop.prompt || typeof loop.prompt !== 'string') {
162
+ errors.push('`loop.prompt` is required and must be a string')
163
+ }
164
+ if (loop.max_iterations !== undefined) {
165
+ const mi = Number(loop.max_iterations)
166
+ if (!Number.isInteger(mi) || mi < 1) {
167
+ errors.push('`loop.max_iterations` must be an integer >= 1')
168
+ }
169
+ }
170
+ if (loop.timeout !== undefined) {
171
+ if (isNaN(parseTimeout(loop.timeout as string))) {
172
+ errors.push(
173
+ '`loop.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
174
+ )
175
+ }
176
+ }
177
+ if (loop.backpressure !== undefined) {
178
+ if (
179
+ !Array.isArray(loop.backpressure) ||
180
+ !(loop.backpressure as unknown[]).every((b) => typeof b === 'string')
181
+ ) {
182
+ errors.push('`loop.backpressure` must be an array of strings')
183
+ }
184
+ }
185
+ if (loop.plan_file !== undefined && typeof loop.plan_file !== 'string') {
186
+ errors.push('`loop.plan_file` must be a string')
187
+ }
188
+ if (loop.model !== undefined && typeof loop.model !== 'string') {
189
+ errors.push('`loop.model` must be a string')
190
+ }
118
191
  }
119
-
120
- // prompt
121
- if (!task.prompt || typeof task.prompt !== 'string') {
122
- errors.push(`${prefix}: \`prompt\` is required and must be a string`)
192
+ } else {
193
+ // Tasks mode — tasks array is required
194
+ if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
195
+ errors.push('`tasks` is required and must be a non-empty array')
196
+ return { valid: false, errors }
123
197
  }
124
198
 
125
- // timeout
126
- if (task.timeout !== undefined) {
127
- if (isNaN(parseTimeout(task.timeout as string))) {
128
- errors.push(
129
- `${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
130
- )
199
+ const taskIds = new Set<string>()
200
+ const tasks = s.tasks as RawTask[]
201
+
202
+ for (let i = 0; i < tasks.length; i++) {
203
+ const task = tasks[i]
204
+ const prefix = `tasks[${i}]`
205
+
206
+ if (!task || typeof task !== 'object') {
207
+ errors.push(`${prefix}: must be an object`)
208
+ continue
131
209
  }
132
- }
133
210
 
134
- // depends_on
135
- if (task.depends_on !== undefined) {
136
- if (!Array.isArray(task.depends_on)) {
137
- errors.push(`${prefix}: \`depends_on\` must be an array`)
211
+ // id
212
+ if (!task.id || typeof task.id !== 'string') {
213
+ errors.push(`${prefix}: \`id\` is required and must be a string`)
214
+ } else if (taskIds.has(task.id)) {
215
+ errors.push(`${prefix}: duplicate task id "${task.id}"`)
138
216
  } else {
139
- for (const dep of task.depends_on as string[]) {
140
- if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
141
- errors.push(
142
- `${prefix}: \`depends_on\` references unknown task "${dep}"`
143
- )
217
+ taskIds.add(task.id)
218
+ }
219
+
220
+ // prompt
221
+ if (!task.prompt || typeof task.prompt !== 'string') {
222
+ errors.push(`${prefix}: \`prompt\` is required and must be a string`)
223
+ }
224
+
225
+ // timeout
226
+ if (task.timeout !== undefined) {
227
+ if (isNaN(parseTimeout(task.timeout as string))) {
228
+ errors.push(
229
+ `${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
230
+ )
231
+ }
232
+ }
233
+
234
+ // depends_on
235
+ if (task.depends_on !== undefined) {
236
+ if (!Array.isArray(task.depends_on)) {
237
+ errors.push(`${prefix}: \`depends_on\` must be an array`)
238
+ } else {
239
+ for (const dep of task.depends_on as string[]) {
240
+ if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
241
+ errors.push(
242
+ `${prefix}: \`depends_on\` references unknown task "${dep}"`
243
+ )
244
+ }
144
245
  }
145
246
  }
146
247
  }
147
- }
148
248
 
149
- // files
150
- if (task.files !== undefined && !Array.isArray(task.files)) {
151
- errors.push(`${prefix}: \`files\` must be an array`)
249
+ // files
250
+ if (task.files !== undefined && !Array.isArray(task.files)) {
251
+ errors.push(`${prefix}: \`files\` must be an array`)
252
+ }
253
+
254
+ // model
255
+ if (task.model !== undefined && typeof task.model !== 'string') {
256
+ errors.push(`${prefix}: \`model\` must be a string`)
257
+ }
258
+
259
+ // max_retries
260
+ if (task.max_retries !== undefined) {
261
+ const mr = Number(task.max_retries)
262
+ if (!Number.isInteger(mr) || mr < 0) {
263
+ errors.push(
264
+ `${prefix}: \`max_retries\` must be a non-negative integer`
265
+ )
266
+ }
267
+ }
152
268
  }
153
- }
154
269
 
155
- // DAG cycle detection
156
- if (errors.length === 0) {
157
- const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
158
- if (cycleErr) errors.push(cycleErr)
270
+ // DAG cycle detection
271
+ if (errors.length === 0) {
272
+ const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
273
+ if (cycleErr) errors.push(cycleErr)
274
+ }
159
275
  }
160
276
 
161
277
  return { valid: errors.length === 0, errors }
@@ -214,19 +330,53 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
214
330
  s.on_failure = (s.on_failure as string) || 'continue'
215
331
  // Leave adapter empty so run.ts can auto-detect the best available CLI
216
332
  s.adapter = (s.adapter as string) || ''
217
-
218
- const tasks = s.tasks as Array<Record<string, unknown>>
219
- for (const task of tasks) {
220
- task.agent = (task.agent as string) || 'developer'
221
- task.timeout = (task.timeout as string) || '30m'
222
- task.depends_on = (task.depends_on as string[]) || []
223
- task.files = (task.files as string[]) || []
224
- task.description = (task.description as string) || (task.id as string)
333
+ s.mode = (s.mode as string) || 'tasks'
334
+
335
+ if (s.mode === 'loop') {
336
+ const loop = ((s.loop ?? {}) as Record<string, unknown>)
337
+ loop.max_iterations = loop.max_iterations !== undefined ? Number(loop.max_iterations) : 20
338
+ loop.plan_file = (loop.plan_file as string) || 'IMPLEMENTATION_PLAN.md'
339
+ loop.timeout = (loop.timeout as string) || '10m'
340
+ s.loop = loop
341
+ } else {
342
+ const tasks = s.tasks as Array<Record<string, unknown>>
343
+ const d =
344
+ s.version === 1 && s.defaults
345
+ ? (s.defaults as Record<string, unknown>)
346
+ : {}
347
+ for (const task of tasks) {
348
+ task.agent =
349
+ (task.agent as string) || (d.agent as string | undefined) || 'developer'
350
+ task.timeout =
351
+ (task.timeout as string) ||
352
+ (d.timeout as string | undefined) ||
353
+ '30m'
354
+ task.depends_on = (task.depends_on as string[]) || []
355
+ task.files = (task.files as string[]) || []
356
+ task.description = (task.description as string) || (task.id as string)
357
+ // model: task-level overrides defaults (no hardcoded fallback)
358
+ if (task.model === undefined && d.model !== undefined) {
359
+ task.model = d.model
360
+ }
361
+ // max_retries: task-level overrides defaults, fallback to 1
362
+ if (task.max_retries === undefined) {
363
+ task.max_retries =
364
+ d.max_retries !== undefined ? Number(d.max_retries) : 1
365
+ }
366
+ }
225
367
  }
226
368
 
227
369
  return s as unknown as TaskSpec
228
370
  }
229
371
 
372
+ /**
373
+ * Returns true if the spec uses the Convoy Engine enhanced format (version: 1).
374
+ */
375
+ export function isConvoySpec(spec: unknown): boolean {
376
+ if (!spec || typeof spec !== 'object') return false
377
+ return (spec as Record<string, unknown>).version === 1
378
+ }
379
+
230
380
  /**
231
381
  * Read, parse, validate, and return a typed task spec from a YAML file.
232
382
  * @throws If file cannot be read, parsed, or spec is invalid
package/src/cli/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFile } from 'node:fs/promises'
1
2
  import { resolve } from 'node:path'
2
3
  import { parseTaskSpec } from './run/schema.js'
3
4
  import { createExecutor, buildPhases } from './run/executor.js'
@@ -9,14 +10,17 @@ const HELP = `
9
10
  opencastle run [options]
10
11
 
11
12
  Process a task queue from a spec file, delegating to AI agents autonomously.
13
+ Supports two modes: tasks (default phase-based execution) and loop (iterative Ralph Loop).
12
14
 
13
15
  Options:
14
16
  --file, -f <path> Task spec file (default: opencastle.tasks.yml)
15
17
  --dry-run Show execution plan without running
16
- --concurrency, -c <n> Override max parallel tasks
18
+ --concurrency, -c <n> Override max parallel tasks (tasks mode)
17
19
  --adapter, -a <name> Override agent runtime adapter
18
20
  --report-dir <path> Where to write run reports (default: .opencastle/runs)
19
21
  --verbose Show full agent output
22
+ --mode <name> Execution mode: tasks | loop
23
+ --max-iterations <n> Override max loop iterations (loop mode)
20
24
  --help, -h Show this help
21
25
  `
22
26
 
@@ -32,6 +36,8 @@ function parseArgs(args: string[]): RunOptions {
32
36
  reportDir: null,
33
37
  verbose: false,
34
38
  help: false,
39
+ maxIterations: null,
40
+ mode: null,
35
41
  }
36
42
 
37
43
  for (let i = 0; i < args.length; i++) {
@@ -73,6 +79,26 @@ function parseArgs(args: string[]): RunOptions {
73
79
  case '--verbose':
74
80
  opts.verbose = true
75
81
  break
82
+ case '--max-iterations': {
83
+ if (i + 1 >= args.length) { console.error(' \u2717 --max-iterations requires a number'); process.exit(1) }
84
+ const val = parseInt(args[++i], 10)
85
+ if (!Number.isFinite(val) || val < 1) {
86
+ console.error(` \u2717 --max-iterations must be an integer >= 1`)
87
+ process.exit(1)
88
+ }
89
+ opts.maxIterations = val
90
+ break
91
+ }
92
+ case '--mode': {
93
+ if (i + 1 >= args.length) { console.error(' \u2717 --mode requires a name'); process.exit(1) }
94
+ const modeVal = args[++i]
95
+ if (modeVal !== 'tasks' && modeVal !== 'loop') {
96
+ console.error(` \u2717 --mode must be one of: tasks, loop`)
97
+ process.exit(1)
98
+ }
99
+ opts.mode = modeVal
100
+ break
101
+ }
76
102
  default:
77
103
  console.error(` ✗ Unknown option: ${arg}`)
78
104
  console.log(HELP)
@@ -102,6 +128,7 @@ export default async function run({ args }: CliContext): Promise<void> {
102
128
  if (opts.concurrency !== null) spec.concurrency = opts.concurrency
103
129
  if (opts.adapter !== null) spec.adapter = opts.adapter
104
130
  if (opts.verbose) spec._verbose = true
131
+ if (opts.mode !== null) spec.mode = opts.mode as 'tasks' | 'loop'
105
132
 
106
133
  // ── Auto-detect adapter if not specified ─────────────────────
107
134
  let detectionFailed = false
@@ -117,9 +144,25 @@ export default async function run({ args }: CliContext): Promise<void> {
117
144
  }
118
145
 
119
146
  // ── Dry run ──────────────────────────────────────────────────
120
- const phases = buildPhases(spec.tasks)
121
-
122
147
  if (opts.dryRun) {
148
+ if (spec.mode === 'loop') {
149
+ const loop = spec.loop!
150
+ console.log(`\n \uD83C\uDFF0 Loop Plan: ${spec.name}`)
151
+ console.log(` Mode: loop`)
152
+ console.log(` Prompt: ${loop.prompt}`)
153
+ console.log(` Max iterations: ${loop.max_iterations}`)
154
+ console.log(` Timeout: ${loop.timeout}`)
155
+ if (loop.plan_file) console.log(` Plan file: ${loop.plan_file}`)
156
+ if (loop.model) console.log(` Model: ${loop.model}`)
157
+ if (loop.backpressure?.length) {
158
+ console.log(` Backpressure:`)
159
+ for (const cmd of loop.backpressure) {
160
+ console.log(` - ${cmd}`)
161
+ }
162
+ }
163
+ return
164
+ }
165
+ const phases = buildPhases(spec.tasks!)
123
166
  printExecutionPlan(spec, phases)
124
167
  return
125
168
  }
@@ -164,8 +207,42 @@ export default async function run({ args }: CliContext): Promise<void> {
164
207
  }
165
208
 
166
209
  // ── Execute ──────────────────────────────────────────────────
167
- console.log(`\n 🏰 OpenCastle Run: ${spec.name}`)
168
- console.log(` Adapter: ${adapter.name} | Concurrency: ${spec.concurrency} | Tasks: ${spec.tasks.length}`)
210
+ if (spec.mode === 'loop') {
211
+ const { createLoopExecutor } = await import('./run/loop-executor.js')
212
+ const { createLoopReporter } = await import('./run/loop-reporter.js')
213
+
214
+ if (opts.maxIterations !== null && spec.loop) {
215
+ spec.loop.max_iterations = opts.maxIterations
216
+ }
217
+
218
+ const promptPath = resolve(process.cwd(), spec.loop!.prompt)
219
+ try {
220
+ await readFile(promptPath)
221
+ } catch {
222
+ console.error(` \u2717 Prompt file not found: ${spec.loop!.prompt}`)
223
+ process.exit(1)
224
+ }
225
+
226
+ console.log(`\n \uD83C\uDFF0 OpenCastle Loop: ${spec.name}`)
227
+ console.log(` Adapter: ${adapter.name} | Max iterations: ${spec.loop!.max_iterations} | Timeout: ${spec.loop!.timeout}`)
228
+ if (spec.loop!.backpressure?.length) {
229
+ console.log(` Backpressure: ${spec.loop!.backpressure.join(', ')}`)
230
+ }
231
+
232
+ const loopReporter = createLoopReporter(spec.name, {
233
+ reportDir: opts.reportDir ? resolve(process.cwd(), opts.reportDir) : undefined,
234
+ verbose: opts.verbose,
235
+ })
236
+
237
+ const loopExecutor = createLoopExecutor(spec, adapter, loopReporter)
238
+ const loopReport = await loopExecutor.run()
239
+
240
+ const failed = loopReport.stoppedReason === 'error' || loopReport.stoppedReason === 'backpressure-fail'
241
+ process.exit(failed ? 1 : 0)
242
+ }
243
+
244
+ console.log(`\n \uD83C\uDFF0 OpenCastle Run: ${spec.name}`)
245
+ console.log(` Adapter: ${adapter.name} | Concurrency: ${spec.concurrency} | Tasks: ${spec.tasks!.length}`)
169
246
 
170
247
  const reporter = createReporter(spec, {
171
248
  reportDir: opts.reportDir
package/src/cli/types.ts CHANGED
@@ -126,14 +126,48 @@ export const IDE_LABELS: Record<IdeChoice, string> = {
126
126
 
127
127
  // ── Run command types ──────────────────────────────────────────
128
128
 
129
+ /** Default values merged into each task for Convoy Engine (version: 1) specs. */
130
+ export interface TaskDefaults {
131
+ timeout?: string;
132
+ model?: string;
133
+ max_retries?: number;
134
+ agent?: string;
135
+ }
136
+
137
+ /** Loop execution configuration. */
138
+ export interface LoopConfig {
139
+ /** Maximum number of agent iterations (default 20). */
140
+ max_iterations: number;
141
+ /** Path to the prompt file read each iteration. */
142
+ prompt: string;
143
+ /** Path to the plan file (default 'IMPLEMENTATION_PLAN.md'). */
144
+ plan_file?: string;
145
+ /** Per-iteration timeout (default '10m'). */
146
+ timeout: string;
147
+ /** Model override for loop sessions. */
148
+ model?: string;
149
+ /** Shell commands that must exit 0 after each iteration. */
150
+ backpressure?: string[];
151
+ }
152
+
129
153
  /** Validated task spec from YAML. */
130
154
  export interface TaskSpec {
131
155
  name: string;
132
156
  concurrency: number;
133
157
  on_failure: 'continue' | 'stop';
134
158
  adapter: string;
135
- tasks: Task[];
159
+ tasks?: Task[];
160
+ mode?: 'tasks' | 'loop';
161
+ loop?: LoopConfig;
136
162
  _verbose?: boolean;
163
+ /** Spec schema version (1 for Convoy Engine format). */
164
+ version?: number;
165
+ /** Worker defaults merged into each task (Convoy Engine). */
166
+ defaults?: TaskDefaults;
167
+ /** Shell commands run after all tasks complete; each must exit 0. */
168
+ gates?: string[];
169
+ /** Git feature branch name. */
170
+ branch?: string;
137
171
  }
138
172
 
139
173
  /** A single task in the spec. */
@@ -146,6 +180,10 @@ export interface Task {
146
180
  files: string[];
147
181
  description: string;
148
182
  _process?: ChildProcess;
183
+ /** Model override for this task. */
184
+ model?: string;
185
+ /** Max retry attempts (default: 1). */
186
+ max_retries: number;
149
187
  }
150
188
 
151
189
  /** Task execution status. */
@@ -231,6 +269,8 @@ export interface RunOptions {
231
269
  reportDir: string | null;
232
270
  verbose: boolean;
233
271
  help: boolean;
272
+ maxIterations: number | null;
273
+ mode: string | null;
234
274
  }
235
275
 
236
276
  /** Validation result. */
@@ -250,3 +290,49 @@ export interface Executor {
250
290
  run(): Promise<RunReport>;
251
291
  getPhases(): Task[][];
252
292
  }
293
+
294
+ // ── Loop executor types ────────────────────────────────────────
295
+
296
+ /** Result of a single backpressure command run. */
297
+ export interface BackpressureResult {
298
+ command: string;
299
+ exitCode: number;
300
+ output: string;
301
+ passed: boolean;
302
+ }
303
+
304
+ /** Result of a single loop iteration. */
305
+ export interface LoopIterationResult {
306
+ iteration: number;
307
+ status: 'done' | 'failed' | 'backpressure-fail';
308
+ duration: number;
309
+ output: string;
310
+ backpressureResults?: BackpressureResult[];
311
+ }
312
+
313
+ /** Final report produced by the loop executor. */
314
+ export interface LoopRunReport {
315
+ name: string;
316
+ mode: 'loop';
317
+ startedAt: string;
318
+ completedAt: string;
319
+ duration: string;
320
+ totalIterations: number;
321
+ completedIterations: number;
322
+ stoppedReason: 'max-iterations' | 'plan-empty' | 'backpressure-fail' | 'user-abort' | 'error';
323
+ iterations: LoopIterationResult[];
324
+ }
325
+
326
+ /** Reporter interface for loop execution progress. */
327
+ export interface LoopReporter {
328
+ onIterationStart(iteration: number, maxIterations: number): void;
329
+ onIterationDone(iteration: number, result: LoopIterationResult): void;
330
+ onBackpressureStart(command: string): void;
331
+ onBackpressureResult(result: BackpressureResult): void;
332
+ onComplete(report: LoopRunReport): Promise<void>;
333
+ }
334
+
335
+ /** Executor for loop-mode run specs. */
336
+ export interface LoopExecutor {
337
+ run(): Promise<LoopRunReport>;
338
+ }
package/src/cli/update.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path'
2
- import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile, appendFile, rename } from 'node:fs/promises'
3
4
  import { readManifest, writeManifest } from './manifest.js'
4
5
  import { multiselect, confirm, closePrompts, c } from './prompt.js'
5
6
  import { isLegacyStack, migrateStackConfig, IDE_LABELS } from './types.js'
@@ -247,6 +248,9 @@ export default async function update({
247
248
  }
248
249
  }
249
250
 
251
+ // ── Migrate legacy log files ────────────────────────────────────
252
+ await migrateLegacyLogs(projectRoot)
253
+
250
254
  // ── Update manifest ─────────────────────────────────────────────
251
255
  if (hasVersionUpdate) manifest.version = pkg.version
252
256
  manifest.ides = ides
@@ -322,6 +326,63 @@ export default async function update({
322
326
  closePrompts()
323
327
  }
324
328
 
329
+ async function migrateLegacyLogs(projectRoot: string): Promise<void> {
330
+ const logsDir = resolve(projectRoot, '.github', 'customizations', 'logs')
331
+ if (!existsSync(logsDir)) return
332
+
333
+ const typeMap: Record<string, string> = {
334
+ 'sessions.ndjson': 'session',
335
+ 'delegations.ndjson': 'delegation',
336
+ 'reviews.ndjson': 'review',
337
+ 'panels.ndjson': 'panel',
338
+ 'disputes.ndjson': 'dispute',
339
+ }
340
+
341
+ const eventsFile = resolve(logsDir, 'events.ndjson')
342
+ let totalMigrated = 0
343
+
344
+ for (const [filename, type] of Object.entries(typeMap)) {
345
+ const filePath = resolve(logsDir, filename)
346
+ if (!existsSync(filePath)) continue
347
+
348
+ let content: string
349
+ try {
350
+ content = await readFile(filePath, 'utf8')
351
+ } catch {
352
+ continue
353
+ }
354
+
355
+ const lines = content.split('\n').filter((line) => line.trim() !== '')
356
+ if (lines.length === 0) continue
357
+
358
+ const migratedLines: string[] = []
359
+ for (const line of lines) {
360
+ try {
361
+ const record = JSON.parse(line) as Record<string, unknown>
362
+ if (!record['type']) {
363
+ record['type'] = type
364
+ }
365
+ migratedLines.push(JSON.stringify(record))
366
+ } catch {
367
+ console.warn(` ${c.yellow('⚠')} Skipping malformed JSON line in ${filename}`)
368
+ }
369
+ }
370
+
371
+ if (migratedLines.length > 0) {
372
+ await appendFile(eventsFile, migratedLines.join('\n') + '\n', 'utf8')
373
+ totalMigrated += migratedLines.length
374
+ }
375
+
376
+ await rename(filePath, filePath + '.migrated')
377
+ }
378
+
379
+ if (totalMigrated > 0) {
380
+ console.log(
381
+ ` ${c.green('✓')} Migrated ${c.bold(String(totalMigrated))} records from legacy log files to events.ndjson`
382
+ )
383
+ }
384
+ }
385
+
325
386
  function sameSet(a: Set<string>, b: Set<string>): boolean {
326
387
  if (a.size !== b.size) return false
327
388
  for (const item of a) {