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.
Files changed (224) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/bin/cli.mjs +69 -0
  4. package/dist/cli/adapters/claude-code.d.ts +22 -0
  5. package/dist/cli/adapters/claude-code.d.ts.map +1 -0
  6. package/dist/cli/adapters/claude-code.js +237 -0
  7. package/dist/cli/adapters/claude-code.js.map +1 -0
  8. package/dist/cli/adapters/cursor.d.ts +20 -0
  9. package/dist/cli/adapters/cursor.d.ts.map +1 -0
  10. package/dist/cli/adapters/cursor.js +231 -0
  11. package/dist/cli/adapters/cursor.js.map +1 -0
  12. package/dist/cli/adapters/vscode.d.ts +20 -0
  13. package/dist/cli/adapters/vscode.d.ts.map +1 -0
  14. package/dist/cli/adapters/vscode.js +132 -0
  15. package/dist/cli/adapters/vscode.js.map +1 -0
  16. package/dist/cli/copy.d.ts +14 -0
  17. package/dist/cli/copy.d.ts.map +1 -0
  18. package/dist/cli/copy.js +62 -0
  19. package/dist/cli/copy.js.map +1 -0
  20. package/dist/cli/dashboard.d.ts +3 -0
  21. package/dist/cli/dashboard.d.ts.map +1 -0
  22. package/dist/cli/dashboard.js +183 -0
  23. package/dist/cli/dashboard.js.map +1 -0
  24. package/dist/cli/diff.d.ts +3 -0
  25. package/dist/cli/diff.d.ts.map +1 -0
  26. package/dist/cli/diff.js +27 -0
  27. package/dist/cli/diff.js.map +1 -0
  28. package/dist/cli/eject.d.ts +3 -0
  29. package/dist/cli/eject.d.ts.map +1 -0
  30. package/dist/cli/eject.js +27 -0
  31. package/dist/cli/eject.js.map +1 -0
  32. package/dist/cli/init.d.ts +3 -0
  33. package/dist/cli/init.d.ts.map +1 -0
  34. package/dist/cli/init.js +92 -0
  35. package/dist/cli/init.js.map +1 -0
  36. package/dist/cli/manifest.d.ts +14 -0
  37. package/dist/cli/manifest.d.ts.map +1 -0
  38. package/dist/cli/manifest.js +34 -0
  39. package/dist/cli/manifest.js.map +1 -0
  40. package/dist/cli/mcp.d.ts +14 -0
  41. package/dist/cli/mcp.d.ts.map +1 -0
  42. package/dist/cli/mcp.js +35 -0
  43. package/dist/cli/mcp.js.map +1 -0
  44. package/dist/cli/prompt.d.ts +12 -0
  45. package/dist/cli/prompt.d.ts.map +1 -0
  46. package/dist/cli/prompt.js +104 -0
  47. package/dist/cli/prompt.js.map +1 -0
  48. package/dist/cli/run/adapters/claude-code.d.ts +16 -0
  49. package/dist/cli/run/adapters/claude-code.d.ts.map +1 -0
  50. package/dist/cli/run/adapters/claude-code.js +82 -0
  51. package/dist/cli/run/adapters/claude-code.js.map +1 -0
  52. package/dist/cli/run/adapters/copilot.d.ts +16 -0
  53. package/dist/cli/run/adapters/copilot.d.ts.map +1 -0
  54. package/dist/cli/run/adapters/copilot.js +84 -0
  55. package/dist/cli/run/adapters/copilot.js.map +1 -0
  56. package/dist/cli/run/adapters/cursor.d.ts +16 -0
  57. package/dist/cli/run/adapters/cursor.d.ts.map +1 -0
  58. package/dist/cli/run/adapters/cursor.js +81 -0
  59. package/dist/cli/run/adapters/cursor.js.map +1 -0
  60. package/dist/cli/run/adapters/index.d.ts +14 -0
  61. package/dist/cli/run/adapters/index.d.ts.map +1 -0
  62. package/dist/cli/run/adapters/index.js +35 -0
  63. package/dist/cli/run/adapters/index.js.map +1 -0
  64. package/dist/cli/run/executor.d.ts +15 -0
  65. package/dist/cli/run/executor.d.ts.map +1 -0
  66. package/dist/cli/run/executor.js +249 -0
  67. package/dist/cli/run/executor.js.map +1 -0
  68. package/dist/cli/run/reporter.d.ts +10 -0
  69. package/dist/cli/run/reporter.d.ts.map +1 -0
  70. package/dist/cli/run/reporter.js +112 -0
  71. package/dist/cli/run/reporter.js.map +1 -0
  72. package/dist/cli/run/schema.d.ts +28 -0
  73. package/dist/cli/run/schema.d.ts.map +1 -0
  74. package/dist/cli/run/schema.js +511 -0
  75. package/dist/cli/run/schema.js.map +1 -0
  76. package/dist/cli/run.d.ts +6 -0
  77. package/dist/cli/run.d.ts.map +1 -0
  78. package/dist/cli/run.js +123 -0
  79. package/dist/cli/run.js.map +1 -0
  80. package/dist/cli/stack-config.d.ts +12 -0
  81. package/dist/cli/stack-config.d.ts.map +1 -0
  82. package/dist/cli/stack-config.js +146 -0
  83. package/dist/cli/stack-config.js.map +1 -0
  84. package/dist/cli/types.d.ts +169 -0
  85. package/dist/cli/types.d.ts.map +1 -0
  86. package/dist/cli/types.js +2 -0
  87. package/dist/cli/types.js.map +1 -0
  88. package/dist/cli/update.d.ts +3 -0
  89. package/dist/cli/update.d.ts.map +1 -0
  90. package/dist/cli/update.js +50 -0
  91. package/dist/cli/update.js.map +1 -0
  92. package/package.json +48 -0
  93. package/src/cli/adapters/claude-code.ts +287 -0
  94. package/src/cli/adapters/cursor.ts +377 -0
  95. package/src/cli/adapters/vscode.ts +168 -0
  96. package/src/cli/copy.ts +79 -0
  97. package/src/cli/dashboard.ts +225 -0
  98. package/src/cli/diff.ts +44 -0
  99. package/src/cli/eject.ts +39 -0
  100. package/src/cli/init.ts +120 -0
  101. package/src/cli/manifest.ts +45 -0
  102. package/src/cli/mcp.ts +49 -0
  103. package/src/cli/prompt.ts +115 -0
  104. package/src/cli/run/adapters/claude-code.ts +95 -0
  105. package/src/cli/run/adapters/copilot.ts +97 -0
  106. package/src/cli/run/adapters/cursor.ts +94 -0
  107. package/src/cli/run/adapters/index.ts +40 -0
  108. package/src/cli/run/executor.ts +292 -0
  109. package/src/cli/run/reporter.ts +129 -0
  110. package/src/cli/run/schema.ts +595 -0
  111. package/src/cli/run.ts +137 -0
  112. package/src/cli/stack-config.ts +180 -0
  113. package/src/cli/types.ts +207 -0
  114. package/src/cli/update.ts +75 -0
  115. package/src/dashboard/astro.config.mjs +6 -0
  116. package/src/dashboard/package-lock.json +5455 -0
  117. package/src/dashboard/package.json +14 -0
  118. package/src/dashboard/public/data/delegations.ndjson +35 -0
  119. package/src/dashboard/public/data/panels.ndjson +13 -0
  120. package/src/dashboard/public/data/sessions.ndjson +50 -0
  121. package/src/dashboard/public/icon-192.png +0 -0
  122. package/src/dashboard/scripts/generate-seed-data.ts +355 -0
  123. package/src/dashboard/src/layouts/Layout.astro +25 -0
  124. package/src/dashboard/src/pages/index.astro +1070 -0
  125. package/src/dashboard/src/styles/dashboard.css +1078 -0
  126. package/src/dashboard/tsconfig.json +6 -0
  127. package/src/orchestrator/agent-workflows/README.md +22 -0
  128. package/src/orchestrator/agent-workflows/bug-fix.md +128 -0
  129. package/src/orchestrator/agent-workflows/data-pipeline.md +145 -0
  130. package/src/orchestrator/agent-workflows/database-migration.md +159 -0
  131. package/src/orchestrator/agent-workflows/feature-implementation.md +223 -0
  132. package/src/orchestrator/agent-workflows/performance-optimization.md +125 -0
  133. package/src/orchestrator/agent-workflows/refactoring.md +142 -0
  134. package/src/orchestrator/agent-workflows/schema-changes.md +164 -0
  135. package/src/orchestrator/agent-workflows/security-audit.md +148 -0
  136. package/src/orchestrator/agent-workflows/shared-delivery-phase.md +33 -0
  137. package/src/orchestrator/agents/api-designer.agent.md +68 -0
  138. package/src/orchestrator/agents/architect.agent.md +129 -0
  139. package/src/orchestrator/agents/content-engineer.agent.md +57 -0
  140. package/src/orchestrator/agents/copywriter.agent.md +95 -0
  141. package/src/orchestrator/agents/data-expert.agent.md +63 -0
  142. package/src/orchestrator/agents/database-engineer.agent.md +62 -0
  143. package/src/orchestrator/agents/developer.agent.md +66 -0
  144. package/src/orchestrator/agents/devops-expert.agent.md +57 -0
  145. package/src/orchestrator/agents/documentation-writer.agent.md +60 -0
  146. package/src/orchestrator/agents/performance-expert.agent.md +58 -0
  147. package/src/orchestrator/agents/release-manager.agent.md +72 -0
  148. package/src/orchestrator/agents/researcher.agent.md +145 -0
  149. package/src/orchestrator/agents/reviewer.agent.md +62 -0
  150. package/src/orchestrator/agents/security-expert.agent.md +64 -0
  151. package/src/orchestrator/agents/seo-specialist.agent.md +67 -0
  152. package/src/orchestrator/agents/team-lead.agent.md +644 -0
  153. package/src/orchestrator/agents/testing-expert.agent.md +85 -0
  154. package/src/orchestrator/agents/ui-ux-expert.agent.md +63 -0
  155. package/src/orchestrator/copilot-instructions.md +3 -0
  156. package/src/orchestrator/customizations/AGENT-EXPERTISE.md +325 -0
  157. package/src/orchestrator/customizations/AGENT-FAILURES.md +69 -0
  158. package/src/orchestrator/customizations/AGENT-PERFORMANCE.md +58 -0
  159. package/src/orchestrator/customizations/DISPUTES.md +162 -0
  160. package/src/orchestrator/customizations/KNOWLEDGE-GRAPH.md +10 -0
  161. package/src/orchestrator/customizations/LESSONS-LEARNED.md +70 -0
  162. package/src/orchestrator/customizations/README.md +59 -0
  163. package/src/orchestrator/customizations/agents/agent-registry.md +46 -0
  164. package/src/orchestrator/customizations/agents/skill-matrix.md +142 -0
  165. package/src/orchestrator/customizations/logs/README.md +181 -0
  166. package/src/orchestrator/customizations/logs/delegations.ndjson +1 -0
  167. package/src/orchestrator/customizations/logs/panels.ndjson +1 -0
  168. package/src/orchestrator/customizations/logs/sessions.ndjson +1 -0
  169. package/src/orchestrator/customizations/project/docs-structure.md +23 -0
  170. package/src/orchestrator/customizations/project/tracker-config.md +45 -0
  171. package/src/orchestrator/customizations/project.instructions.md +64 -0
  172. package/src/orchestrator/customizations/stack/api-config.md +37 -0
  173. package/src/orchestrator/customizations/stack/cms-config.md +26 -0
  174. package/src/orchestrator/customizations/stack/data-pipeline-config.md +41 -0
  175. package/src/orchestrator/customizations/stack/database-config.md +44 -0
  176. package/src/orchestrator/customizations/stack/deployment-config.md +45 -0
  177. package/src/orchestrator/customizations/stack/testing-config.md +56 -0
  178. package/src/orchestrator/instructions/ai-optimization.instructions.md +143 -0
  179. package/src/orchestrator/instructions/general.instructions.md +194 -0
  180. package/src/orchestrator/mcp.json +55 -0
  181. package/src/orchestrator/prompts/bootstrap-customizations.prompt.md +235 -0
  182. package/src/orchestrator/prompts/brainstorm.prompt.md +115 -0
  183. package/src/orchestrator/prompts/bug-fix.prompt.md +141 -0
  184. package/src/orchestrator/prompts/create-skill.prompt.md +103 -0
  185. package/src/orchestrator/prompts/generate-task-spec.prompt.md +154 -0
  186. package/src/orchestrator/prompts/implement-feature.prompt.md +124 -0
  187. package/src/orchestrator/prompts/metrics-report.prompt.md +142 -0
  188. package/src/orchestrator/prompts/quick-refinement.prompt.md +137 -0
  189. package/src/orchestrator/prompts/resolve-pr-comments.prompt.md +100 -0
  190. package/src/orchestrator/skills/accessibility-standards/SKILL.md +164 -0
  191. package/src/orchestrator/skills/agent-hooks/SKILL.md +147 -0
  192. package/src/orchestrator/skills/agent-memory/SKILL.md +144 -0
  193. package/src/orchestrator/skills/api-patterns/SKILL.md +106 -0
  194. package/src/orchestrator/skills/browser-testing/SKILL.md +203 -0
  195. package/src/orchestrator/skills/code-commenting/SKILL.md +133 -0
  196. package/src/orchestrator/skills/contentful-cms/SKILL.md +43 -0
  197. package/src/orchestrator/skills/context-map/SKILL.md +135 -0
  198. package/src/orchestrator/skills/convex-database/SKILL.md +80 -0
  199. package/src/orchestrator/skills/data-engineering/SKILL.md +99 -0
  200. package/src/orchestrator/skills/deployment-infrastructure/SKILL.md +49 -0
  201. package/src/orchestrator/skills/documentation-standards/SKILL.md +85 -0
  202. package/src/orchestrator/skills/fast-review/SKILL.md +327 -0
  203. package/src/orchestrator/skills/frontend-design/SKILL.md +42 -0
  204. package/src/orchestrator/skills/jira-management/SKILL.md +168 -0
  205. package/src/orchestrator/skills/memory-merger/SKILL.md +123 -0
  206. package/src/orchestrator/skills/nextjs-patterns/SKILL.md +75 -0
  207. package/src/orchestrator/skills/nx-workspace/SKILL.md +192 -0
  208. package/src/orchestrator/skills/panel-majority-vote/SKILL.md +184 -0
  209. package/src/orchestrator/skills/panel-majority-vote/panel-report.template.md +38 -0
  210. package/src/orchestrator/skills/performance-optimization/SKILL.md +101 -0
  211. package/src/orchestrator/skills/react-development/SKILL.md +117 -0
  212. package/src/orchestrator/skills/sanity-cms/SKILL.md +18 -0
  213. package/src/orchestrator/skills/security-hardening/SKILL.md +118 -0
  214. package/src/orchestrator/skills/self-improvement/SKILL.md +137 -0
  215. package/src/orchestrator/skills/seo-patterns/SKILL.md +40 -0
  216. package/src/orchestrator/skills/session-checkpoints/SKILL.md +205 -0
  217. package/src/orchestrator/skills/slack-notifications/SKILL.md +211 -0
  218. package/src/orchestrator/skills/strapi-cms/SKILL.md +43 -0
  219. package/src/orchestrator/skills/supabase-database/SKILL.md +24 -0
  220. package/src/orchestrator/skills/task-management/SKILL.md +143 -0
  221. package/src/orchestrator/skills/team-lead-reference/SKILL.md +317 -0
  222. package/src/orchestrator/skills/teams-notifications/SKILL.md +249 -0
  223. package/src/orchestrator/skills/testing-workflow/SKILL.md +134 -0
  224. 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
+ }