opencastle 0.10.7 → 0.11.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 (103) hide show
  1. package/README.md +4 -0
  2. package/bin/cli.mjs +4 -0
  3. package/dist/cli/dashboard.d.ts.map +1 -1
  4. package/dist/cli/dashboard.js +5 -1
  5. package/dist/cli/dashboard.js.map +1 -1
  6. package/dist/cli/init.test.js +1 -1
  7. package/dist/cli/init.test.js.map +1 -1
  8. package/dist/cli/lesson.d.ts +17 -0
  9. package/dist/cli/lesson.d.ts.map +1 -0
  10. package/dist/cli/lesson.js +294 -0
  11. package/dist/cli/lesson.js.map +1 -0
  12. package/dist/cli/log.d.ts +7 -0
  13. package/dist/cli/log.d.ts.map +1 -0
  14. package/dist/cli/log.js +131 -0
  15. package/dist/cli/log.js.map +1 -0
  16. package/dist/cli/run/executor.js.map +1 -1
  17. package/dist/cli/run/loop-executor.d.ts +3 -0
  18. package/dist/cli/run/loop-executor.d.ts.map +1 -0
  19. package/dist/cli/run/loop-executor.js +154 -0
  20. package/dist/cli/run/loop-executor.js.map +1 -0
  21. package/dist/cli/run/loop-reporter.d.ts +6 -0
  22. package/dist/cli/run/loop-reporter.d.ts.map +1 -0
  23. package/dist/cli/run/loop-reporter.js +112 -0
  24. package/dist/cli/run/loop-reporter.js.map +1 -0
  25. package/dist/cli/run/reporter.d.ts.map +1 -1
  26. package/dist/cli/run/reporter.js +28 -1
  27. package/dist/cli/run/reporter.js.map +1 -1
  28. package/dist/cli/run/schema.d.ts.map +1 -1
  29. package/dist/cli/run/schema.js +104 -52
  30. package/dist/cli/run/schema.js.map +1 -1
  31. package/dist/cli/run/schema.test.js +214 -0
  32. package/dist/cli/run/schema.test.js.map +1 -1
  33. package/dist/cli/run.d.ts.map +1 -1
  34. package/dist/cli/run.js +84 -3
  35. package/dist/cli/run.js.map +1 -1
  36. package/dist/cli/types.d.ts +59 -1
  37. package/dist/cli/types.d.ts.map +1 -1
  38. package/dist/cli/update.d.ts.map +1 -1
  39. package/dist/cli/update.js +54 -1
  40. package/dist/cli/update.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/cli/dashboard.ts +5 -1
  43. package/src/cli/init.test.ts +1 -1
  44. package/src/cli/lesson.ts +312 -0
  45. package/src/cli/log.ts +133 -0
  46. package/src/cli/run/executor.ts +8 -8
  47. package/src/cli/run/loop-executor.ts +198 -0
  48. package/src/cli/run/loop-reporter.ts +125 -0
  49. package/src/cli/run/reporter.ts +30 -1
  50. package/src/cli/run/schema.test.ts +242 -2
  51. package/src/cli/run/schema.ts +115 -59
  52. package/src/cli/run.ts +82 -5
  53. package/src/cli/types.ts +67 -1
  54. package/src/cli/update.ts +62 -1
  55. package/src/dashboard/dist/index.html +14 -15
  56. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  57. package/src/dashboard/scripts/generate-seed-data.ts +23 -43
  58. package/src/dashboard/seed-data/events.ndjson +104 -0
  59. package/src/dashboard/src/pages/index.astro +14 -15
  60. package/src/orchestrator/agents/api-designer.agent.md +1 -1
  61. package/src/orchestrator/agents/architect.agent.md +1 -1
  62. package/src/orchestrator/agents/content-engineer.agent.md +1 -1
  63. package/src/orchestrator/agents/copywriter.agent.md +1 -1
  64. package/src/orchestrator/agents/data-expert.agent.md +1 -1
  65. package/src/orchestrator/agents/database-engineer.agent.md +1 -1
  66. package/src/orchestrator/agents/developer.agent.md +1 -1
  67. package/src/orchestrator/agents/devops-expert.agent.md +1 -1
  68. package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
  69. package/src/orchestrator/agents/performance-expert.agent.md +1 -1
  70. package/src/orchestrator/agents/release-manager.agent.md +1 -1
  71. package/src/orchestrator/agents/security-expert.agent.md +1 -1
  72. package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
  73. package/src/orchestrator/agents/session-guard.agent.md +9 -21
  74. package/src/orchestrator/agents/team-lead.agent.md +8 -34
  75. package/src/orchestrator/agents/testing-expert.agent.md +1 -1
  76. package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
  77. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
  78. package/src/orchestrator/customizations/DISPUTES.md +2 -2
  79. package/src/orchestrator/customizations/README.md +1 -3
  80. package/src/orchestrator/customizations/logs/README.md +66 -14
  81. package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
  82. package/src/orchestrator/instructions/general.instructions.md +35 -181
  83. package/src/orchestrator/plugins/nx/SKILL.md +1 -1
  84. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
  85. package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
  86. package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
  87. package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
  88. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
  89. package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
  90. package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
  91. package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
  92. package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
  93. package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
  94. package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
  95. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
  96. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
  97. package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
  98. package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
  99. package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
  100. package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
  101. package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
  102. package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
  103. /package/src/orchestrator/customizations/logs/{disputes.ndjson → events.ndjson} +0 -0
