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.
- package/README.md +4 -0
- package/bin/cli.mjs +4 -0
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +5 -1
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/init.test.js +1 -1
- package/dist/cli/init.test.js.map +1 -1
- package/dist/cli/lesson.d.ts +17 -0
- package/dist/cli/lesson.d.ts.map +1 -0
- package/dist/cli/lesson.js +294 -0
- package/dist/cli/lesson.js.map +1 -0
- package/dist/cli/log.d.ts +7 -0
- package/dist/cli/log.d.ts.map +1 -0
- package/dist/cli/log.js +131 -0
- package/dist/cli/log.js.map +1 -0
- package/dist/cli/run/executor.js.map +1 -1
- package/dist/cli/run/loop-executor.d.ts +3 -0
- package/dist/cli/run/loop-executor.d.ts.map +1 -0
- package/dist/cli/run/loop-executor.js +154 -0
- package/dist/cli/run/loop-executor.js.map +1 -0
- package/dist/cli/run/loop-reporter.d.ts +6 -0
- package/dist/cli/run/loop-reporter.d.ts.map +1 -0
- package/dist/cli/run/loop-reporter.js +112 -0
- package/dist/cli/run/loop-reporter.js.map +1 -0
- package/dist/cli/run/reporter.d.ts.map +1 -1
- package/dist/cli/run/reporter.js +28 -1
- package/dist/cli/run/reporter.js.map +1 -1
- package/dist/cli/run/schema.d.ts.map +1 -1
- package/dist/cli/run/schema.js +104 -52
- package/dist/cli/run/schema.js.map +1 -1
- package/dist/cli/run/schema.test.js +214 -0
- package/dist/cli/run/schema.test.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +84 -3
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/types.d.ts +59 -1
- package/dist/cli/types.d.ts.map +1 -1
- package/dist/cli/update.d.ts.map +1 -1
- package/dist/cli/update.js +54 -1
- package/dist/cli/update.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/dashboard.ts +5 -1
- package/src/cli/init.test.ts +1 -1
- package/src/cli/lesson.ts +312 -0
- package/src/cli/log.ts +133 -0
- package/src/cli/run/executor.ts +8 -8
- package/src/cli/run/loop-executor.ts +198 -0
- package/src/cli/run/loop-reporter.ts +125 -0
- package/src/cli/run/reporter.ts +30 -1
- package/src/cli/run/schema.test.ts +242 -2
- package/src/cli/run/schema.ts +115 -59
- package/src/cli/run.ts +82 -5
- package/src/cli/types.ts +67 -1
- package/src/cli/update.ts +62 -1
- package/src/dashboard/dist/index.html +14 -15
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/scripts/generate-seed-data.ts +23 -43
- package/src/dashboard/seed-data/events.ndjson +104 -0
- package/src/dashboard/src/pages/index.astro +14 -15
- package/src/orchestrator/agents/api-designer.agent.md +1 -1
- package/src/orchestrator/agents/architect.agent.md +1 -1
- package/src/orchestrator/agents/content-engineer.agent.md +1 -1
- package/src/orchestrator/agents/copywriter.agent.md +1 -1
- package/src/orchestrator/agents/data-expert.agent.md +1 -1
- package/src/orchestrator/agents/database-engineer.agent.md +1 -1
- package/src/orchestrator/agents/developer.agent.md +1 -1
- package/src/orchestrator/agents/devops-expert.agent.md +1 -1
- package/src/orchestrator/agents/documentation-writer.agent.md +1 -1
- package/src/orchestrator/agents/performance-expert.agent.md +1 -1
- package/src/orchestrator/agents/release-manager.agent.md +1 -1
- package/src/orchestrator/agents/security-expert.agent.md +1 -1
- package/src/orchestrator/agents/seo-specialist.agent.md +1 -1
- package/src/orchestrator/agents/session-guard.agent.md +9 -21
- package/src/orchestrator/agents/team-lead.agent.md +8 -34
- package/src/orchestrator/agents/testing-expert.agent.md +1 -1
- package/src/orchestrator/agents/ui-ux-expert.agent.md +1 -1
- package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +11 -12
- package/src/orchestrator/customizations/DISPUTES.md +2 -2
- package/src/orchestrator/customizations/README.md +1 -3
- package/src/orchestrator/customizations/logs/README.md +66 -14
- package/src/orchestrator/instructions/ai-optimization.instructions.md +21 -132
- package/src/orchestrator/instructions/general.instructions.md +35 -181
- package/src/orchestrator/plugins/nx/SKILL.md +1 -1
- package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +4 -8
- package/src/orchestrator/prompts/bug-fix.prompt.md +4 -4
- package/src/orchestrator/prompts/implement-feature.prompt.md +3 -3
- package/src/orchestrator/prompts/quick-refinement.prompt.md +3 -3
- package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +1 -1
- package/src/orchestrator/skills/agent-hooks/SKILL.md +11 -11
- package/src/orchestrator/skills/decomposition/SKILL.md +1 -1
- package/src/orchestrator/skills/fast-review/SKILL.md +4 -19
- package/src/orchestrator/skills/git-workflow/SKILL.md +72 -0
- package/src/orchestrator/skills/memory-merger/SKILL.md +1 -1
- package/src/orchestrator/skills/observability-logging/SKILL.md +129 -0
- package/src/orchestrator/skills/orchestration-protocols/SKILL.md +2 -2
- package/src/orchestrator/skills/panel-majority-vote/SKILL.md +4 -7
- package/src/orchestrator/skills/self-improvement/SKILL.md +13 -26
- package/src/orchestrator/skills/team-lead-reference/SKILL.md +2 -2
- package/src/orchestrator/customizations/logs/delegations.ndjson +0 -1
- package/src/orchestrator/customizations/logs/panels.ndjson +0 -1
- package/src/orchestrator/customizations/logs/reviews.ndjson +0 -0
- package/src/orchestrator/customizations/logs/sessions.ndjson +0 -1
- /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
|
+
}
|
package/src/cli/run/reporter.ts
CHANGED
|
@@ -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
|
|
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
|
+
})
|
package/src/cli/run/schema.ts
CHANGED
|
@@ -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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
121
|
-
if (!
|
|
122
|
-
errors.push(
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|