@forgeailab/spark 0.2.0 → 0.3.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 (138) hide show
  1. package/package.json +8 -6
  2. package/src/commands/add.ts +3 -1
  3. package/src/commands/preset.ts +1 -0
  4. package/src/internal/board.ts +266 -304
  5. package/src/io/board.ts +29 -65
  6. package/src/io/env.ts +8 -3
  7. package/src/io/files.ts +22 -7
  8. package/packs/README.md +0 -130
  9. package/packs/ai-anthropic/files/app/api/ai/route.ts +0 -57
  10. package/packs/ai-anthropic/files/lib/anthropic.ts +0 -58
  11. package/packs/ai-anthropic/pack.toml +0 -31
  12. package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +0 -87
  13. package/packs/ai-anthropic/tasks.yaml +0 -9
  14. package/packs/ai-openai/files/app/api/ai-openai/route.ts +0 -55
  15. package/packs/ai-openai/files/lib/openai.ts +0 -21
  16. package/packs/ai-openai/pack.toml +0 -30
  17. package/packs/ai-openai/tasks.yaml +0 -9
  18. package/packs/analytics-posthog/files/components/PostHogProvider.tsx +0 -19
  19. package/packs/analytics-posthog/files/lib/posthog/client.ts +0 -20
  20. package/packs/analytics-posthog/files/lib/posthog/server.ts +0 -24
  21. package/packs/analytics-posthog/pack.toml +0 -35
  22. package/packs/analytics-posthog/tasks.yaml +0 -15
  23. package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +0 -58
  24. package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +0 -4
  25. package/packs/auth-better-auth/files/lib/auth.ts +0 -60
  26. package/packs/auth-better-auth/pack.toml +0 -28
  27. package/packs/auth-better-auth/tasks.yaml +0 -10
  28. package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +0 -4
  29. package/packs/auth-better-auth-pg/files/lib/auth.ts +0 -125
  30. package/packs/auth-better-auth-pg/pack.toml +0 -28
  31. package/packs/auth-better-auth-pg/tasks.yaml +0 -17
  32. package/packs/auth-supabase/files/app/(auth)/login/page.tsx +0 -64
  33. package/packs/auth-supabase/files/app/auth/callback/route.ts +0 -15
  34. package/packs/auth-supabase/files/middleware.ts +0 -41
  35. package/packs/auth-supabase/pack.toml +0 -34
  36. package/packs/auth-supabase/tasks.yaml +0 -10
  37. package/packs/db-postgres/files/compose/postgres.yml +0 -28
  38. package/packs/db-postgres/files/docker-compose.include.yml +0 -1
  39. package/packs/db-postgres/files/docker-compose.yml +0 -6
  40. package/packs/db-postgres/files/drizzle.config.ts +0 -10
  41. package/packs/db-postgres/files/lib/db/index.ts +0 -10
  42. package/packs/db-postgres/files/lib/db/schema.ts +0 -11
  43. package/packs/db-postgres/pack.toml +0 -53
  44. package/packs/db-postgres/tasks.yaml +0 -11
  45. package/packs/db-sqlite/files/drizzle.config.ts +0 -10
  46. package/packs/db-sqlite/files/lib/db.ts +0 -8
  47. package/packs/db-sqlite/files/lib/schema.ts +0 -13
  48. package/packs/db-sqlite/pack.toml +0 -34
  49. package/packs/db-sqlite/tasks.yaml +0 -6
  50. package/packs/db-supabase/files/lib/supabase/client.ts +0 -8
  51. package/packs/db-supabase/files/lib/supabase/server.ts +0 -27
  52. package/packs/db-supabase/pack.toml +0 -32
  53. package/packs/db-supabase/skills/supabase-patterns/SKILL.md +0 -82
  54. package/packs/db-supabase/tasks.yaml +0 -6
  55. package/packs/deploy-vercel/files/docs/deploy.md +0 -21
  56. package/packs/deploy-vercel/files/vercel.json +0 -4
  57. package/packs/deploy-vercel/pack.toml +0 -30
  58. package/packs/deploy-vercel/tasks.yaml +0 -14
  59. package/packs/docker-compose-dev/files/.env.docker.example +0 -2
  60. package/packs/docker-compose-dev/files/compose/redis.yml +0 -17
  61. package/packs/docker-compose-dev/files/docker-compose.include.yml +0 -1
  62. package/packs/docker-compose-dev/files/docker-compose.yml +0 -6
  63. package/packs/docker-compose-dev/pack.toml +0 -38
  64. package/packs/docker-compose-dev/tasks.yaml +0 -9
  65. package/packs/email-resend/files/app/api/email/test/route.ts +0 -38
  66. package/packs/email-resend/files/emails/welcome.tsx +0 -66
  67. package/packs/email-resend/files/lib/email.ts +0 -40
  68. package/packs/email-resend/pack.toml +0 -34
  69. package/packs/email-resend/tasks.yaml +0 -9
  70. package/packs/example/pack.toml +0 -69
  71. package/packs/payments-stripe/files/app/api/billing-portal/route.ts +0 -24
  72. package/packs/payments-stripe/files/app/api/checkout/route.ts +0 -58
  73. package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +0 -84
  74. package/packs/payments-stripe/files/lib/stripe.ts +0 -158
  75. package/packs/payments-stripe/pack.toml +0 -45
  76. package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +0 -93
  77. package/packs/payments-stripe/tasks.yaml +0 -16
  78. package/packs/sync-zero/files/components/ZeroProvider.tsx +0 -13
  79. package/packs/sync-zero/files/compose/zero-cache.yml +0 -26
  80. package/packs/sync-zero/files/docker-compose.include.yml +0 -1
  81. package/packs/sync-zero/files/docker-compose.yml +0 -6
  82. package/packs/sync-zero/files/lib/zero/client.ts +0 -18
  83. package/packs/sync-zero/files/lib/zero/schema.ts +0 -30
  84. package/packs/sync-zero/files/zero.config.ts +0 -26
  85. package/packs/sync-zero/pack.toml +0 -57
  86. package/packs/sync-zero/skills/zero-patterns/SKILL.md +0 -69
  87. package/packs/sync-zero/tasks.yaml +0 -16
  88. package/packs/testing-playwright/files/e2e/example.spec.ts +0 -7
  89. package/packs/testing-playwright/files/playwright.config.ts +0 -33
  90. package/packs/testing-playwright/pack.toml +0 -25
  91. package/packs/testing-playwright/tasks.yaml +0 -9
  92. package/packs/ui-shadcn/files/app/globals.css +0 -56
  93. package/packs/ui-shadcn/files/components/ui/button.tsx +0 -47
  94. package/packs/ui-shadcn/files/components/ui/card.tsx +0 -33
  95. package/packs/ui-shadcn/files/lib/utils.ts +0 -6
  96. package/packs/ui-shadcn/files/postcss.config.mjs +0 -7
  97. package/packs/ui-shadcn/files/tailwind.config.ts +0 -57
  98. package/packs/ui-shadcn/pack.toml +0 -44
  99. package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +0 -85
  100. package/packs/ui-shadcn/tasks.yaml +0 -6
  101. package/presets/docs-site.toml +0 -4
  102. package/presets/internal-tool.toml +0 -4
  103. package/presets/lean-saas.toml +0 -4
  104. package/presets/local-ai-mvp.toml +0 -4
  105. package/presets/saas-classic.toml +0 -4
  106. package/templates/README.md +0 -43
  107. package/templates/astro/README.md +0 -3
  108. package/templates/astro/template.toml +0 -4
  109. package/templates/astro-starlight/README.md +0 -3
  110. package/templates/astro-starlight/template.toml +0 -4
  111. package/templates/nextjs/.ai/architecture.md +0 -13
  112. package/templates/nextjs/.ai/board.md +0 -7
  113. package/templates/nextjs/.ai/product-spec.md +0 -11
  114. package/templates/nextjs/.claude/skills/.gitkeep +0 -0
  115. package/templates/nextjs/.codex/skills/.gitkeep +0 -0
  116. package/templates/nextjs/AGENTS.md +0 -95
  117. package/templates/nextjs/CLAUDE.md +0 -3
  118. package/templates/nextjs/README.md +0 -20
  119. package/templates/nextjs/app/(app)/home/page.tsx +0 -43
  120. package/templates/nextjs/app/(app)/home/posts-panel.tsx +0 -83
  121. package/templates/nextjs/app/(app)/layout.tsx +0 -12
  122. package/templates/nextjs/app/(auth)/login/page.tsx +0 -97
  123. package/templates/nextjs/app/globals.css +0 -23
  124. package/templates/nextjs/app/layout.tsx +0 -20
  125. package/templates/nextjs/app/page.tsx +0 -39
  126. package/templates/nextjs/lib/auth-placeholder.ts +0 -21
  127. package/templates/nextjs/lib/posts-placeholder.ts +0 -30
  128. package/templates/nextjs/next.config.ts +0 -5
  129. package/templates/nextjs/package.json +0 -26
  130. package/templates/nextjs/postcss.config.mjs +0 -7
  131. package/templates/nextjs/spark.config.json +0 -4
  132. package/templates/nextjs/template.toml +0 -4
  133. package/templates/nextjs/tsconfig.json +0 -27
  134. package/templates/nextjs/types/post.ts +0 -13
  135. package/templates/one/README.md +0 -5
  136. package/templates/one/template.toml +0 -4
  137. package/templates/vite-react/README.md +0 -3
  138. package/templates/vite-react/template.toml +0 -4
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join } from 'node:path';
3
3
 
