@forgeailab/spark 0.2.0 → 0.4.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/package.json +8 -6
- package/src/commands/add.ts +3 -1
- package/src/commands/preset.ts +1 -0
- package/src/internal/board.ts +266 -304
- package/src/io/board.ts +29 -65
- package/src/io/env.ts +8 -3
- package/src/io/files.ts +22 -7
- package/packs/README.md +0 -130
- package/packs/ai-anthropic/files/app/api/ai/route.ts +0 -57
- package/packs/ai-anthropic/files/lib/anthropic.ts +0 -58
- package/packs/ai-anthropic/pack.toml +0 -31
- package/packs/ai-anthropic/skills/ai-feature-patterns/SKILL.md +0 -87
- package/packs/ai-anthropic/tasks.yaml +0 -9
- package/packs/ai-openai/files/app/api/ai-openai/route.ts +0 -55
- package/packs/ai-openai/files/lib/openai.ts +0 -21
- package/packs/ai-openai/pack.toml +0 -30
- package/packs/ai-openai/tasks.yaml +0 -9
- package/packs/analytics-posthog/files/components/PostHogProvider.tsx +0 -19
- package/packs/analytics-posthog/files/lib/posthog/client.ts +0 -20
- package/packs/analytics-posthog/files/lib/posthog/server.ts +0 -24
- package/packs/analytics-posthog/pack.toml +0 -35
- package/packs/analytics-posthog/tasks.yaml +0 -15
- package/packs/auth-better-auth/files/app/(auth)/login/page.tsx +0 -58
- package/packs/auth-better-auth/files/app/api/auth/[...all]/route.ts +0 -4
- package/packs/auth-better-auth/files/lib/auth.ts +0 -60
- package/packs/auth-better-auth/pack.toml +0 -28
- package/packs/auth-better-auth/tasks.yaml +0 -10
- package/packs/auth-better-auth-pg/files/app/api/auth/[...all]/route.ts +0 -4
- package/packs/auth-better-auth-pg/files/lib/auth.ts +0 -125
- package/packs/auth-better-auth-pg/pack.toml +0 -28
- package/packs/auth-better-auth-pg/tasks.yaml +0 -17
- package/packs/auth-supabase/files/app/(auth)/login/page.tsx +0 -64
- package/packs/auth-supabase/files/app/auth/callback/route.ts +0 -15
- package/packs/auth-supabase/files/middleware.ts +0 -41
- package/packs/auth-supabase/pack.toml +0 -34
- package/packs/auth-supabase/tasks.yaml +0 -10
- package/packs/db-postgres/files/compose/postgres.yml +0 -28
- package/packs/db-postgres/files/docker-compose.include.yml +0 -1
- package/packs/db-postgres/files/docker-compose.yml +0 -6
- package/packs/db-postgres/files/drizzle.config.ts +0 -10
- package/packs/db-postgres/files/lib/db/index.ts +0 -10
- package/packs/db-postgres/files/lib/db/schema.ts +0 -11
- package/packs/db-postgres/pack.toml +0 -53
- package/packs/db-postgres/tasks.yaml +0 -11
- package/packs/db-sqlite/files/drizzle.config.ts +0 -10
- package/packs/db-sqlite/files/lib/db.ts +0 -8
- package/packs/db-sqlite/files/lib/schema.ts +0 -13
- package/packs/db-sqlite/pack.toml +0 -34
- package/packs/db-sqlite/tasks.yaml +0 -6
- package/packs/db-supabase/files/lib/supabase/client.ts +0 -8
- package/packs/db-supabase/files/lib/supabase/server.ts +0 -27
- package/packs/db-supabase/pack.toml +0 -32
- package/packs/db-supabase/skills/supabase-patterns/SKILL.md +0 -82
- package/packs/db-supabase/tasks.yaml +0 -6
- package/packs/deploy-vercel/files/docs/deploy.md +0 -21
- package/packs/deploy-vercel/files/vercel.json +0 -4
- package/packs/deploy-vercel/pack.toml +0 -30
- package/packs/deploy-vercel/tasks.yaml +0 -14
- package/packs/docker-compose-dev/files/.env.docker.example +0 -2
- package/packs/docker-compose-dev/files/compose/redis.yml +0 -17
- package/packs/docker-compose-dev/files/docker-compose.include.yml +0 -1
- package/packs/docker-compose-dev/files/docker-compose.yml +0 -6
- package/packs/docker-compose-dev/pack.toml +0 -38
- package/packs/docker-compose-dev/tasks.yaml +0 -9
- package/packs/email-resend/files/app/api/email/test/route.ts +0 -38
- package/packs/email-resend/files/emails/welcome.tsx +0 -66
- package/packs/email-resend/files/lib/email.ts +0 -40
- package/packs/email-resend/pack.toml +0 -34
- package/packs/email-resend/tasks.yaml +0 -9
- package/packs/example/pack.toml +0 -69
- package/packs/payments-stripe/files/app/api/billing-portal/route.ts +0 -24
- package/packs/payments-stripe/files/app/api/checkout/route.ts +0 -58
- package/packs/payments-stripe/files/app/api/webhooks/stripe/route.ts +0 -84
- package/packs/payments-stripe/files/lib/stripe.ts +0 -158
- package/packs/payments-stripe/pack.toml +0 -45
- package/packs/payments-stripe/skills/stripe-patterns/SKILL.md +0 -93
- package/packs/payments-stripe/tasks.yaml +0 -16
- package/packs/sync-zero/files/components/ZeroProvider.tsx +0 -13
- package/packs/sync-zero/files/compose/zero-cache.yml +0 -26
- package/packs/sync-zero/files/docker-compose.include.yml +0 -1
- package/packs/sync-zero/files/docker-compose.yml +0 -6
- package/packs/sync-zero/files/lib/zero/client.ts +0 -18
- package/packs/sync-zero/files/lib/zero/schema.ts +0 -30
- package/packs/sync-zero/files/zero.config.ts +0 -26
- package/packs/sync-zero/pack.toml +0 -57
- package/packs/sync-zero/skills/zero-patterns/SKILL.md +0 -69
- package/packs/sync-zero/tasks.yaml +0 -16
- package/packs/testing-playwright/files/e2e/example.spec.ts +0 -7
- package/packs/testing-playwright/files/playwright.config.ts +0 -33
- package/packs/testing-playwright/pack.toml +0 -25
- package/packs/testing-playwright/tasks.yaml +0 -9
- package/packs/ui-shadcn/files/app/globals.css +0 -56
- package/packs/ui-shadcn/files/components/ui/button.tsx +0 -47
- package/packs/ui-shadcn/files/components/ui/card.tsx +0 -33
- package/packs/ui-shadcn/files/lib/utils.ts +0 -6
- package/packs/ui-shadcn/files/postcss.config.mjs +0 -7
- package/packs/ui-shadcn/files/tailwind.config.ts +0 -57
- package/packs/ui-shadcn/pack.toml +0 -44
- package/packs/ui-shadcn/skills/shadcn-dashboard-patterns/SKILL.md +0 -85
- package/packs/ui-shadcn/tasks.yaml +0 -6
- package/presets/docs-site.toml +0 -4
- package/presets/internal-tool.toml +0 -4
- package/presets/lean-saas.toml +0 -4
- package/presets/local-ai-mvp.toml +0 -4
- package/presets/saas-classic.toml +0 -4
- package/templates/README.md +0 -43
- package/templates/astro/README.md +0 -3
- package/templates/astro/template.toml +0 -4
- package/templates/astro-starlight/README.md +0 -3
- package/templates/astro-starlight/template.toml +0 -4
- package/templates/nextjs/.ai/architecture.md +0 -13
- package/templates/nextjs/.ai/board.md +0 -7
- package/templates/nextjs/.ai/product-spec.md +0 -11
- package/templates/nextjs/.claude/skills/.gitkeep +0 -0
- package/templates/nextjs/.codex/skills/.gitkeep +0 -0
- package/templates/nextjs/AGENTS.md +0 -95
- package/templates/nextjs/CLAUDE.md +0 -3
- package/templates/nextjs/README.md +0 -20
- package/templates/nextjs/app/(app)/home/page.tsx +0 -43
- package/templates/nextjs/app/(app)/home/posts-panel.tsx +0 -83
- package/templates/nextjs/app/(app)/layout.tsx +0 -12
- package/templates/nextjs/app/(auth)/login/page.tsx +0 -97
- package/templates/nextjs/app/globals.css +0 -23
- package/templates/nextjs/app/layout.tsx +0 -20
- package/templates/nextjs/app/page.tsx +0 -39
- package/templates/nextjs/lib/auth-placeholder.ts +0 -21
- package/templates/nextjs/lib/posts-placeholder.ts +0 -30
- package/templates/nextjs/next.config.ts +0 -5
- package/templates/nextjs/package.json +0 -26
- package/templates/nextjs/postcss.config.mjs +0 -7
- package/templates/nextjs/spark.config.json +0 -4
- package/templates/nextjs/template.toml +0 -4
- package/templates/nextjs/tsconfig.json +0 -27
- package/templates/nextjs/types/post.ts +0 -13
- package/templates/one/README.md +0 -5
- package/templates/one/template.toml +0 -4
- package/templates/vite-react/README.md +0 -3
- package/templates/vite-react/template.toml +0 -4
package/src/internal/board.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
if (marker === '
|
|
115
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
lines
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 =
|
|
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
|
-
|
|
309
|
-
if (yamlTaskLike) {
|
|
206
|
+
if (/^\s*[-*]\s+\[[^\]]\]\s+/u.test(line)) {
|
|
310
207
|
if (!currentEpic) {
|
|
311
|
-
throw parseError(path, lineNumber, 'task appears before any
|
|
208
|
+
throw parseError(path, lineNumber, 'task appears before any section heading');
|
|
312
209
|
}
|
|
313
|
-
|
|
314
210
|
finishTask(index);
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
const path = boardFilePath(projectRoot);
|
|
251
|
+
const allTasks: AggregatedTask[] = [];
|
|
344
252
|
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
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((
|
|
359
|
+
lines.push(...description.split('\n').map((l) => ` ${l}`));
|
|
401
360
|
}
|
|
402
|
-
|
|
403
361
|
return lines.join('\n');
|
|
404
362
|
}
|
|
405
363
|
|
|
406
|
-
function
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
420
|
-
const offsets = lineStartOffsets(board);
|
|
385
|
+
const rawLines = raw.split('\n');
|
|
421
386
|
let headingIndex = -1;
|
|
422
387
|
|
|
423
|
-
for (let
|
|
424
|
-
if (cleanLine(
|
|
425
|
-
headingIndex =
|
|
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
|
-
|
|
396
|
+
const sep = raw.endsWith('\n') ? '\n' : '\n\n';
|
|
397
|
+
return `${raw}${sep}${heading}\n\n${taskBlock}\n`;
|
|
432
398
|
}
|
|
433
399
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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) => !
|
|
431
|
+
.filter((task) => !existingIds.has(task.id) && !rawHasTaskId(currentRaw, task.id));
|
|
474
432
|
|
|
475
|
-
if (normalizedTasks.length === 0)
|
|
476
|
-
|
|
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,
|
|
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
|
-
|
|
447
|
+
tasksFile: string,
|
|
485
448
|
taskId: string,
|
|
486
449
|
status: BoardTaskStatus,
|
|
487
450
|
): Promise<void> {
|
|
488
|
-
const
|
|
489
|
-
const
|
|
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)}
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|