@@ -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++) {
@@ -324,7 +324,7 @@ describe('applyDefaults', () => {
324
324
  name: 'test',
325
325
  tasks: [{ id: 'a', prompt: 'x' }],
326
326
  })
327
- const task = spec.tasks[0]
327
+ const task = spec.tasks![0]
328
328
  expect(task.agent).toBe('developer')
329
329
  expect(task.timeout).toBe('30m')
330
330
  expect(task.depends_on).toEqual([])
@@ -343,10 +343,250 @@ describe('applyDefaults', () => {
343
343
  files: ['src/'],
344
344
  }],
345
345
  })
346
- const task = spec.tasks[0]
346
+ const task = spec.tasks![0]
347
347
  expect(task.agent).toBe('ui-ux-expert')
348
348
  expect(task.timeout).toBe('15m')
349
349
  expect(task.depends_on).toEqual(['b'])
350
350
  expect(task.files).toEqual(['src/'])
351
351
  })
352
352
  })
353
+
354
+ // ── loop mode — validateSpec ───────────────────────────────────
355
+
356
+ describe('validateSpec — loop mode', () => {
357
+ const validLoopSpec = {
358
+ name: 'build-auth',
359
+ mode: 'loop',
360
+ adapter: 'copilot',
361
+ loop: {
362
+ prompt: 'PROMPT_build.md',
363
+ plan_file: 'IMPLEMENTATION_PLAN.md',
364
+ max_iterations: 20,
365
+ timeout: '10m',
366
+ model: 'gpt-5.1',
367
+ backpressure: ['npm test', 'npx tsc --noEmit'],
368
+ },
369
+ }
370
+
371
+ it('accepts a valid minimal loop spec (only prompt required)', () => {
372
+ const result = validateSpec({
373
+ name: 'build-auth',
374
+ mode: 'loop',
375
+ loop: { prompt: 'PROMPT_build.md' },
376
+ })
377
+ expect(result.valid).toBe(true)
378
+ expect(result.errors).toHaveLength(0)
379
+ })
380
+
381
+ it('accepts a full loop spec', () => {
382
+ const result = validateSpec(validLoopSpec)
383
+ expect(result.valid).toBe(true)
384
+ expect(result.errors).toHaveLength(0)
385
+ })
386
+
387
+ it('does not require tasks array in loop mode', () => {
388
+ const result = validateSpec({
389
+ name: 'build-auth',
390
+ mode: 'loop',
391
+ loop: { prompt: 'PROMPT_build.md' },
392
+ })
393
+ expect(result.valid).toBe(true)
394
+ })
395
+
396
+ it('fails when loop object is missing', () => {
397
+ const result = validateSpec({ name: 'build-auth', mode: 'loop' })
398
+ expect(result.valid).toBe(false)
399
+ expect(result.errors).toContainEqual(expect.stringContaining('`loop` is required'))
400
+ })
401
+
402
+ it('fails when loop.prompt is missing', () => {
403
+ const result = validateSpec({
404
+ name: 'build-auth',
405
+ mode: 'loop',
406
+ loop: { max_iterations: 10 },
407
+ })
408
+ expect(result.valid).toBe(false)
409
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
410
+ })
411
+
412
+ it('fails when loop.prompt is not a string', () => {
413
+ const result = validateSpec({
414
+ name: 'build-auth',
415
+ mode: 'loop',
416
+ loop: { prompt: 123 },
417
+ })
418
+ expect(result.valid).toBe(false)
419
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
420
+ })
421
+
422
+ it('fails when loop.max_iterations is 0', () => {
423
+ const result = validateSpec({
424
+ name: 'build-auth',
425
+ mode: 'loop',
426
+ loop: { prompt: 'PROMPT.md', max_iterations: 0 },
427
+ })
428
+ expect(result.valid).toBe(false)
429
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
430
+ })
431
+
432
+ it('fails when loop.max_iterations is a float', () => {
433
+ const result = validateSpec({
434
+ name: 'build-auth',
435
+ mode: 'loop',
436
+ loop: { prompt: 'PROMPT.md', max_iterations: 1.5 },
437
+ })
438
+ expect(result.valid).toBe(false)
439
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
440
+ })
441
+
442
+ it('fails when loop.timeout has invalid format', () => {
443
+ const result = validateSpec({
444
+ name: 'build-auth',
445
+ mode: 'loop',
446
+ loop: { prompt: 'PROMPT.md', timeout: 'bad' },
447
+ })
448
+ expect(result.valid).toBe(false)
449
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.timeout'))
450
+ })
451
+
452
+ it('accepts valid loop.timeout formats', () => {
453
+ for (const t of ['5s', '10m', '2h']) {
454
+ const result = validateSpec({
455
+ name: 'build-auth',
456
+ mode: 'loop',
457
+ loop: { prompt: 'PROMPT.md', timeout: t },
458
+ })
459
+ expect(result.valid).toBe(true)
460
+ }
461
+ })
462
+
463
+ it('fails when loop.backpressure is not an array', () => {
464
+ const result = validateSpec({
465
+ name: 'build-auth',
466
+ mode: 'loop',
467
+ loop: { prompt: 'PROMPT.md', backpressure: 'npm test' },
468
+ })
469
+ expect(result.valid).toBe(false)
470
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
471
+ })
472
+
473
+ it('fails when loop.backpressure contains non-strings', () => {
474
+ const result = validateSpec({
475
+ name: 'build-auth',
476
+ mode: 'loop',
477
+ loop: { prompt: 'PROMPT.md', backpressure: ['npm test', 42] },
478
+ })
479
+ expect(result.valid).toBe(false)
480
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
481
+ })
482
+
483
+ it('fails when loop.plan_file is not a string', () => {
484
+ const result = validateSpec({
485
+ name: 'build-auth',
486
+ mode: 'loop',
487
+ loop: { prompt: 'PROMPT.md', plan_file: true },
488
+ })
489
+ expect(result.valid).toBe(false)
490
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.plan_file'))
491
+ })
492
+
493
+ it('fails when loop.model is not a string', () => {
494
+ const result = validateSpec({
495
+ name: 'build-auth',
496
+ mode: 'loop',
497
+ loop: { prompt: 'PROMPT.md', model: 99 },
498
+ })
499
+ expect(result.valid).toBe(false)
500
+ expect(result.errors).toContainEqual(expect.stringContaining('loop.model'))
501
+ })
502
+
503
+ it('rejects unknown mode value', () => {
504
+ const result = validateSpec({
505
+ name: 'build-auth',
506
+ mode: 'parallel',
507
+ tasks: [{ id: 'a', prompt: 'x' }],
508
+ })
509
+ expect(result.valid).toBe(false)
510
+ expect(result.errors).toContainEqual(expect.stringContaining('mode'))
511
+ })
512
+
513
+ it('mode: tasks still requires tasks array', () => {
514
+ const result = validateSpec({ name: 'build-auth', mode: 'tasks' })
515
+ expect(result.valid).toBe(false)
516
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
517
+ })
518
+
519
+ it('spec without mode field defaults to tasks behavior (requires tasks)', () => {
520
+ const result = validateSpec({ name: 'build-auth' })
521
+ expect(result.valid).toBe(false)
522
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
523
+ })
524
+ })
525
+
526
+ // ── loop mode — applyDefaults ──────────────────────────────────
527
+
528
+ describe('applyDefaults — loop mode', () => {
529
+ it('applies default max_iterations', () => {
530
+ const spec = applyDefaults({
531
+ name: 'build-auth',
532
+ mode: 'loop',
533
+ loop: { prompt: 'PROMPT.md' },
534
+ })
535
+ expect(spec.loop?.max_iterations).toBe(20)
536
+ })
537
+
538
+ it('applies default plan_file', () => {
539
+ const spec = applyDefaults({
540
+ name: 'build-auth',
541
+ mode: 'loop',
542
+ loop: { prompt: 'PROMPT.md' },
543
+ })
544
+ expect(spec.loop?.plan_file).toBe('IMPLEMENTATION_PLAN.md')
545
+ })
546
+
547
+ it('applies default timeout', () => {
548
+ const spec = applyDefaults({
549
+ name: 'build-auth',
550
+ mode: 'loop',
551
+ loop: { prompt: 'PROMPT.md' },
552
+ })
553
+ expect(spec.loop?.timeout).toBe('10m')
554
+ })
555
+
556
+ it('preserves user-specified loop values', () => {
557
+ const spec = applyDefaults({
558
+ name: 'build-auth',
559
+ mode: 'loop',
560
+ loop: {
561
+ prompt: 'PROMPT.md',
562
+ max_iterations: 5,
563
+ plan_file: 'MY_PLAN.md',
564
+ timeout: '30m',
565
+ model: 'gpt-5.1',
566
+ backpressure: ['npm test'],
567
+ },
568
+ })
569
+ expect(spec.loop?.max_iterations).toBe(5)
570
+ expect(spec.loop?.plan_file).toBe('MY_PLAN.md')
571
+ expect(spec.loop?.timeout).toBe('30m')
572
+ expect(spec.loop?.model).toBe('gpt-5.1')
573
+ expect(spec.loop?.backpressure).toEqual(['npm test'])
574
+ })
575
+
576
+ it('sets mode to tasks when not specified', () => {
577
+ const spec = applyDefaults({
578
+ name: 'test',
579
+ tasks: [{ id: 'a', prompt: 'x' }],
580
+ })
581
+ expect(spec.mode).toBe('tasks')
582
+ })
583
+
584
+ it('preserves mode: loop', () => {
585
+ const spec = applyDefaults({
586
+ name: 'build-auth',
587
+ mode: 'loop',
588
+ loop: { prompt: 'PROMPT.md' },
589
+ })
590
+ expect(spec.mode).toBe('loop')
591
+ })
592
+ })
@@ -39,6 +39,8 @@ interface RawSpec {
39
39
  on_failure?: unknown
40
40
  adapter?: unknown
41
41
  tasks?: unknown
42
+ mode?: unknown
43
+ loop?: unknown
42
44
  }