4
4
  export enum BoardTaskStatus {
@@ -6,6 +6,7 @@ export enum BoardTaskStatus {
6
6
  InProgress = 'in-progress',
7
7
  Done = 'done',
8
8
  Blocked = 'blocked',
9
+ Cut = 'cut',
9
10
  }
10
11
 
11
12
  export type BoardTask = {
@@ -41,221 +42,135 @@ export type SeedTask = {
41
42
  description?: string;
42
43
  };
43
44
 
44
- type DraftTask = {
45
- kind: 'checkbox' | 'yaml';
46
- id: string;
47
- title: string;
48
- status: BoardTaskStatus;
49
- startIndex: number;
50
- };
51
-
52
- type DraftEpic = {
53
- name: string;
54
- startIndex: number;
55
- firstTaskIndex?: number;
56
- tasks: BoardTask[];
57
- };
45
+ export type AggregatedTask = BoardTask & { changeId: string };
58
46
 
59
47
  const statusMarkers: Record<BoardTaskStatus, string> = {
60
48
  [BoardTaskStatus.Todo]: ' ',
61
49
  [BoardTaskStatus.InProgress]: '~',
62
50
  [BoardTaskStatus.Done]: 'x',
63
51
  [BoardTaskStatus.Blocked]: '!',
52
+ [BoardTaskStatus.Cut]: '-',
64
53
  };
65
54
 
66
- function boardFilePath(projectRoot: string): string {
67
- return join(projectRoot, '.ai', 'board.md');
68
- }
69
-
70
55
  function cleanLine(line: string): string {
71
56
  return line.endsWith('\r') ? line.slice(0, -1) : line;
72
57
  }
73
58
 
74
59
  function boardLines(raw: string): string[] {
75
- if (raw.length === 0) {
76
- return [];
77
- }
78
-
60
+ if (raw.length === 0) return [];
79
61
  return raw.endsWith('\n') ? raw.slice(0, -1).split('\n') : raw.split('\n');
80
62
  }
81
63
 
82
- function stripYamlValue(value: string): string {
83
- const trimmed = value.trim();
84
- if (
85
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
86
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
87
- ) {
88
- return trimmed.slice(1, -1);
89
- }
90
-
91
- return trimmed;
92
- }
93
-
94
- function parseKeyValue(line: string): { key: string; value: string } | undefined {
95
- const index = line.indexOf(':');
96
- if (index === -1) {
97
- return undefined;
98
- }
99
-
100
- return {
101
- key: line.slice(0, index).trim(),
102
- value: stripYamlValue(line.slice(index + 1)),
103
- };
104
- }
105
-
106
64
  function parseError(path: string, line: number, message: string): Error {
107
- return new Error(`Malformed board at ${path}:${line}: ${message}`);
65
+ return new Error(`Malformed tasks file at ${path}:${line}: ${message}`);
108
66
  }
109
67
 
110
68
  function statusFromMarker(path: string, lineNumber: number, marker: string): BoardTaskStatus {
111
- if (marker === ' ') {
112
- return BoardTaskStatus.Todo;
113
- }
114
- if (marker === '~' || marker === '/') {
115
- return BoardTaskStatus.InProgress;
116
- }
117
- if (marker === 'x' || marker === 'X') {
118
- return BoardTaskStatus.Done;
119
- }
120
- if (marker === '!') {
121
- return BoardTaskStatus.Blocked;
122
- }
123
-
69
+ if (marker === ' ') return BoardTaskStatus.Todo;
70
+ if (marker === '~' || marker === '/') return BoardTaskStatus.InProgress;
71
+ if (marker === 'x' || marker === 'X') return BoardTaskStatus.Done;
72
+ if (marker === '!') return BoardTaskStatus.Blocked;
73
+ if (marker === '-') return BoardTaskStatus.Cut;
124
74
  throw parseError(path, lineNumber, `unsupported status marker "${marker}"`);
125
75
  }
126
76
 
127
- function statusFromValue(value: string): BoardTaskStatus | undefined {
128
- const normalized = stripYamlValue(value).trim().toLowerCase().replace(/\s+/g, ' ');
129
-
130
- if (
131
- normalized === BoardTaskStatus.Todo ||
132
- normalized === 'to do' ||
133
- normalized === 'clarifying' ||
134
- normalized === 'approved for planning'
135
- ) {
136
- return BoardTaskStatus.Todo;
137
- }
138
-
139
- if (
140
- normalized === BoardTaskStatus.InProgress ||
141
- normalized === 'in progress' ||
142
- normalized === 'approved for execution' ||
143
- normalized === 'needs review'
144
- ) {
145
- return BoardTaskStatus.InProgress;
146
- }
147
-
148
- if (normalized === BoardTaskStatus.Done || normalized === 'validated') {
149
- return BoardTaskStatus.Done;
150
- }
151
-
152
- if (normalized === BoardTaskStatus.Blocked || normalized === 'cut from mvp') {
153
- return BoardTaskStatus.Blocked;
154
- }
155
-
156
- return undefined;
157
- }
158
-
159
77
  function assertStatus(status: BoardTaskStatus, path: string): BoardTaskStatus {
160
- if (Object.values(BoardTaskStatus).includes(status)) {
161
- return status;
162
- }
163
-
78
+ if ((Object.values(BoardTaskStatus) as string[]).includes(status)) return status;
164
79
  throw new Error(`Unsupported board task status "${status}" for ${path}`);
165
80
  }
166
81
 
167
- function parseCheckboxTask(path: string, lineNumber: number, line: string): DraftTask {
168
- const match = /^\s*[-*]\s+\[([^\]])\]\s+([^\s:]+)\s*:\s*(.+?)\s*$/u.exec(line);
169
- if (!match) {
170
- throw parseError(path, lineNumber, 'expected checkbox task format "- [ ] TASK-ID: Title"');
171
- }
172
-
173
- return {
174
- kind: 'checkbox',
175
- id: match[2],
176
- title: match[3],
177
- status: statusFromMarker(path, lineNumber, match[1]),
178
- startIndex: lineNumber - 1,
179
- };
82
+ function escapeRegex(value: string): string {
83
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
84
  }
181
85
 
182
- function parseYamlTaskStart(path: string, lineNumber: number, line: string): DraftTask {
183
- const match = /^\s*-\s+id:\s*(.*)$/u.exec(line);
184
- if (!match) {
185
- throw parseError(path, lineNumber, 'expected YAML task id');
86
+ /**
87
+ * Parse a task checkbox line. Supports two id styles:
88
+ * - "- [ ] 1.2 Wire login form" -> id="1.2", title="Wire login form"
89
+ * - "- [ ] PAY-001: Wire checkout" -> id="PAY-001", title="Wire checkout"
90
+ * Rule: id = first whitespace-delimited token after the checkbox, trailing ":" stripped.
91
+ * Side states may be a marker ([!]/[-]) or an inline annotation ("Blocked:"/"Cut:").
92
+ */
93
+ function parseTaskLine(
94
+ path: string,
95
+ lineNumber: number,
96
+ line: string,
97
+ ): { id: string; title: string; status: BoardTaskStatus } | undefined {
98
+ const checkboxMatch = /^\s*[-*]\s+\[([^\]])\]\s+(.+)$/u.exec(line);
99
+ if (!checkboxMatch) return undefined;
100
+
101
+ const markerChar = checkboxMatch[1];
102
+ const rest = checkboxMatch[2].trim();
103
+ const spaceIdx = rest.search(/\s/u);
104
+
105
+ let id: string;
106
+ let title: string;
107
+ if (spaceIdx === -1) {
108
+ id = rest.replace(/:$/u, '');
109
+ title = id;
110
+ } else {
111
+ id = rest.slice(0, spaceIdx).replace(/:$/u, '');
112
+ title = rest.slice(spaceIdx).trim();
186
113
  }
187
114
 
188
- const id = stripYamlValue(match[1]);
189
- if (id.length === 0) {
190
- throw parseError(path, lineNumber, 'task id is required');
115
+ if (id.length === 0) return undefined;
116
+
117
+ let status = statusFromMarker(path, lineNumber, markerChar);
118
+ // Documented founder form: side states annotated inline on a "- [ ]" line.
119
+ if (/(^|\s)Cut:/u.test(rest)) {
120
+ status = BoardTaskStatus.Cut;
121
+ } else if (status === BoardTaskStatus.Todo && /(^|\s)Blocked:/u.test(rest)) {
122
+ status = BoardTaskStatus.Blocked;
191
123
  }
192
124
 
193
- return {
194
- kind: 'yaml',
195
- id,
196
- title: id,
197
- status: BoardTaskStatus.Todo,
198
- startIndex: lineNumber - 1,
199
- };
125
+ return { id, title, status };
200
126
  }
201
127
 
202
- function materializeTask(
203
- path: string,
204
- lines: readonly string[],
205
- draft: DraftTask,
206
- endIndex: number,
207
- ): BoardTask {
208
- const rawLines = lines.slice(draft.startIndex, endIndex);
209
- let title = draft.title;
210
- let status = draft.status;
211
-
212
- if (draft.kind === 'yaml') {
213
- for (const rawLine of rawLines.slice(1)) {
214
- const kv = parseKeyValue(cleanLine(rawLine).trim());
215
- if (!kv) {
216
- continue;
217
- }
218
-
219
- if (kv.key === 'title') {
220
- title = kv.value || title;
221
- } else if (kv.key === 'status') {
222
- const parsed = statusFromValue(kv.value);
223
- if (!parsed) {
224
- throw parseError(path, draft.startIndex + 1, `unsupported status "${kv.value}"`);
225
- }
226
- status = parsed;
227
- }
228
- }
128
+ /** Returns the index of the first line AFTER a leading frontmatter block, else 0. */
129
+ function skipFrontmatter(lines: readonly string[]): number {
130
+ if (lines.length === 0 || cleanLine(lines[0]).trim() !== '---') return 0;
131
+ for (let i = 1; i < lines.length; i += 1) {
132
+ if (cleanLine(lines[i]).trim() === '---') return i + 1;
229
133
  }
230
-
231
- return {
232
- id: draft.id,
233
- title,
234
- status,
235
- description: rawLines.slice(1).join('\n'),
236
- raw: rawLines.join('\n'),
237
- startLine: draft.startIndex + 1,
238
- endLine: endIndex,
239
- };
134
+ return 0;
240
135
  }
241
136
 
242
- function parseBoardMarkdown(path: string, raw: string): Board {
137
+ type DraftTask = {
138
+ id: string;
139
+ title: string;
140
+ status: BoardTaskStatus;
141
+ startIndex: number;
142
+ };
143
+
144
+ type DraftEpic = {
145
+ name: string;
146
+ startIndex: number;
147
+ firstTaskIndex?: number;
148
+ tasks: BoardTask[];
149
+ };
150
+
151
+ export function parseTasksMarkdown(path: string, raw: string): Board {
243
152
  const lines = boardLines(raw);
244
153
  const epics: BoardEpic[] = [];
245
154
  const seenTaskIds = new Set<string>();
246
155
  let currentEpic: DraftEpic | undefined;
247
156
  let currentTask: DraftTask | undefined;
157
+ const startLine = skipFrontmatter(lines);
248
158
 
249
159
  function finishTask(endIndex: number): void {
250
- if (!currentTask || !currentEpic) {
251
- return;
252
- }
253
-
254
- const task = materializeTask(path, lines, currentTask, endIndex);
160
+ if (!currentTask || !currentEpic) return;
161
+ const rawLines = lines.slice(currentTask.startIndex, endIndex);
162
+ const task: BoardTask = {
163
+ id: currentTask.id,
164
+ title: currentTask.title,
165
+ status: currentTask.status,
166
+ description: rawLines.slice(1).join('\n'),
167
+ raw: rawLines.join('\n'),
168
+ startLine: currentTask.startIndex + 1,
169
+ endLine: endIndex,
170
+ };
255
171
  if (seenTaskIds.has(task.id)) {
256
172
  throw parseError(path, task.startLine, `duplicate task id "${task.id}"`);
257
173
  }
258
-
259
174
  seenTaskIds.add(task.id);
260
175
  currentEpic.firstTaskIndex ??= currentTask.startIndex;
261
176
  currentEpic.tasks.push(task);
@@ -263,10 +178,7 @@ function parseBoardMarkdown(path: string, raw: string): Board {
263
178
  }
264
179
 
265
180
  function finishEpic(endIndex: number): void {
266
- if (!currentEpic) {
267
- return;
268
- }
269
-
181
+ if (!currentEpic) return;
270
182
  finishTask(endIndex);
271
183
  const descriptionEnd = currentEpic.firstTaskIndex ?? endIndex;
272
184
  epics.push({
@@ -280,39 +192,25 @@ function parseBoardMarkdown(path: string, raw: string): Board {
280
192
  currentEpic = undefined;
281
193
  }
282
194
 
283
- for (let index = 0; index < lines.length; index += 1) {
195
+ for (let index = startLine; index < lines.length; index += 1) {
284
196
  const lineNumber = index + 1;
285
197
  const line = cleanLine(lines[index]);
198
+
286
199
  const epicMatch = /^##(?!#)\s+(.+?)\s*$/u.exec(line);
287
200
  if (epicMatch) {
288
201
  finishEpic(index);
289
- currentEpic = {
290
- name: epicMatch[1],
291
- startIndex: index,
292
- tasks: [],
293
- };
294
- continue;
295
- }
296
-
297
- const checkboxLike = /^\s*[-*]\s+\[[^\]]+\]\s+/u.test(line);
298
- if (checkboxLike) {
299
- if (!currentEpic) {
300
- throw parseError(path, lineNumber, 'task appears before any epic heading');
301
- }
302
-
303
- finishTask(index);
304
- currentTask = parseCheckboxTask(path, lineNumber, line);
202
+ currentEpic = { name: epicMatch[1], startIndex: index, tasks: [] };
305
203
  continue;
306
204
  }
307
205
 
308
- const yamlTaskLike = /^\s*-\s+id:/u.test(line);
309
- if (yamlTaskLike) {
206
+ if (/^\s*[-*]\s+\[[^\]]\]\s+/u.test(line)) {
310
207
  if (!currentEpic) {
311
- throw parseError(path, lineNumber, 'task appears before any epic heading');
208
+ throw parseError(path, lineNumber, 'task appears before any section heading');
312
209
  }
313
-
314
210
  finishTask(index);
315
- currentTask = parseYamlTaskStart(path, lineNumber, line);
211
+ const parsed = parseTaskLine(path, lineNumber, line);
212
+ if (!parsed) throw parseError(path, lineNumber, 'unrecognised task line format');
213
+ currentTask = { ...parsed, startIndex: index };
316
214
  }
317
215
  }
318
216
 
@@ -328,41 +226,103 @@ function parseBoardMarkdown(path: string, raw: string): Board {
328
226
  };
329
227
  }
330
228
 
331
- async function readExistingBoard(path: string): Promise<string> {
229
+ // Legacy alias retained for any existing callers.
230
+ export { parseTasksMarkdown as parseBoardMarkdown };
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Multi-change aggregation
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export async function readAllChangeTasks(projectRoot: string): Promise<AggregatedTask[]> {
237
+ const changesDir = join(projectRoot, 'docs', 'spark', 'changes');
238
+ let changeIds: string[];
239
+
332
240
  try {
333
- return await readFile(path, 'utf8');
241
+ const dirents = await readdir(changesDir, { withFileTypes: true });
242
+ changeIds = dirents
243
+ .filter((d) => d.isDirectory() && d.name !== 'archive')
244
+ .map((d) => d.name)
245
+ .sort();
334
246
  } catch (error) {
335
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
336
- return '# Board\n';
337
- }
247
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
338
248
  throw error;
339
249
  }
340
- }
341
250
 
342
- export async function readBoard(projectRoot: string): Promise<Board> {
343
- const path = boardFilePath(projectRoot);
251
+ const allTasks: AggregatedTask[] = [];
344
252
 
345
- try {
346
- const raw = await readFile(path, 'utf8');
347
- return parseBoardMarkdown(path, raw);
348
- } catch (error) {
349
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
350
- throw new Error(`Board file not found at ${path}`, { cause: error });
253
+ for (const changeId of changeIds) {
254
+ const tasksPath = join(changesDir, changeId, 'tasks.md');
255
+ let raw: string;
256
+ try {
257
+ raw = await readFile(tasksPath, 'utf8');
258
+ } catch (error) {
259
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
260
+ throw error;
261
+ }
262
+ const board = parseTasksMarkdown(tasksPath, raw);
263
+ for (const epic of board.epics) {
264
+ for (const task of epic.tasks) {
265
+ allTasks.push({ ...task, changeId });
266
+ }
351
267
  }
352
- throw error;
353
268
  }
269
+
270
+ return allTasks;
354
271
  }
355
272
 
356
- function escapeRegex(value: string): string {
357
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
273
+ // ---------------------------------------------------------------------------
274
+ // renderBuildStatus — pure, deterministic
275
+ // ---------------------------------------------------------------------------
276
+
277
+ export function renderBuildStatus(tasks: readonly AggregatedTask[]): string {
278
+ const todo = tasks.filter((t) => t.status === BoardTaskStatus.Todo);
279
+ const inProgress = tasks.filter((t) => t.status === BoardTaskStatus.InProgress);
280
+ const done = tasks.filter((t) => t.status === BoardTaskStatus.Done);
281
+ const blocked = tasks.filter((t) => t.status === BoardTaskStatus.Blocked);
282
+ const cut = tasks.filter((t) => t.status === BoardTaskStatus.Cut);
283
+
284
+ const lines: string[] = [
285
+ `Todo: ${todo.length} · In progress: ${inProgress.length} · Done: ${done.length} · Blocked: ${blocked.length}`,
286
+ ];
287
+
288
+ function renderGroup(label: string, group: readonly AggregatedTask[]): void {
289
+ if (group.length === 0) return;
290
+ lines.push(`\n### ${label}`);
291
+ const sorted = [...group].sort((a, b) =>
292
+ a.changeId === b.changeId ? a.id.localeCompare(b.id) : a.changeId.localeCompare(b.changeId),
293
+ );
294
+ for (const task of sorted) {
295
+ lines.push(`- [${task.changeId}] ${task.id}: ${task.title}`);
296
+ }
297
+ }
298
+
299
+ renderGroup('In progress', inProgress);
300
+ renderGroup('Blocked', blocked);
301
+ renderGroup('Todo', todo);
302
+ renderGroup('Done', done);
303
+ if (cut.length > 0) renderGroup('Cut', cut);
304
+
305
+ return lines.join('\n');
358
306
  }
359
307
 
360
- function boardHasTask(board: string, taskId: string): boolean {
361
- const escaped = escapeRegex(taskId);
362
- const checkboxPattern = new RegExp(`(^|\\n)\\s*[-*]\\s+\\[[^\\]]+\\]\\s+${escaped}\\s*:`, 'u');
363
- const yamlPattern = new RegExp(`(^|\\n)\\s*-\\s+id:\\s*["']?${escaped}["']?\\s*(\\n|$)`, 'u');
308
+ // ---------------------------------------------------------------------------
309
+ // Pack-install task seeding
310
+ // ---------------------------------------------------------------------------
311
+
312
+ export function packInstallChangeId(date: Date = new Date()): string {
313
+ const yyyy = date.getUTCFullYear();
314
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
315
+ const dd = String(date.getUTCDate()).padStart(2, '0');
316
+ return `pack-install-${yyyy}-${mm}-${dd}`;
317
+ }
318
+
319
+ function changeTasksFilePath(projectRoot: string, changeId: string): string {
320
+ return join(projectRoot, 'docs', 'spark', 'changes', changeId, 'tasks.md');
321
+ }
364
322
 
365
- return checkboxPattern.test(board) || yamlPattern.test(board);
323
+ function buildFrontmatter(): string {
324
+ const now = new Date().toISOString();
325
+ return `---\ncreated_at: ${now}\nupdated_at: ${now}\ncompleted_at:\n---\n`;
366
326
  }
367
327
 
368
328
  function generatedTaskId(packName: string, index: number): string {
@@ -370,7 +330,6 @@ function generatedTaskId(packName: string, index: number): string {
370
330
  .toUpperCase()
371
331
  .replace(/[^A-Z0-9]+/g, '-')
372
332
  .replace(/^-+|-+$/g, '');
373
-
374
333
  return `${prefix || 'PACK'}-${String(index + 1).padStart(3, '0')}`;
375
334
  }
376
335
 
@@ -383,7 +342,6 @@ function normalizeSeedTask(
383
342
  if (task.title.trim().length === 0) {
384
343
  throw new Error(`Cannot seed board task with an empty title for ${path}`);
385
344
  }
386
-
387
345
  return {
388
346
  id: task.id?.trim() || generatedTaskId(packName, index),
389
347
  title: task.title.trim(),
@@ -392,69 +350,69 @@ function normalizeSeedTask(
392
350
  };
393
351
  }
394
352
 
395
- function formatSeedTask(task: Required<SeedTask>): string {
353
+ function formatSeedTask(task: Required<SeedTask>, packName: string): string {
396
354
  const lines = [`- [${statusMarkers[task.status]}] ${task.id}: ${task.title}`];
355
+ lines.push(' - Status: Clarifying');
356
+ lines.push(` - requires_pack: ${packName}`);
397
357
  const description = task.description.replace(/\r\n/g, '\n').replace(/\n$/u, '');
398
-
399
358
  if (description.trim().length > 0) {
400
- lines.push(...description.split('\n').map((line) => ` ${line}`));
359
+ lines.push(...description.split('\n').map((l) => ` ${l}`));
401
360
  }
402
-
403
361
  return lines.join('\n');
404
362
  }
405
363
 
406
- function lineStartOffsets(raw: string): number[] {
407
- const offsets = [0];
408
- for (let index = 0; index < raw.length; index += 1) {
409
- if (raw[index] === '\n') {
410
- offsets.push(index + 1);
411
- }
412
- }
364
+ function rawHasTaskId(raw: string, taskId: string): boolean {
365
+ const escaped = escapeRegex(taskId);
366
+ return new RegExp(`(^|\\n)\\s*[-*]\\s+\\[[^\\]]\\]\\s+${escaped}(?::|\\s|$)`, 'u').test(raw);
367
+ }
413
368
 
414
- return offsets;
369
+ async function readExistingRaw(path: string): Promise<string> {
370
+ try {
371
+ return await readFile(path, 'utf8');
372
+ } catch (error) {
373
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return '';
374
+ throw error;
375
+ }
415
376
  }
416
377
 
417
- function findSectionInsertIndex(board: string, packName: string): number | undefined {
378
+ function appendPackSection(
379
+ raw: string,
380
+ packName: string,
381
+ tasks: readonly Required<SeedTask>[],
382
+ ): string {
383
+ const taskBlock = tasks.map((t) => formatSeedTask(t, packName)).join('\n\n');
418
384
  const heading = `## ${packName}`;
419
- const lines = board.split('\n');
420
- const offsets = lineStartOffsets(board);
385
+ const rawLines = raw.split('\n');
421
386
  let headingIndex = -1;
422
387
 
423
- for (let index = 0; index < lines.length; index += 1) {
424
- if (cleanLine(lines[index]).trim() === heading) {
425
- headingIndex = index;
388
+ for (let i = 0; i < rawLines.length; i += 1) {
389
+ if (cleanLine(rawLines[i]).trim() === heading) {
390
+ headingIndex = i;
426
391
  break;
427
392
  }
428
393
  }
429
394
 
430
395
  if (headingIndex === -1) {
431
- return undefined;
396
+ const sep = raw.endsWith('\n') ? '\n' : '\n\n';
397
+ return `${raw}${sep}${heading}\n\n${taskBlock}\n`;
432
398
  }
433
399
 
434
- for (let index = headingIndex + 1; index < lines.length; index += 1) {
435
- if (/^##(?!#)\s+/u.test(cleanLine(lines[index]))) {
436
- return offsets[index] ?? board.length;
400
+ // Section exists insert before the next "## " heading, or at EOF.
401
+ let insertOffset = raw.length;
402
+ let charCount = 0;
403
+ for (let i = 0; i < rawLines.length; i += 1) {
404
+ if (i > headingIndex && /^##(?!#)\s+/u.test(cleanLine(rawLines[i]))) {
405
+ insertOffset = charCount;
406
+ break;
437
407
  }
408
+ charCount += rawLines[i].length + 1;
438
409
  }
439
410
 
440
- return board.length;
441
- }
442
-
443
- function appendTasks(board: string, packName: string, tasks: readonly Required<SeedTask>[]): string {
444
- const taskBlock = tasks.map(formatSeedTask).join('\n\n');
445
- const insertIndex = findSectionInsertIndex(board, packName);
446
-
447
- if (insertIndex === undefined) {
448
- const separator = board.endsWith('\n') ? '\n' : '\n\n';
449
- return `${board}${separator}## ${packName}\n\n${taskBlock}\n`;
450
- }
451
-
452
- const before = board.slice(0, insertIndex);
453
- const after = board.slice(insertIndex);
454
- const separator = before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
455
- const afterSeparator = after.length > 0 ? '\n' : '';
456
-
457
- return `${before}${separator}${taskBlock}\n${afterSeparator}${after}`;
411
+ const before = raw.slice(0, insertOffset);
412
+ const after = raw.slice(insertOffset);
413
+ const sep = before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
414
+ const afterSep = after.length > 0 ? '\n' : '';
415
+ return `${before}${sep}${taskBlock}\n${afterSep}${after}`;
458
416
  }
459
417
 
460
418
  export async function seedTasks(
@@ -462,36 +420,40 @@ export async function seedTasks(
462
420
  packName: string,
463
421
  tasks: Array<SeedTask>,
464
422
  ): Promise<void> {
465
- if (tasks.length === 0) {
466
- return;
467
- }
423
+ if (tasks.length === 0) return;
424
+
425
+ const existingIds = new Set((await readAllChangeTasks(projectRoot)).map((t) => t.id));
426
+ const path = changeTasksFilePath(projectRoot, packInstallChangeId());
427
+ const currentRaw = await readExistingRaw(path);
468
428
 
469
- const path = boardFilePath(projectRoot);
470
- const board = await readExistingBoard(path);
471
429
  const normalizedTasks = tasks
472
430
  .map((task, index) => normalizeSeedTask(path, packName, task, index))
473
- .filter((task) => !boardHasTask(board, task.id));
431
+ .filter((task) => !existingIds.has(task.id) && !rawHasTaskId(currentRaw, task.id));
474
432
 
475
- if (normalizedTasks.length === 0) {
476
- return;
477
- }
433
+ if (normalizedTasks.length === 0) return;
434
+
435
+ const base = currentRaw.length > 0 ? currentRaw : buildFrontmatter();
436
+ const next = appendPackSection(base, packName, normalizedTasks);
478
437
 
479
438
  await mkdir(dirname(path), { recursive: true });
480
- await writeFile(path, appendTasks(board, packName, normalizedTasks));
439
+ await writeFile(path, next);
481
440
  }
482
441
 
442
+ // ---------------------------------------------------------------------------
443
+ // updateStatus — parameterized by the tasks file path
444
+ // ---------------------------------------------------------------------------
445
+
483
446
  export async function updateStatus(
484
- projectRoot: string,
447
+ tasksFile: string,
485
448
  taskId: string,
486
449
  status: BoardTaskStatus,
487
450
  ): Promise<void> {
488
- const path = boardFilePath(projectRoot);
489
- const nextStatus = assertStatus(status, path);
490
- const raw = await readFile(path, 'utf8');
451
+ const nextStatus = assertStatus(status, tasksFile);
452
+ const raw = await readFile(tasksFile, 'utf8');
491
453
  const lines = raw.split('\n');
492
454
  const marker = statusMarkers[nextStatus];
493
455
  const checkboxPattern = new RegExp(
494
- `^(\\s*[-*]\\s+\\[)([^\\]])(\\]\\s+${escapeRegex(taskId)}\\s*:\\s*.+)$`,
456
+ `^(\\s*[-*]\\s+\\[)([^\\]])(\\]\\s+${escapeRegex(taskId)}(?::|\\s).*)$`,
495
457
  'u',
496
458
  );
497
459
 
@@ -499,43 +461,43 @@ export async function updateStatus(
499
461
  const originalLine = lines[index];
500
462
  const hasCarriage = originalLine.endsWith('\r');
501
463
  const line = cleanLine(originalLine);
502
- const checkboxMatch = checkboxPattern.exec(line);
503
-
504
- if (checkboxMatch) {
505
- lines[index] = `${checkboxMatch[1]}${marker}${checkboxMatch[3]}${hasCarriage ? '\r' : ''}`;
464
+ const match = checkboxPattern.exec(line);
465
+ if (match) {
466
+ lines[index] = `${match[1]}${marker}${match[3]}${hasCarriage ? '\r' : ''}`;
506
467
  const nextRaw = lines.join('\n');
507
- if (nextRaw !== raw) {
508
- await writeFile(path, nextRaw);
509
- }
468
+ if (nextRaw !== raw) await writeFile(tasksFile, nextRaw);
510
469
  return;
511
470
  }
471
+ }
512
472
 
513
- const yamlMatch = /^\s*-\s+id:\s*(.*)$/u.exec(line);
514
- if (yamlMatch && stripYamlValue(yamlMatch[1]) === taskId) {
515
- for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex += 1) {
516
- const candidate = cleanLine(lines[nextIndex]);
517
- if (/^##(?!#)\s+/u.test(candidate) || /^\s*-\s+id:/u.test(candidate)) {
518
- break;
519
- }
520
-
521
- const statusMatch = /^(\s*status:\s*)(.*?)(\s*)$/u.exec(candidate);
522
- if (!statusMatch) {
523
- continue;
524
- }
525
-
526
- const candidateHasCarriage = lines[nextIndex].endsWith('\r');
527
- lines[nextIndex] =
528
- `${statusMatch[1]}${nextStatus}${statusMatch[3]}${candidateHasCarriage ? '\r' : ''}`;
529
- const nextRaw = lines.join('\n');
530
- if (nextRaw !== raw) {
531
- await writeFile(path, nextRaw);
532
- }
533
- return;
534
- }
473
+ throw new Error(`Task "${taskId}" not found in ${tasksFile}`);
474
+ }
535
475
 
536
- throw new Error(`Task "${taskId}" has no status marker in ${path}`);
476
+ // ---------------------------------------------------------------------------
477
+ // readBoard — reads a single change's tasks.md, or aggregates across changes
478
+ // ---------------------------------------------------------------------------
479
+
480
+ export async function readBoard(projectRoot: string, changeId?: string): Promise<Board> {
481
+ if (changeId) {
482
+ const path = changeTasksFilePath(projectRoot, changeId);
483
+ try {
484
+ const raw = await readFile(path, 'utf8');
485
+ return parseTasksMarkdown(path, raw);
486
+ } catch (error) {
487
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
488
+ throw new Error(`Tasks file not found at ${path}`, { cause: error });
489
+ }
490
+ throw error;
537
491
  }
538
492
  }
539
493
 
540
- throw new Error(`Task "${taskId}" not found in ${path}`);
494
+ const allTasks = await readAllChangeTasks(projectRoot);
495
+ return {
496
+ path: join(projectRoot, 'docs', 'spark', 'changes'),
497
+ raw: '',
498
+ epics: [],
499
+ toMarkdown() {
500
+ return renderBuildStatus(allTasks);
501
+ },
502
+ };
541
503
  }