opencastle 0.1.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/LICENSE +21 -0
- package/README.md +215 -0
- package/bin/cli.mjs +69 -0
- package/dist/cli/adapters/claude-code.d.ts +22 -0
- package/dist/cli/adapters/claude-code.d.ts.map +1 -0
- package/dist/cli/adapters/claude-code.js +237 -0
- package/dist/cli/adapters/claude-code.js.map +1 -0
- package/dist/cli/adapters/cursor.d.ts +20 -0
- package/dist/cli/adapters/cursor.d.ts.map +1 -0
- package/dist/cli/adapters/cursor.js +231 -0
- package/dist/cli/adapters/cursor.js.map +1 -0
- package/dist/cli/adapters/vscode.d.ts +20 -0
- package/dist/cli/adapters/vscode.d.ts.map +1 -0
- package/dist/cli/adapters/vscode.js +132 -0
- package/dist/cli/adapters/vscode.js.map +1 -0
- package/dist/cli/copy.d.ts +14 -0
- package/dist/cli/copy.d.ts.map +1 -0
- package/dist/cli/copy.js +62 -0
- package/dist/cli/copy.js.map +1 -0
- package/dist/cli/dashboard.d.ts +3 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/dashboard.js +183 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/diff.d.ts +3 -0
- package/dist/cli/diff.d.ts.map +1 -0
- package/dist/cli/diff.js +27 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/eject.d.ts +3 -0
- package/dist/cli/eject.d.ts.map +1 -0
- package/dist/cli/eject.js +27 -0
- package/dist/cli/eject.js.map +1 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +92 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/manifest.d.ts +14 -0
- package/dist/cli/manifest.d.ts.map +1 -0
- package/dist/cli/manifest.js +34 -0
- package/dist/cli/manifest.js.map +1 -0
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -0
- package/dist/cli/mcp.js +35 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/cli/prompt.d.ts +12 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/prompt.js +104 -0
- package/dist/cli/prompt.js.map +1 -0
- package/dist/cli/run/adapters/claude-code.d.ts +16 -0
- package/dist/cli/run/adapters/claude-code.d.ts.map +1 -0
- package/dist/cli/run/adapters/claude-code.js +82 -0
- package/dist/cli/run/adapters/claude-code.js.map +1 -0
- package/dist/cli/run/adapters/copilot.d.ts +16 -0
- package/dist/cli/run/adapters/copilot.d.ts.map +1 -0
- package/dist/cli/run/adapters/copilot.js +84 -0
- package/dist/cli/run/adapters/copilot.js.map +1 -0
- package/dist/cli/run/adapters/cursor.d.ts +16 -0
- package/dist/cli/run/adapters/cursor.d.ts.map +1 -0
- package/dist/cli/run/adapters/cursor.js +81 -0
- package/dist/cli/run/adapters/cursor.js.map +1 -0
- package/dist/cli/run/adapters/index.d.ts +14 -0
- package/dist/cli/run/adapters/index.d.ts.map +1 -0
- package/dist/cli/run/adapters/index.js +35 -0
- package/dist/cli/run/adapters/index.js.map +1 -0
- package/dist/cli/run/executor.d.ts +15 -0
- package/dist/cli/run/executor.d.ts.map +1 -0
- package/dist/cli/run/executor.js +249 -0
- package/dist/cli/run/executor.js.map +1 -0
- package/dist/cli/run/reporter.d.ts +10 -0
- package/dist/cli/run/reporter.d.ts.map +1 -0
- package/dist/cli/run/reporter.js +112 -0
- package/dist/cli/run/reporter.js.map +1 -0
- package/dist/cli/run/schema.d.ts +28 -0
- package/dist/cli/run/schema.d.ts.map +1 -0
- package/dist/cli/run/schema.js +511 -0
- package/dist/cli/run/schema.js.map +1 -0
- package/dist/cli/run.d.ts +6 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +123 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/stack-config.d.ts +12 -0
- package/dist/cli/stack-config.d.ts.map +1 -0
- package/dist/cli/stack-config.js +146 -0
- package/dist/cli/stack-config.js.map +1 -0
- package/dist/cli/types.d.ts +169 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +2 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/cli/update.d.ts +3 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +50 -0
- package/dist/cli/update.js.map +1 -0
- package/package.json +48 -0
- package/src/cli/adapters/claude-code.ts +287 -0
- package/src/cli/adapters/cursor.ts +377 -0
- package/src/cli/adapters/vscode.ts +168 -0
- package/src/cli/copy.ts +79 -0
- package/src/cli/dashboard.ts +225 -0
- package/src/cli/diff.ts +44 -0
- package/src/cli/eject.ts +39 -0
- package/src/cli/init.ts +120 -0
- package/src/cli/manifest.ts +45 -0
- package/src/cli/mcp.ts +49 -0
- package/src/cli/prompt.ts +115 -0
- package/src/cli/run/adapters/claude-code.ts +95 -0
- package/src/cli/run/adapters/copilot.ts +97 -0
- package/src/cli/run/adapters/cursor.ts +94 -0
- package/src/cli/run/adapters/index.ts +40 -0
- package/src/cli/run/executor.ts +292 -0
- package/src/cli/run/reporter.ts +129 -0
- package/src/cli/run/schema.ts +595 -0
- package/src/cli/run.ts +137 -0
- package/src/cli/stack-config.ts +180 -0
- package/src/cli/types.ts +207 -0
- package/src/cli/update.ts +75 -0
- package/src/dashboard/astro.config.mjs +6 -0
- package/src/dashboard/package-lock.json +5455 -0
- package/src/dashboard/package.json +14 -0
- package/src/dashboard/public/data/delegations.ndjson +35 -0
- package/src/dashboard/public/data/panels.ndjson +13 -0
- package/src/dashboard/public/data/sessions.ndjson +50 -0
- package/src/dashboard/public/icon-192.png +0 -0
- package/src/dashboard/scripts/generate-seed-data.ts +355 -0
- package/src/dashboard/src/layouts/Layout.astro +25 -0
- package/src/dashboard/src/pages/index.astro +1070 -0
- package/src/dashboard/src/styles/dashboard.css +1078 -0
- package/src/dashboard/tsconfig.json +6 -0
- package/src/orchestrator/agent-workflows/README.md +22 -0
- package/src/orchestrator/agent-workflows/bug-fix.md +128 -0
- package/src/orchestrator/agent-workflows/data-pipeline.md +145 -0
- package/src/orchestrator/agent-workflows/database-migration.md +159 -0
- package/src/orchestrator/agent-workflows/feature-implementation.md +223 -0
- package/src/orchestrator/agent-workflows/performance-optimization.md +125 -0
- package/src/orchestrator/agent-workflows/refactoring.md +142 -0
- package/src/orchestrator/agent-workflows/schema-changes.md +164 -0
- package/src/orchestrator/agent-workflows/security-audit.md +148 -0
- package/src/orchestrator/agent-workflows/shared-delivery-phase.md +33 -0
- package/src/orchestrator/agents/api-designer.agent.md +68 -0
- package/src/orchestrator/agents/architect.agent.md +129 -0
- package/src/orchestrator/agents/content-engineer.agent.md +57 -0
- package/src/orchestrator/agents/copywriter.agent.md +95 -0
- package/src/orchestrator/agents/data-expert.agent.md +63 -0
- package/src/orchestrator/agents/database-engineer.agent.md +62 -0
- package/src/orchestrator/agents/developer.agent.md +66 -0
- package/src/orchestrator/agents/devops-expert.agent.md +57 -0
- package/src/orchestrator/agents/documentation-writer.agent.md +60 -0
- package/src/orchestrator/agents/performance-expert.agent.md +58 -0
- package/src/orchestrator/agents/release-manager.agent.md +72 -0
- package/src/orchestrator/agents/researcher.agent.md +145 -0
- package/src/orchestrator/agents/reviewer.agent.md +62 -0
- package/src/orchestrator/agents/security-expert.agent.md +64 -0
- package/src/orchestrator/agents/seo-specialist.agent.md +67 -0
- package/src/orchestrator/agents/team-lead.agent.md +644 -0
- package/src/orchestrator/agents/testing-expert.agent.md +85 -0
- package/src/orchestrator/agents/ui-ux-expert.agent.md +63 -0
- package/src/orchestrator/copilot-instructions.md +3 -0
- package/src/orchestrator/customizations/AGENT-EXPERTISE.md +325 -0
- package/src/orchestrator/customizations/AGENT-FAILURES.md +69 -0
- package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +58 -0
- package/src/orchestrator/customizations/DISPUTES.md +162 -0
- package/src/orchestrator/customizations/KNOWLEDGE-GRAPH.md +10 -0
- package/src/orchestrator/customizations/LESSONS-LEARNED.md +70 -0
- package/src/orchestrator/customizations/README.md +59 -0
- package/src/orchestrator/customizations/agents/agent-registry.md +46 -0
- package/src/orchestrator/customizations/agents/skill-matrix.md +142 -0
- package/src/orchestrator/customizations/logs/README.md +181 -0
- package/src/orchestrator/customizations/logs/delegations.ndjson +1 -0
- package/src/orchestrator/customizations/logs/panels.ndjson +1 -0
- package/src/orchestrator/customizations/logs/sessions.ndjson +1 -0
- package/src/orchestrator/customizations/project/docs-structure.md +23 -0
- package/src/orchestrator/customizations/project/tracker-config.md +45 -0
- package/src/orchestrator/customizations/project.instructions.md +64 -0
- package/src/orchestrator/customizations/stack/api-config.md +37 -0
- package/src/orchestrator/customizations/stack/cms-config.md +26 -0
- package/src/orchestrator/customizations/stack/data-pipeline-config.md +41 -0
- package/src/orchestrator/customizations/stack/database-config.md +44 -0
- package/src/orchestrator/customizations/stack/deployment-config.md +45 -0
- package/src/orchestrator/customizations/stack/testing-config.md +56 -0
- package/src/orchestrator/instructions/ai-optimization.instructions.md +143 -0
- package/src/orchestrator/instructions/general.instructions.md +194 -0
- package/src/orchestrator/mcp.json +55 -0
- package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +235 -0
- package/src/orchestrator/prompts/brainstorm.prompt.md +115 -0
- package/src/orchestrator/prompts/bug-fix.prompt.md +141 -0
- package/src/orchestrator/prompts/create-skill.prompt.md +103 -0
- package/src/orchestrator/prompts/generate-task-spec.prompt.md +154 -0
- package/src/orchestrator/prompts/implement-feature.prompt.md +124 -0
- package/src/orchestrator/prompts/metrics-report.prompt.md +142 -0
- package/src/orchestrator/prompts/quick-refinement.prompt.md +137 -0
- package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +100 -0
- package/src/orchestrator/skills/accessibility-standards/SKILL.md +164 -0
- package/src/orchestrator/skills/agent-hooks/SKILL.md +147 -0
- package/src/orchestrator/skills/agent-memory/SKILL.md +144 -0
- package/src/orchestrator/skills/api-patterns/SKILL.md +106 -0
- package/src/orchestrator/skills/browser-testing/SKILL.md +203 -0
- package/src/orchestrator/skills/code-commenting/SKILL.md +133 -0
- package/src/orchestrator/skills/contentful-cms/SKILL.md +43 -0
- package/src/orchestrator/skills/context-map/SKILL.md +135 -0
- package/src/orchestrator/skills/convex-database/SKILL.md +80 -0
- package/src/orchestrator/skills/data-engineering/SKILL.md +99 -0
- package/src/orchestrator/skills/deployment-infrastructure/SKILL.md +49 -0
- package/src/orchestrator/skills/documentation-standards/SKILL.md +85 -0
- package/src/orchestrator/skills/fast-review/SKILL.md +327 -0
- package/src/orchestrator/skills/frontend-design/SKILL.md +42 -0
- package/src/orchestrator/skills/jira-management/SKILL.md +168 -0
- package/src/orchestrator/skills/memory-merger/SKILL.md +123 -0
- package/src/orchestrator/skills/nextjs-patterns/SKILL.md +75 -0
- package/src/orchestrator/skills/nx-workspace/SKILL.md +192 -0
- package/src/orchestrator/skills/panel-majority-vote/SKILL.md +184 -0
- package/src/orchestrator/skills/panel-majority-vote/panel-report.template.md +38 -0
- package/src/orchestrator/skills/performance-optimization/SKILL.md +101 -0
- package/src/orchestrator/skills/react-development/SKILL.md +117 -0
- package/src/orchestrator/skills/sanity-cms/SKILL.md +18 -0
- package/src/orchestrator/skills/security-hardening/SKILL.md +118 -0
- package/src/orchestrator/skills/self-improvement/SKILL.md +137 -0
- package/src/orchestrator/skills/seo-patterns/SKILL.md +40 -0
- package/src/orchestrator/skills/session-checkpoints/SKILL.md +205 -0
- package/src/orchestrator/skills/slack-notifications/SKILL.md +211 -0
- package/src/orchestrator/skills/strapi-cms/SKILL.md +43 -0
- package/src/orchestrator/skills/supabase-database/SKILL.md +24 -0
- package/src/orchestrator/skills/task-management/SKILL.md +143 -0
- package/src/orchestrator/skills/team-lead-reference/SKILL.md +317 -0
- package/src/orchestrator/skills/teams-notifications/SKILL.md +249 -0
- package/src/orchestrator/skills/testing-workflow/SKILL.md +134 -0
- package/src/orchestrator/skills/validation-gates/SKILL.md +100 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import type { TaskSpec, ParseResult, ValidationResult } from '../types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal YAML parser for task spec files.
|
|
6
|
+
* Handles: key-value, lists, nested objects, block scalars (|), comments, quoted strings.
|
|
7
|
+
* Does NOT handle: anchors, aliases, flow mappings, merge keys, tags.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a YAML string into a JS object.
|
|
12
|
+
*/
|
|
13
|
+
export function parseYaml(text: string): Record<string, unknown> {
|
|
14
|
+
const lines = text.split('\n')
|
|
15
|
+
return parseBlock(lines, 0, -1).value as Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Remove inline comments and trim trailing whitespace.
|
|
20
|
+
* Respects quoted strings — won't strip # inside quotes.
|
|
21
|
+
*/
|
|
22
|
+
function stripInlineComment(line: string): string {
|
|
23
|
+
let inSingle = false
|
|
24
|
+
let inDouble = false
|
|
25
|
+
for (let i = 0; i < line.length; i++) {
|
|
26
|
+
const ch = line[i]
|
|
27
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle
|
|
28
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble
|
|
29
|
+
else if (ch === '#' && !inSingle && !inDouble) {
|
|
30
|
+
// Must be preceded by whitespace (or be at start)
|
|
31
|
+
if (i === 0 || /\s/.test(line[i - 1])) {
|
|
32
|
+
return line.slice(0, i).trimEnd()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return line.trimEnd()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Measure indent level (number of leading spaces).
|
|
41
|
+
*/
|
|
42
|
+
function indentOf(line: string): number {
|
|
43
|
+
const m = line.match(/^( *)/)
|
|
44
|
+
return m ? m[1].length : 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Unquote a string value (strip surrounding quotes).
|
|
49
|
+
*/
|
|
50
|
+
function unquote(val: string): string {
|
|
51
|
+
if (
|
|
52
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
53
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
54
|
+
) {
|
|
55
|
+
return val.slice(1, -1)
|
|
56
|
+
}
|
|
57
|
+
return val
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cast a scalar value to its JS type.
|
|
62
|
+
*/
|
|
63
|
+
function castScalar(raw: string): string | number | boolean | null {
|
|
64
|
+
const val = raw.trim()
|
|
65
|
+
if (val === '' || val === '~' || val === 'null') return null
|
|
66
|
+
if (val === 'true') return true
|
|
67
|
+
if (val === 'false') return false
|
|
68
|
+
if (/^-?\d+$/.test(val)) return parseInt(val, 10)
|
|
69
|
+
if (/^-?\d+\.\d+$/.test(val)) return parseFloat(val)
|
|
70
|
+
return unquote(val)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse a block of YAML lines starting at `startIdx` with minimum indent `parentIndent`.
|
|
75
|
+
* Returns { value, nextIndex }.
|
|
76
|
+
*/
|
|
77
|
+
function parseBlock(lines: string[], startIdx: number, parentIndent: number): ParseResult {
|
|
78
|
+
let i = startIdx
|
|
79
|
+
|
|
80
|
+
// Skip blank / comment-only lines
|
|
81
|
+
while (i < lines.length) {
|
|
82
|
+
const stripped = lines[i].trimStart()
|
|
83
|
+
if (stripped === '' || stripped.startsWith('#')) {
|
|
84
|
+
i++
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
if (i >= lines.length) return { value: null, nextIndex: i }
|
|
90
|
+
|
|
91
|
+
const firstLine = stripInlineComment(lines[i])
|
|
92
|
+
const firstIndent = indentOf(firstLine)
|
|
93
|
+
if (firstIndent <= parentIndent) return { value: null, nextIndex: i }
|
|
94
|
+
|
|
95
|
+
const trimmedFirst = firstLine.trimStart()
|
|
96
|
+
|
|
97
|
+
// Detect whether this block is a list or a mapping
|
|
98
|
+
if (trimmedFirst.startsWith('- ') || trimmedFirst === '-') {
|
|
99
|
+
return parseList(lines, i, firstIndent)
|
|
100
|
+
}
|
|
101
|
+
return parseMapping(lines, i, firstIndent)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse a YAML list block.
|
|
106
|
+
*/
|
|
107
|
+
function parseList(lines: string[], startIdx: number, blockIndent: number): ParseResult {
|
|
108
|
+
const result: unknown[] = []
|
|
109
|
+
let i = startIdx
|
|
110
|
+
|
|
111
|
+
while (i < lines.length) {
|
|
112
|
+
// Skip blanks / comments
|
|
113
|
+
const raw = lines[i]
|
|
114
|
+
const stripped = raw.trimStart()
|
|
115
|
+
if (stripped === '' || stripped.startsWith('#')) {
|
|
116
|
+
i++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const indent = indentOf(raw)
|
|
121
|
+
if (indent < blockIndent) break
|
|
122
|
+
if (indent > blockIndent) break // Shouldn't happen at list level
|
|
123
|
+
|
|
124
|
+
const line = stripInlineComment(raw)
|
|
125
|
+
const trimmed = line.trimStart()
|
|
126
|
+
|
|
127
|
+
if (!trimmed.startsWith('- ') && trimmed !== '-') break
|
|
128
|
+
|
|
129
|
+
// Content after "- "
|
|
130
|
+
const after = trimmed === '-' ? '' : trimmed.slice(2).trim()
|
|
131
|
+
i++
|
|
132
|
+
|
|
133
|
+
if (after === '' || after.endsWith(':')) {
|
|
134
|
+
// List item is a nested mapping or empty
|
|
135
|
+
// Check if the next non-empty lines at deeper indent form an object
|
|
136
|
+
// If after ends with ':', it's the first key in a mapping
|
|
137
|
+
const obj: Record<string, unknown> = {}
|
|
138
|
+
if (after.endsWith(':')) {
|
|
139
|
+
const key = after.slice(0, -1).trim()
|
|
140
|
+
// Value on next lines or empty
|
|
141
|
+
const nested = parseValueAfterColon('', lines, i, blockIndent + 2)
|
|
142
|
+
obj[key] = nested.value
|
|
143
|
+
i = nested.nextIndex
|
|
144
|
+
}
|
|
145
|
+
// Collect remaining keys at the deeper indent
|
|
146
|
+
const sub = parseItemBody(lines, i, blockIndent + 2)
|
|
147
|
+
Object.assign(obj, (sub.value as Record<string, unknown>) || {})
|
|
148
|
+
i = sub.nextIndex
|
|
149
|
+
result.push(Object.keys(obj).length > 0 ? obj : null)
|
|
150
|
+
} else if (after.includes(': ') || after.endsWith(':')) {
|
|
151
|
+
// Inline mapping start: "- key: value"
|
|
152
|
+
const colonIdx = after.indexOf(':')
|
|
153
|
+
const key = after.slice(0, colonIdx).trim()
|
|
154
|
+
const rest = after.slice(colonIdx + 1).trim()
|
|
155
|
+
const obj: Record<string, unknown> = {}
|
|
156
|
+
|
|
157
|
+
if (rest === '' || rest === '|') {
|
|
158
|
+
const nested = parseValueAfterColon(rest, lines, i, blockIndent + 2)
|
|
159
|
+
obj[key] = nested.value
|
|
160
|
+
i = nested.nextIndex
|
|
161
|
+
} else {
|
|
162
|
+
obj[key] = castScalar(rest)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Collect remaining keys at deeper indent
|
|
166
|
+
const sub = parseItemBody(lines, i, blockIndent + 2)
|
|
167
|
+
Object.assign(obj, (sub.value as Record<string, unknown>) || {})
|
|
168
|
+
i = sub.nextIndex
|
|
169
|
+
result.push(obj)
|
|
170
|
+
} else if (after.startsWith('[') && after.endsWith(']')) {
|
|
171
|
+
// Inline flow sequence
|
|
172
|
+
result.push(parseFlowSequence(after))
|
|
173
|
+
} else {
|
|
174
|
+
// Simple scalar list item
|
|
175
|
+
result.push(castScalar(after))
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { value: result, nextIndex: i }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse the body (remaining keys) of a list item at a given indent.
|
|
184
|
+
*/
|
|
185
|
+
function parseItemBody(lines: string[], startIdx: number, minIndent: number): ParseResult {
|
|
186
|
+
let i = startIdx
|
|
187
|
+
const obj: Record<string, unknown> = {}
|
|
188
|
+
|
|
189
|
+
while (i < lines.length) {
|
|
190
|
+
const raw = lines[i]
|
|
191
|
+
const stripped = raw.trimStart()
|
|
192
|
+
if (stripped === '' || stripped.startsWith('#')) {
|
|
193
|
+
i++
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const indent = indentOf(raw)
|
|
198
|
+
if (indent < minIndent) break
|
|
199
|
+
|
|
200
|
+
const line = stripInlineComment(raw)
|
|
201
|
+
const trimmed = line.trimStart()
|
|
202
|
+
|
|
203
|
+
// If this is a list item at this indent, it belongs to a parent list
|
|
204
|
+
if (trimmed.startsWith('- ')) break
|
|
205
|
+
|
|
206
|
+
const colonIdx = trimmed.indexOf(':')
|
|
207
|
+
if (colonIdx === -1) {
|
|
208
|
+
i++
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
213
|
+
const rest = trimmed.slice(colonIdx + 1).trim()
|
|
214
|
+
i++
|
|
215
|
+
|
|
216
|
+
const nested = parseValueAfterColon(rest, lines, i, indent)
|
|
217
|
+
obj[key] = nested.value
|
|
218
|
+
i = nested.nextIndex
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { value: Object.keys(obj).length > 0 ? obj : null, nextIndex: i }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse a YAML mapping block.
|
|
226
|
+
*/
|
|
227
|
+
function parseMapping(lines: string[], startIdx: number, blockIndent: number): ParseResult {
|
|
228
|
+
const result: Record<string, unknown> = {}
|
|
229
|
+
let i = startIdx
|
|
230
|
+
|
|
231
|
+
while (i < lines.length) {
|
|
232
|
+
const raw = lines[i]
|
|
233
|
+
const stripped = raw.trimStart()
|
|
234
|
+
if (stripped === '' || stripped.startsWith('#')) {
|
|
235
|
+
i++
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const indent = indentOf(raw)
|
|
240
|
+
if (indent < blockIndent) break
|
|
241
|
+
if (indent > blockIndent) {
|
|
242
|
+
i++
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const line = stripInlineComment(raw)
|
|
247
|
+
const trimmed = line.trimStart()
|
|
248
|
+
|
|
249
|
+
const colonIdx = trimmed.indexOf(':')
|
|
250
|
+
if (colonIdx === -1) {
|
|
251
|
+
i++
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const key = trimmed.slice(0, colonIdx).trim()
|
|
256
|
+
const rest = trimmed.slice(colonIdx + 1).trim()
|
|
257
|
+
i++
|
|
258
|
+
|
|
259
|
+
const nested = parseValueAfterColon(rest, lines, i, blockIndent)
|
|
260
|
+
result[key] = nested.value
|
|
261
|
+
i = nested.nextIndex
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { value: result, nextIndex: i }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse the value after a colon — could be inline scalar, block scalar (|),
|
|
269
|
+
* nested mapping, or nested list.
|
|
270
|
+
*/
|
|
271
|
+
function parseValueAfterColon(
|
|
272
|
+
rest: string,
|
|
273
|
+
lines: string[],
|
|
274
|
+
nextIdx: number,
|
|
275
|
+
parentIndent: number
|
|
276
|
+
): ParseResult {
|
|
277
|
+
// Block scalar
|
|
278
|
+
if (rest === '|') {
|
|
279
|
+
return parseBlockScalar(lines, nextIdx, parentIndent)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Flow sequence [a, b, c]
|
|
283
|
+
if (rest.startsWith('[') && rest.endsWith(']')) {
|
|
284
|
+
return { value: parseFlowSequence(rest), nextIndex: nextIdx }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Inline scalar value present
|
|
288
|
+
if (rest !== '') {
|
|
289
|
+
return { value: castScalar(rest), nextIndex: nextIdx }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Empty after colon — check for nested block
|
|
293
|
+
const nested = parseBlock(lines, nextIdx, parentIndent)
|
|
294
|
+
if (nested.value !== null) {
|
|
295
|
+
return nested
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { value: null, nextIndex: nextIdx }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Parse a block scalar (| indicator).
|
|
303
|
+
* Collects all lines with indent greater than the parent.
|
|
304
|
+
*/
|
|
305
|
+
function parseBlockScalar(lines: string[], startIdx: number, parentIndent: number): ParseResult {
|
|
306
|
+
let i = startIdx
|
|
307
|
+
const collected: string[] = []
|
|
308
|
+
let blockIndent = -1
|
|
309
|
+
|
|
310
|
+
while (i < lines.length) {
|
|
311
|
+
const raw = lines[i]
|
|
312
|
+
|
|
313
|
+
// Blank line inside block scalar — preserve it
|
|
314
|
+
if (raw.trim() === '') {
|
|
315
|
+
collected.push('')
|
|
316
|
+
i++
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const indent = indentOf(raw)
|
|
321
|
+
if (blockIndent === -1) {
|
|
322
|
+
// First content line determines the block indent
|
|
323
|
+
if (indent <= parentIndent) break
|
|
324
|
+
blockIndent = indent
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (indent < blockIndent) break
|
|
328
|
+
|
|
329
|
+
collected.push(raw.slice(blockIndent))
|
|
330
|
+
i++
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Remove trailing blank lines
|
|
334
|
+
while (collected.length > 0 && collected[collected.length - 1] === '') {
|
|
335
|
+
collected.pop()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { value: collected.join('\n') + '\n', nextIndex: i }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parse a flow sequence: [item1, item2, item3]
|
|
343
|
+
*/
|
|
344
|
+
function parseFlowSequence(text: string): Array<string | number | boolean | null> {
|
|
345
|
+
const inner = text.slice(1, -1).trim()
|
|
346
|
+
if (inner === '') return []
|
|
347
|
+
return inner.split(',').map((s) => castScalar(s.trim()))
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ── Schema validation ──────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
const VALID_ON_FAILURE = ['continue', 'stop']
|
|
353
|
+
const TIMEOUT_RE = /^(\d+)(s|m|h)$/
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Parse a timeout string into milliseconds.
|
|
357
|
+
*/
|
|
358
|
+
export function parseTimeout(timeout: string): number {
|
|
359
|
+
const m = String(timeout).match(TIMEOUT_RE)
|
|
360
|
+
if (!m) return NaN
|
|
361
|
+
const num = parseInt(m[1], 10)
|
|
362
|
+
const unit = m[2]
|
|
363
|
+
if (unit === 's') return num * 1000
|
|
364
|
+
if (unit === 'm') return num * 60 * 1000
|
|
365
|
+
if (unit === 'h') return num * 60 * 60 * 1000
|
|
366
|
+
return NaN
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
interface RawSpec {
|
|
370
|
+
name?: unknown
|
|
371
|
+
concurrency?: unknown
|
|
372
|
+
on_failure?: unknown
|
|
373
|
+
adapter?: unknown
|
|
374
|
+
tasks?: unknown
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface RawTask {
|
|
378
|
+
id?: unknown
|
|
379
|
+
prompt?: unknown
|
|
380
|
+
agent?: unknown
|
|
381
|
+
timeout?: unknown
|
|
382
|
+
depends_on?: unknown
|
|
383
|
+
files?: unknown
|
|
384
|
+
description?: unknown
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Validate a parsed spec object.
|
|
389
|
+
*/
|
|
390
|
+
export function validateSpec(spec: unknown): ValidationResult {
|
|
391
|
+
const errors: string[] = []
|
|
392
|
+
|
|
393
|
+
if (!spec || typeof spec !== 'object') {
|
|
394
|
+
return { valid: false, errors: ['Spec must be a YAML object'] }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const s = spec as RawSpec
|
|
398
|
+
|
|
399
|
+
// Name
|
|
400
|
+
if (!s.name || typeof s.name !== 'string') {
|
|
401
|
+
errors.push('`name` is required and must be a string')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Concurrency
|
|
405
|
+
if (s.concurrency !== undefined) {
|
|
406
|
+
const c = Number(s.concurrency)
|
|
407
|
+
if (!Number.isInteger(c) || c < 1) {
|
|
408
|
+
errors.push('`concurrency` must be an integer >= 1')
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// on_failure
|
|
413
|
+
if (s.on_failure !== undefined) {
|
|
414
|
+
if (!VALID_ON_FAILURE.includes(s.on_failure as string)) {
|
|
415
|
+
errors.push(
|
|
416
|
+
`\`on_failure\` must be one of: ${VALID_ON_FAILURE.join(', ')}`
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// adapter
|
|
422
|
+
if (s.adapter !== undefined && typeof s.adapter !== 'string') {
|
|
423
|
+
errors.push('`adapter` must be a string')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Tasks
|
|
427
|
+
if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
|
|
428
|
+
errors.push('`tasks` is required and must be a non-empty array')
|
|
429
|
+
return { valid: false, errors }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const taskIds = new Set<string>()
|
|
433
|
+
const tasks = s.tasks as RawTask[]
|
|
434
|
+
|
|
435
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
436
|
+
const task = tasks[i]
|
|
437
|
+
const prefix = `tasks[${i}]`
|
|
438
|
+
|
|
439
|
+
if (!task || typeof task !== 'object') {
|
|
440
|
+
errors.push(`${prefix}: must be an object`)
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// id
|
|
445
|
+
if (!task.id || typeof task.id !== 'string') {
|
|
446
|
+
errors.push(`${prefix}: \`id\` is required and must be a string`)
|
|
447
|
+
} else if (taskIds.has(task.id)) {
|
|
448
|
+
errors.push(`${prefix}: duplicate task id "${task.id}"`)
|
|
449
|
+
} else {
|
|
450
|
+
taskIds.add(task.id)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// prompt
|
|
454
|
+
if (!task.prompt || typeof task.prompt !== 'string') {
|
|
455
|
+
errors.push(`${prefix}: \`prompt\` is required and must be a string`)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// timeout
|
|
459
|
+
if (task.timeout !== undefined) {
|
|
460
|
+
if (isNaN(parseTimeout(task.timeout as string))) {
|
|
461
|
+
errors.push(
|
|
462
|
+
`${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// depends_on
|
|
468
|
+
if (task.depends_on !== undefined) {
|
|
469
|
+
if (!Array.isArray(task.depends_on)) {
|
|
470
|
+
errors.push(`${prefix}: \`depends_on\` must be an array`)
|
|
471
|
+
} else {
|
|
472
|
+
for (const dep of task.depends_on as string[]) {
|
|
473
|
+
if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
|
|
474
|
+
errors.push(
|
|
475
|
+
`${prefix}: \`depends_on\` references unknown task "${dep}"`
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// files
|
|
483
|
+
if (task.files !== undefined && !Array.isArray(task.files)) {
|
|
484
|
+
errors.push(`${prefix}: \`files\` must be an array`)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// DAG cycle detection
|
|
489
|
+
if (errors.length === 0) {
|
|
490
|
+
const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
|
|
491
|
+
if (cycleErr) errors.push(cycleErr)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return { valid: errors.length === 0, errors }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Detect cycles in the task dependency graph using DFS.
|
|
499
|
+
*/
|
|
500
|
+
function detectCycles(tasks: Array<{ id: string; depends_on?: string[] }>): string | null {
|
|
501
|
+
const adj = new Map<string, string[]>()
|
|
502
|
+
for (const t of tasks) {
|
|
503
|
+
adj.set(t.id, t.depends_on || [])
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const WHITE = 0, GRAY = 1, BLACK = 2
|
|
507
|
+
const color = new Map<string, number>()
|
|
508
|
+
for (const id of adj.keys()) color.set(id, WHITE)
|
|
509
|
+
|
|
510
|
+
function dfs(node: string, path: string[]): string[] | null {
|
|
511
|
+
color.set(node, GRAY)
|
|
512
|
+
path.push(node)
|
|
513
|
+
|
|
514
|
+
for (const dep of adj.get(node) || []) {
|
|
515
|
+
if (color.get(dep) === GRAY) {
|
|
516
|
+
const cycleStart = path.indexOf(dep)
|
|
517
|
+
return [...path.slice(cycleStart), dep]
|
|
518
|
+
}
|
|
519
|
+
if (color.get(dep) === WHITE) {
|
|
520
|
+
const result = dfs(dep, path)
|
|
521
|
+
if (result) return result
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
color.set(node, BLACK)
|
|
526
|
+
path.pop()
|
|
527
|
+
return null
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
for (const id of adj.keys()) {
|
|
531
|
+
if (color.get(id) === WHITE) {
|
|
532
|
+
const cycle = dfs(id, [])
|
|
533
|
+
if (cycle) {
|
|
534
|
+
return `Circular dependency detected: ${cycle.join(' → ')}`
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return null
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Apply default values to a parsed spec.
|
|
543
|
+
*/
|
|
544
|
+
export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
|
|
545
|
+
const s = spec as Record<string, unknown>
|
|
546
|
+
s.concurrency = s.concurrency !== undefined ? Number(s.concurrency) : 1
|
|
547
|
+
s.on_failure = (s.on_failure as string) || 'continue'
|
|
548
|
+
s.adapter = (s.adapter as string) || 'claude-code'
|
|
549
|
+
|
|
550
|
+
const tasks = s.tasks as Array<Record<string, unknown>>
|
|
551
|
+
for (const task of tasks) {
|
|
552
|
+
task.agent = (task.agent as string) || 'developer'
|
|
553
|
+
task.timeout = (task.timeout as string) || '30m'
|
|
554
|
+
task.depends_on = (task.depends_on as string[]) || []
|
|
555
|
+
task.files = (task.files as string[]) || []
|
|
556
|
+
task.description = (task.description as string) || (task.id as string)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return s as unknown as TaskSpec
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Read, parse, validate, and return a typed task spec from a YAML file.
|
|
564
|
+
* @throws If file cannot be read, parsed, or spec is invalid
|
|
565
|
+
*/
|
|
566
|
+
export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
|
|
567
|
+
let text: string
|
|
568
|
+
try {
|
|
569
|
+
text = await readFile(filePath, 'utf8')
|
|
570
|
+
} catch (err: unknown) {
|
|
571
|
+
const e = err as Error & { code?: string }
|
|
572
|
+
if (e.code === 'ENOENT') {
|
|
573
|
+
throw new Error(`Task spec file not found: ${filePath}`)
|
|
574
|
+
}
|
|
575
|
+
throw new Error(`Cannot read task spec file: ${e.message}`)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!text.trim()) {
|
|
579
|
+
throw new Error('Task spec file is empty')
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let spec: Record<string, unknown>
|
|
583
|
+
try {
|
|
584
|
+
spec = parseYaml(text)
|
|
585
|
+
} catch (err: unknown) {
|
|
586
|
+
throw new Error(`YAML parse error: ${(err as Error).message}`)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const { valid, errors } = validateSpec(spec)
|
|
590
|
+
if (!valid) {
|
|
591
|
+
throw new Error(`Invalid task spec:\n • ${errors.join('\n • ')}`)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return applyDefaults(spec)
|
|
595
|
+
}
|