43
45
 
44
46
  interface RawTask {
@@ -90,72 +92,117 @@ export function validateSpec(spec: unknown): ValidationResult {
90
92
  errors.push('`adapter` must be a string')
91
93
  }
92
94
 
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 }
95
+ // mode
96
+ const mode = s.mode !== undefined ? s.mode : 'tasks'
97
+ if (mode !== 'tasks' && mode !== 'loop') {
98
+ errors.push('`mode` must be one of: tasks, loop')
97
99
  }
98
100
 
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}]`
105
-
106
- if (!task || typeof task !== 'object') {
107
- errors.push(`${prefix}: must be an object`)
108
- continue
109
- }
110
-
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}"`)
101
+ if (mode === 'loop') {
102
+ // Loop validation tasks array is NOT required
103
+ const loop = s.loop as Record<string, unknown> | undefined
104
+ if (!loop || typeof loop !== 'object') {
105
+ errors.push('`loop` is required when mode is "loop"')
116
106
  } else {
117
- taskIds.add(task.id)
107
+ if (!loop.prompt || typeof loop.prompt !== 'string') {
108
+ errors.push('`loop.prompt` is required and must be a string')
109
+ }
110
+ if (loop.max_iterations !== undefined) {
111
+ const mi = Number(loop.max_iterations)
112
+ if (!Number.isInteger(mi) || mi < 1) {
113
+ errors.push('`loop.max_iterations` must be an integer >= 1')
114
+ }
115
+ }
116
+ if (loop.timeout !== undefined) {
117
+ if (isNaN(parseTimeout(loop.timeout as string))) {
118
+ errors.push(
119
+ '`loop.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
120
+ )
121
+ }
122
+ }
123
+ if (loop.backpressure !== undefined) {
124
+ if (
125
+ !Array.isArray(loop.backpressure) ||
126
+ !(loop.backpressure as unknown[]).every((b) => typeof b === 'string')
127
+ ) {
128
+ errors.push('`loop.backpressure` must be an array of strings')
129
+ }
130
+ }
131
+ if (loop.plan_file !== undefined && typeof loop.plan_file !== 'string') {
132
+ errors.push('`loop.plan_file` must be a string')
133
+ }
134
+ if (loop.model !== undefined && typeof loop.model !== 'string') {
135
+ errors.push('`loop.model` must be a string')
136
+ }
118
137
  }
119
-
120
- // prompt
121
- if (!task.prompt || typeof task.prompt !== 'string') {
122
- errors.push(`${prefix}: \`prompt\` is required and must be a string`)
138
+ } else {
139
+ // Tasks mode — tasks array is required
140
+ if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
141
+ errors.push('`tasks` is required and must be a non-empty array')
142
+ return { valid: false, errors }
123
143
  }
124
144
 
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
- )
145
+ const taskIds = new Set<string>()
146
+ const tasks = s.tasks as RawTask[]
147
+
148
+ for (let i = 0; i < tasks.length; i++) {
149
+ const task = tasks[i]
150
+ const prefix = `tasks[${i}]`
151
+
152
+ if (!task || typeof task !== 'object') {
153
+ errors.push(`${prefix}: must be an object`)
154
+ continue
131
155
  }
132
- }
133
156
 
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`)
157
+ // id
158
+ if (!task.id || typeof task.id !== 'string') {
159
+ errors.push(`${prefix}: \`id\` is required and must be a string`)
160
+ } else if (taskIds.has(task.id)) {
161
+ errors.push(`${prefix}: duplicate task id "${task.id}"`)
138
162
  } 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
- )
163
+ taskIds.add(task.id)
164
+ }
165
+
166
+ // prompt
167
+ if (!task.prompt || typeof task.prompt !== 'string') {
168
+ errors.push(`${prefix}: \`prompt\` is required and must be a string`)
169
+ }
170
+
171
+ // timeout
172
+ if (task.timeout !== undefined) {
173
+ if (isNaN(parseTimeout(task.timeout as string))) {
174
+ errors.push(
175
+ `${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
176
+ )
177
+ }
178
+ }
179
+
180
+ // depends_on
181
+ if (task.depends_on !== undefined) {
182
+ if (!Array.isArray(task.depends_on)) {
183
+ errors.push(`${prefix}: \`depends_on\` must be an array`)
184
+ } else {
185
+ for (const dep of task.depends_on as string[]) {
186
+ if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
187
+ errors.push(
188
+ `${prefix}: \`depends_on\` references unknown task "${dep}"`
189
+ )
190
+ }
144
191
  }
145
192
  }
146
193
  }
147
- }
148
194
 
149
- // files
150
- if (task.files !== undefined && !Array.isArray(task.files)) {
151
- errors.push(`${prefix}: \`files\` must be an array`)
195
+ // files
196
+ if (task.files !== undefined && !Array.isArray(task.files)) {
197
+ errors.push(`${prefix}: \`files\` must be an array`)
198
+ }
152
199
  }
153
- }
154
200
 
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)
201
+ // DAG cycle detection
202
+ if (errors.length === 0) {
203
+ const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
204
+ if (cycleErr) errors.push(cycleErr)
205
+ }
159
206
  }
160
207
 
161
208
  return { valid: errors.length === 0, errors }
@@ -214,14 +261,23 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
214
261
  s.on_failure = (s.on_failure as string) || 'continue'
215
262
  // Leave adapter empty so run.ts can auto-detect the best available CLI
216
263
  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)
264
+ s.mode = (s.mode as string) || 'tasks'
265
+
266
+ if (s.mode === 'loop') {
267
+ const loop = ((s.loop ?? {}) as Record<string, unknown>)
268
+ loop.max_iterations = loop.max_iterations !== undefined ? Number(loop.max_iterations) : 20
269
+ loop.plan_file = (loop.plan_file as string) || 'IMPLEMENTATION_PLAN.md'
270
+ loop.timeout = (loop.timeout as string) || '10m'
271
+ s.loop = loop
272
+ } else {
273
+ const tasks = s.tasks as Array<Record<string, unknown>>
274
+ for (const task of tasks) {
275
+ task.agent = (task.agent as string) || 'developer'
276
+ task.timeout = (task.timeout as string) || '30m'
277
+ task.depends_on = (task.depends_on as string[]) || []
278
+ task.files = (task.files as string[]) || []
279
+ task.description = (task.description as string) || (task.id as string)
280
+ }
225
281
  }
226
282
 
227
283
  return s as unknown as TaskSpec