gswd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agents/gswd/architecture-drafter.md +70 -0
- package/agents/gswd/brainstorm-alternatives.md +60 -0
- package/agents/gswd/devils-advocate.md +57 -0
- package/agents/gswd/icp-persona.md +58 -0
- package/agents/gswd/integrations-checker.md +68 -0
- package/agents/gswd/journey-mapper.md +69 -0
- package/agents/gswd/market-researcher.md +54 -0
- package/agents/gswd/positioning.md +54 -0
- package/bin/gswd-tools.cjs +716 -0
- package/lib/audit.ts +959 -0
- package/lib/bootstrap.ts +617 -0
- package/lib/compile.ts +940 -0
- package/lib/config.ts +164 -0
- package/lib/imagine-agents.ts +154 -0
- package/lib/imagine-gate.ts +156 -0
- package/lib/imagine-input.ts +242 -0
- package/lib/imagine-synthesis.ts +402 -0
- package/lib/imagine.ts +433 -0
- package/lib/parse.ts +196 -0
- package/lib/render.ts +200 -0
- package/lib/specify-agents.ts +332 -0
- package/lib/specify-journeys.ts +410 -0
- package/lib/specify-nfr.ts +208 -0
- package/lib/specify-roles.ts +122 -0
- package/lib/specify.ts +773 -0
- package/lib/state.ts +305 -0
- package/package.json +26 -0
- package/templates/gswd/ARCHITECTURE.template.md +17 -0
- package/templates/gswd/AUDIT.template.md +31 -0
- package/templates/gswd/COMPETITION.template.md +18 -0
- package/templates/gswd/DECISIONS.template.md +18 -0
- package/templates/gswd/GTM.template.md +18 -0
- package/templates/gswd/ICP.template.md +18 -0
- package/templates/gswd/IMAGINE.template.md +24 -0
- package/templates/gswd/INTEGRATIONS.template.md +7 -0
- package/templates/gswd/JOURNEYS.template.md +7 -0
- package/templates/gswd/NFR.template.md +7 -0
- package/templates/gswd/PROJECT.template.md +21 -0
- package/templates/gswd/REQUIREMENTS.template.md +31 -0
- package/templates/gswd/ROADMAP.template.md +21 -0
- package/templates/gswd/SPEC.template.md +19 -0
- package/templates/gswd/STATE.template.md +15 -0
package/lib/compile.ts
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Compile Module — Deterministic contract doc generation
|
|
3
|
+
*
|
|
4
|
+
* Reads spec artifacts (IMAGINE.md, DECISIONS.md, SPEC.md, NFR.md, JOURNEYS.md, INTEGRATIONS.md),
|
|
5
|
+
* parses into a SpecBundle, and generates 4 GSD contract docs deterministically.
|
|
6
|
+
* Zero LLM calls, no randomness, no ordering variance.
|
|
7
|
+
*
|
|
8
|
+
* Schema: GSWD_SPEC.md Section 8.5, 9.2, 11.3
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { extractIds, extractHeadingContent, normalizeId, validateHeadings } from './parse.js';
|
|
14
|
+
import {
|
|
15
|
+
readState,
|
|
16
|
+
writeState,
|
|
17
|
+
safeWriteFile,
|
|
18
|
+
updateStageStatus,
|
|
19
|
+
writeCheckpoint,
|
|
20
|
+
} from './state.js';
|
|
21
|
+
import { checkAuditGate } from './audit.js';
|
|
22
|
+
|
|
23
|
+
// ─── Types (all exported) ─────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ParsedFR {
|
|
26
|
+
id: string;
|
|
27
|
+
description: string;
|
|
28
|
+
scope: 'v1' | 'v2' | 'out';
|
|
29
|
+
priority: 'P0' | 'P1' | 'P2';
|
|
30
|
+
sourceJourneys: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ParsedNFR {
|
|
34
|
+
id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
category: string;
|
|
37
|
+
threshold: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ParsedJourney {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
type: string;
|
|
44
|
+
linkedFRs: string[];
|
|
45
|
+
linkedIntegrations: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ParsedIntegration {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
status: string;
|
|
52
|
+
fallback: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SpecBundle {
|
|
56
|
+
vision: string;
|
|
57
|
+
targetUser: string;
|
|
58
|
+
productDirection: string;
|
|
59
|
+
wedge: string;
|
|
60
|
+
frozenDecisions: string[];
|
|
61
|
+
successMetrics: string[];
|
|
62
|
+
outOfScope: string[];
|
|
63
|
+
risks: string[];
|
|
64
|
+
openQuestions: string[];
|
|
65
|
+
approvals: Record<string, string>;
|
|
66
|
+
frs: ParsedFR[];
|
|
67
|
+
roles: string;
|
|
68
|
+
nfrs: ParsedNFR[];
|
|
69
|
+
journeys: ParsedJourney[];
|
|
70
|
+
integrations: ParsedIntegration[];
|
|
71
|
+
projectSlug: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Constants (all exported) ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export const SCOPE_ORDER: Record<string, number> = { v1: 0, v2: 1, out: 2 };
|
|
77
|
+
export const PRIORITY_ORDER: Record<string, number> = { P0: 0, P1: 1, P2: 2 };
|
|
78
|
+
export const NFR_CATEGORY_ORDER = ['security', 'privacy', 'performance', 'observability'];
|
|
79
|
+
export const JOURNEY_TYPE_TO_PHASE: Record<string, number> = {
|
|
80
|
+
onboarding: 1,
|
|
81
|
+
core_action: 1,
|
|
82
|
+
view_results: 2,
|
|
83
|
+
error_states: 3,
|
|
84
|
+
empty_states: 3,
|
|
85
|
+
settings: 3,
|
|
86
|
+
};
|
|
87
|
+
export const PHASE_NAMES: Record<number, string> = {
|
|
88
|
+
1: 'Skeleton & Core Loop',
|
|
89
|
+
2: 'Persistence & History',
|
|
90
|
+
3: 'Polish: errors, empty states, settings',
|
|
91
|
+
4: 'Observability & Hardening',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ─── Sort Helpers ─────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Sort FRs by scope (v1 first), then priority (P0 first), then numeric ID ascending.
|
|
98
|
+
*/
|
|
99
|
+
export function sortFRs(frs: ParsedFR[]): ParsedFR[] {
|
|
100
|
+
return [...frs].sort((a, b) => {
|
|
101
|
+
const scopeDiff = (SCOPE_ORDER[a.scope] ?? 99) - (SCOPE_ORDER[b.scope] ?? 99);
|
|
102
|
+
if (scopeDiff !== 0) return scopeDiff;
|
|
103
|
+
const priorityDiff = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99);
|
|
104
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
105
|
+
// Numeric ID sort: FR-001 < FR-002 < FR-010
|
|
106
|
+
return parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Spec Artifact Parsers ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse FR entries from SPEC.md content.
|
|
114
|
+
* Uses ### FR-NNN heading pattern, extracts description, scope, and priority.
|
|
115
|
+
* Accepts a frToJourneys map to populate sourceJourneys.
|
|
116
|
+
*/
|
|
117
|
+
export function parseFRsFromSpec(
|
|
118
|
+
specContent: string,
|
|
119
|
+
frToJourneys: Map<string, Set<string>>
|
|
120
|
+
): ParsedFR[] {
|
|
121
|
+
if (!specContent) return [];
|
|
122
|
+
|
|
123
|
+
const frRegex = /^###?\s+(FR-\d{1,4})[:.]?\s*/gm;
|
|
124
|
+
const positions: { id: string; start: number }[] = [];
|
|
125
|
+
|
|
126
|
+
let match: RegExpExecArray | null;
|
|
127
|
+
while ((match = frRegex.exec(specContent)) !== null) {
|
|
128
|
+
positions.push({ id: normalizeId(match[1]), start: match.index });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const frs: ParsedFR[] = positions.map((pos, i) => {
|
|
132
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : specContent.length;
|
|
133
|
+
const section = specContent.slice(pos.start, end);
|
|
134
|
+
|
|
135
|
+
const scopeMatch = section.match(/\*{0,2}Scope:?\*{0,2}:?\s*(v1|v2|out)/i);
|
|
136
|
+
const priorityMatch = section.match(/\*{0,2}Priority:?\*{0,2}:?\s*(P0|P1|P2)/i);
|
|
137
|
+
|
|
138
|
+
// Get description: first non-empty line after the heading line
|
|
139
|
+
const lines = section.split('\n');
|
|
140
|
+
const headingLine = lines[0]; // e.g., "### FR-001: User can register"
|
|
141
|
+
// Extract description from heading line if present (after the ID)
|
|
142
|
+
const headingDescMatch = headingLine.match(/^###?\s+FR-\d{1,4}:?\s+(.*)/);
|
|
143
|
+
let description = '';
|
|
144
|
+
if (headingDescMatch && headingDescMatch[1].trim().length > 0) {
|
|
145
|
+
description = headingDescMatch[1].trim();
|
|
146
|
+
} else {
|
|
147
|
+
// Fall back to first non-empty line after heading
|
|
148
|
+
const descLine = lines.slice(1).find((l) => l.trim().length > 0 && !l.startsWith('**'));
|
|
149
|
+
description = descLine?.trim() ?? '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sourceJourneys = Array.from(frToJourneys.get(pos.id) ?? new Set<string>()).sort();
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
id: pos.id,
|
|
156
|
+
description,
|
|
157
|
+
scope: (scopeMatch?.[1]?.toLowerCase() as 'v1' | 'v2' | 'out') ?? 'v1',
|
|
158
|
+
priority: (priorityMatch?.[1] as 'P0' | 'P1' | 'P2') ?? 'P1',
|
|
159
|
+
sourceJourneys,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return sortFRs(frs);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse NFR entries from NFR.md content.
|
|
168
|
+
* Extracts id, description, category (from **Category:** field), threshold (from **Threshold:** field).
|
|
169
|
+
* Sorts by NFR_CATEGORY_ORDER then numeric ID.
|
|
170
|
+
*/
|
|
171
|
+
export function parseNFRsFromContent(nfrContent: string): ParsedNFR[] {
|
|
172
|
+
if (!nfrContent) return [];
|
|
173
|
+
|
|
174
|
+
const nfrRegex = /^###?\s+(NFR-\d{1,4})[:.]?\s*/gm;
|
|
175
|
+
const positions: { id: string; start: number }[] = [];
|
|
176
|
+
|
|
177
|
+
let match: RegExpExecArray | null;
|
|
178
|
+
while ((match = nfrRegex.exec(nfrContent)) !== null) {
|
|
179
|
+
positions.push({ id: normalizeId(match[1]), start: match.index });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const nfrs: ParsedNFR[] = positions.map((pos, i) => {
|
|
183
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : nfrContent.length;
|
|
184
|
+
const section = nfrContent.slice(pos.start, end);
|
|
185
|
+
|
|
186
|
+
const categoryMatch = section.match(/\*{0,2}Category:?\*{0,2}:?\s*(\w+)/i);
|
|
187
|
+
const thresholdMatch = section.match(/\*{0,2}Threshold:?\*{0,2}:?\s*(.+)/i);
|
|
188
|
+
|
|
189
|
+
// Extract description from heading line
|
|
190
|
+
const headingLine = section.split('\n')[0];
|
|
191
|
+
const headingDescMatch = headingLine.match(/^###?\s+NFR-\d{1,4}:?\s+(.*)/);
|
|
192
|
+
const description = headingDescMatch?.[1]?.trim() ?? '';
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
id: pos.id,
|
|
196
|
+
description,
|
|
197
|
+
category: categoryMatch?.[1]?.toLowerCase() ?? 'observability',
|
|
198
|
+
threshold: thresholdMatch?.[1]?.trim() ?? '',
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Sort by category order then numeric ID
|
|
203
|
+
return nfrs.sort((a, b) => {
|
|
204
|
+
const aOrder = NFR_CATEGORY_ORDER.indexOf(a.category);
|
|
205
|
+
const bOrder = NFR_CATEGORY_ORDER.indexOf(b.category);
|
|
206
|
+
const aCatOrder = aOrder === -1 ? 99 : aOrder;
|
|
207
|
+
const bCatOrder = bOrder === -1 ? 99 : bOrder;
|
|
208
|
+
if (aCatOrder !== bCatOrder) return aCatOrder - bCatOrder;
|
|
209
|
+
return parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Parse journey entries from JOURNEYS.md content.
|
|
215
|
+
* Extracts id, name, type, linkedFRs, linkedIntegrations.
|
|
216
|
+
* Sorts by numeric ID.
|
|
217
|
+
*/
|
|
218
|
+
export function parseJourneysFromContent(journeysContent: string): ParsedJourney[] {
|
|
219
|
+
if (!journeysContent) return [];
|
|
220
|
+
|
|
221
|
+
const journeyRegex = /^###?\s+(J-\d{1,4})[:.]?\s*/gm;
|
|
222
|
+
const positions: { id: string; start: number }[] = [];
|
|
223
|
+
|
|
224
|
+
let match: RegExpExecArray | null;
|
|
225
|
+
while ((match = journeyRegex.exec(journeysContent)) !== null) {
|
|
226
|
+
positions.push({ id: normalizeId(match[1]), start: match.index });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const journeys: ParsedJourney[] = positions.map((pos, i) => {
|
|
230
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : journeysContent.length;
|
|
231
|
+
const section = journeysContent.slice(pos.start, end);
|
|
232
|
+
|
|
233
|
+
// Extract name from heading line
|
|
234
|
+
const headingLine = section.split('\n')[0];
|
|
235
|
+
const headingNameMatch = headingLine.match(/^###?\s+J-\d{1,4}:?\s+(.*)/);
|
|
236
|
+
const name = headingNameMatch?.[1]?.trim() ?? '';
|
|
237
|
+
|
|
238
|
+
// Extract type
|
|
239
|
+
const typeMatch = section.match(/\*{0,2}Type:?\*{0,2}:?\s*(\w+)/i);
|
|
240
|
+
const type = typeMatch?.[1]?.toLowerCase() ?? '';
|
|
241
|
+
|
|
242
|
+
// Extract linked FRs
|
|
243
|
+
const frLine = section.match(/\*{0,2}Linked FRs:?\*{0,2}:?\s*(.+)/i);
|
|
244
|
+
const linkedFRs = frLine
|
|
245
|
+
? extractIds(frLine[1], 'FR').map((e) => e.id)
|
|
246
|
+
: [];
|
|
247
|
+
|
|
248
|
+
// Extract linked integrations
|
|
249
|
+
const intLine = section.match(/\*{0,2}Linked Integrations:?\*{0,2}:?\s*(.+)/i);
|
|
250
|
+
const linkedIntegrations =
|
|
251
|
+
intLine && intLine[1].trim().toLowerCase() !== 'none'
|
|
252
|
+
? extractIds(intLine[1], 'I').map((e) => e.id)
|
|
253
|
+
: [];
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
id: pos.id,
|
|
257
|
+
name,
|
|
258
|
+
type,
|
|
259
|
+
linkedFRs,
|
|
260
|
+
linkedIntegrations,
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Sort by numeric ID
|
|
265
|
+
return journeys.sort(
|
|
266
|
+
(a, b) => parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10)
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Parse integration entries from INTEGRATIONS.md content.
|
|
272
|
+
* Extracts id, name, status, fallback.
|
|
273
|
+
* Sorts by numeric ID.
|
|
274
|
+
*/
|
|
275
|
+
export function parseIntegrationsFromContent(intContent: string): ParsedIntegration[] {
|
|
276
|
+
if (!intContent) return [];
|
|
277
|
+
|
|
278
|
+
const intRegex = /^###?\s+(I-\d{1,4})[:.]?\s*/gm;
|
|
279
|
+
const positions: { id: string; start: number }[] = [];
|
|
280
|
+
|
|
281
|
+
let match: RegExpExecArray | null;
|
|
282
|
+
while ((match = intRegex.exec(intContent)) !== null) {
|
|
283
|
+
positions.push({ id: normalizeId(match[1]), start: match.index });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const integrations: ParsedIntegration[] = positions.map((pos, i) => {
|
|
287
|
+
const end = i + 1 < positions.length ? positions[i + 1].start : intContent.length;
|
|
288
|
+
const section = intContent.slice(pos.start, end);
|
|
289
|
+
|
|
290
|
+
// Extract name from heading
|
|
291
|
+
const headingLine = section.split('\n')[0];
|
|
292
|
+
const headingNameMatch = headingLine.match(/^###?\s+I-\d{1,4}:?\s+(.*)/);
|
|
293
|
+
const name = headingNameMatch?.[1]?.trim() ?? '';
|
|
294
|
+
|
|
295
|
+
const statusMatch = section.match(/\*{0,2}Status:?\*{0,2}:?\s*(\w+)/i);
|
|
296
|
+
const fallbackMatch = section.match(/\*{0,2}Fallback:?\*{0,2}:?\s*(.+)/i);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
id: pos.id,
|
|
300
|
+
name,
|
|
301
|
+
status: statusMatch?.[1]?.toLowerCase() ?? 'unknown',
|
|
302
|
+
fallback: fallbackMatch?.[1]?.trim() ?? '',
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
return integrations.sort(
|
|
307
|
+
(a, b) => parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10)
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse a list of bullet/numbered items from a section content string.
|
|
313
|
+
* Handles: "1. Text", "- Text", "* Text" patterns.
|
|
314
|
+
*/
|
|
315
|
+
function parseListItems(content: string): string[] {
|
|
316
|
+
if (!content) return [];
|
|
317
|
+
const lines = content.split('\n');
|
|
318
|
+
const items: string[] = [];
|
|
319
|
+
|
|
320
|
+
for (const line of lines) {
|
|
321
|
+
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
322
|
+
const numberedMatch = line.match(/^\d+[.)]\s+(.+)/);
|
|
323
|
+
if (bulletMatch) {
|
|
324
|
+
items.push(bulletMatch[1].trim());
|
|
325
|
+
} else if (numberedMatch) {
|
|
326
|
+
items.push(numberedMatch[1].trim());
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return items;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Build SpecBundle from file contents.
|
|
335
|
+
*
|
|
336
|
+
* Uses extractHeadingContent() for IMAGINE.md and DECISIONS.md sections.
|
|
337
|
+
* Populates frToJourneys mapping from journey parsing.
|
|
338
|
+
* If statePath provided, reads STATE.json for approvals and projectSlug.
|
|
339
|
+
* If no statePath provided, projectSlug defaults to '' and approvals defaults to {}.
|
|
340
|
+
*/
|
|
341
|
+
export function parseSpecBundle(
|
|
342
|
+
files: {
|
|
343
|
+
imagine: string;
|
|
344
|
+
decisions: string;
|
|
345
|
+
spec: string;
|
|
346
|
+
nfr: string;
|
|
347
|
+
journeys: string;
|
|
348
|
+
integrations: string;
|
|
349
|
+
},
|
|
350
|
+
statePath?: string
|
|
351
|
+
): SpecBundle {
|
|
352
|
+
// Parse IMAGINE.md sections
|
|
353
|
+
const vision = extractHeadingContent(files.imagine, '## Vision') ?? '';
|
|
354
|
+
const targetUser = extractHeadingContent(files.imagine, '## Target User') ?? '';
|
|
355
|
+
const productDirection = extractHeadingContent(files.imagine, '## Product Direction') ?? '';
|
|
356
|
+
const wedge = extractHeadingContent(files.imagine, '## Wedge') ?? '';
|
|
357
|
+
|
|
358
|
+
// Parse DECISIONS.md sections
|
|
359
|
+
const frozenDecisionsContent = extractHeadingContent(files.decisions, '## Frozen Decisions') ?? '';
|
|
360
|
+
const frozenDecisions = parseListItems(frozenDecisionsContent);
|
|
361
|
+
|
|
362
|
+
const successMetricsContent = extractHeadingContent(files.decisions, '## Success Metrics') ?? '';
|
|
363
|
+
const successMetrics = parseListItems(successMetricsContent);
|
|
364
|
+
|
|
365
|
+
const outOfScopeContent = extractHeadingContent(files.decisions, '## Out of Scope') ?? '';
|
|
366
|
+
const outOfScope = parseListItems(outOfScopeContent);
|
|
367
|
+
|
|
368
|
+
const risksContent = extractHeadingContent(files.decisions, '## Risks & Mitigations') ?? '';
|
|
369
|
+
const risks = parseListItems(risksContent);
|
|
370
|
+
|
|
371
|
+
const openQuestionsContent = extractHeadingContent(files.decisions, '## Open Questions') ?? '';
|
|
372
|
+
const openQuestions = parseListItems(openQuestionsContent);
|
|
373
|
+
|
|
374
|
+
// Parse journeys first to build frToJourneys map
|
|
375
|
+
const journeys = parseJourneysFromContent(files.journeys);
|
|
376
|
+
|
|
377
|
+
// Build frToJourneys reverse map
|
|
378
|
+
const frToJourneys = new Map<string, Set<string>>();
|
|
379
|
+
for (const journey of journeys) {
|
|
380
|
+
for (const frId of journey.linkedFRs) {
|
|
381
|
+
if (!frToJourneys.has(frId)) {
|
|
382
|
+
frToJourneys.set(frId, new Set<string>());
|
|
383
|
+
}
|
|
384
|
+
frToJourneys.get(frId)!.add(journey.id);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Parse FRs from SPEC.md (with frToJourneys for sourceJourneys population)
|
|
389
|
+
const frs = parseFRsFromSpec(files.spec, frToJourneys);
|
|
390
|
+
|
|
391
|
+
// Parse roles from SPEC.md
|
|
392
|
+
const roles = extractHeadingContent(files.spec, '## Roles & Permissions') ?? '';
|
|
393
|
+
|
|
394
|
+
// Parse NFRs
|
|
395
|
+
const nfrs = parseNFRsFromContent(files.nfr);
|
|
396
|
+
|
|
397
|
+
// Parse integrations
|
|
398
|
+
const integrations = parseIntegrationsFromContent(files.integrations);
|
|
399
|
+
|
|
400
|
+
// Load state for approvals and projectSlug
|
|
401
|
+
let approvals: Record<string, string> = {};
|
|
402
|
+
let projectSlug = '';
|
|
403
|
+
|
|
404
|
+
if (statePath) {
|
|
405
|
+
const state = readState(statePath);
|
|
406
|
+
if (state) {
|
|
407
|
+
projectSlug = state.project_slug ?? '';
|
|
408
|
+
approvals = {
|
|
409
|
+
auth_model: state.approvals?.auth_model ?? '',
|
|
410
|
+
data_store: state.approvals?.data_store ?? '',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
vision,
|
|
417
|
+
targetUser,
|
|
418
|
+
productDirection,
|
|
419
|
+
wedge,
|
|
420
|
+
frozenDecisions,
|
|
421
|
+
successMetrics,
|
|
422
|
+
outOfScope,
|
|
423
|
+
risks,
|
|
424
|
+
openQuestions,
|
|
425
|
+
approvals,
|
|
426
|
+
frs,
|
|
427
|
+
roles,
|
|
428
|
+
nfrs,
|
|
429
|
+
journeys,
|
|
430
|
+
integrations,
|
|
431
|
+
projectSlug,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ─── Document Generators ──────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Generate PROJECT.md content from SpecBundle.
|
|
439
|
+
*
|
|
440
|
+
* Headings: # projectSlug, ## What This Is, ## Target User, ## Problem Statement,
|
|
441
|
+
* ## Wedge / MVP Boundary, ## Success Metrics (bulleted), ## Out of Scope (bulleted).
|
|
442
|
+
* CRITICAL: No Date, Date.now(), or any time-dependent call.
|
|
443
|
+
* CRITICAL: Ends with exactly one trailing newline.
|
|
444
|
+
*/
|
|
445
|
+
export function generateProjectDoc(bundle: SpecBundle): string {
|
|
446
|
+
const lines: string[] = [];
|
|
447
|
+
|
|
448
|
+
lines.push(`# ${bundle.projectSlug}`);
|
|
449
|
+
lines.push('');
|
|
450
|
+
lines.push('## What This Is');
|
|
451
|
+
lines.push('');
|
|
452
|
+
lines.push(bundle.vision);
|
|
453
|
+
lines.push('');
|
|
454
|
+
lines.push('## Target User');
|
|
455
|
+
lines.push('');
|
|
456
|
+
lines.push(bundle.targetUser);
|
|
457
|
+
lines.push('');
|
|
458
|
+
lines.push('## Problem Statement');
|
|
459
|
+
lines.push('');
|
|
460
|
+
lines.push(bundle.productDirection);
|
|
461
|
+
lines.push('');
|
|
462
|
+
lines.push('## Wedge / MVP Boundary');
|
|
463
|
+
lines.push('');
|
|
464
|
+
lines.push(bundle.wedge);
|
|
465
|
+
lines.push('');
|
|
466
|
+
lines.push('## Success Metrics');
|
|
467
|
+
lines.push('');
|
|
468
|
+
for (const metric of bundle.successMetrics) {
|
|
469
|
+
lines.push(`- ${metric}`);
|
|
470
|
+
}
|
|
471
|
+
lines.push('');
|
|
472
|
+
lines.push('## Out of Scope');
|
|
473
|
+
lines.push('');
|
|
474
|
+
for (const item of bundle.outOfScope) {
|
|
475
|
+
lines.push(`- ${item}`);
|
|
476
|
+
}
|
|
477
|
+
lines.push('');
|
|
478
|
+
|
|
479
|
+
return lines.join('\n');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Generate REQUIREMENTS.md content from SpecBundle.
|
|
484
|
+
*
|
|
485
|
+
* Structure:
|
|
486
|
+
* - # Requirements
|
|
487
|
+
* - ## Functional Requirements
|
|
488
|
+
* - ### v1 (In Scope) — FRs sorted by priority then ID
|
|
489
|
+
* - ### v2 (Future) — FRs sorted by priority then ID
|
|
490
|
+
* - ## Non-Functional Requirements — grouped by NFR_CATEGORY_ORDER
|
|
491
|
+
* - ## Traceability — FR to journey mapping table (v1 FRs only)
|
|
492
|
+
*
|
|
493
|
+
* CRITICAL: No Date, Date.now(), or any time-dependent call.
|
|
494
|
+
* CRITICAL: Ends with exactly one trailing newline.
|
|
495
|
+
*/
|
|
496
|
+
export function generateRequirementsDoc(bundle: SpecBundle): string {
|
|
497
|
+
const lines: string[] = [];
|
|
498
|
+
|
|
499
|
+
lines.push('# Requirements');
|
|
500
|
+
lines.push('');
|
|
501
|
+
lines.push('## Functional Requirements');
|
|
502
|
+
lines.push('');
|
|
503
|
+
|
|
504
|
+
// Sort all FRs deterministically
|
|
505
|
+
const sortedFRs = sortFRs(bundle.frs);
|
|
506
|
+
const v1FRs = sortedFRs.filter((fr) => fr.scope === 'v1');
|
|
507
|
+
const v2FRs = sortedFRs.filter((fr) => fr.scope === 'v2');
|
|
508
|
+
|
|
509
|
+
lines.push('### v1 (In Scope)');
|
|
510
|
+
lines.push('');
|
|
511
|
+
for (const fr of v1FRs) {
|
|
512
|
+
lines.push(`#### ${fr.id}: ${fr.description}`);
|
|
513
|
+
lines.push(`**Scope:** ${fr.scope} **Priority:** ${fr.priority}`);
|
|
514
|
+
lines.push('');
|
|
515
|
+
}
|
|
516
|
+
if (v1FRs.length === 0) {
|
|
517
|
+
lines.push('*(none)*');
|
|
518
|
+
lines.push('');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (v2FRs.length > 0) {
|
|
522
|
+
lines.push('### v2 (Future)');
|
|
523
|
+
lines.push('');
|
|
524
|
+
for (const fr of v2FRs) {
|
|
525
|
+
lines.push(`#### ${fr.id}: ${fr.description}`);
|
|
526
|
+
lines.push(`**Scope:** ${fr.scope} **Priority:** ${fr.priority}`);
|
|
527
|
+
lines.push('');
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// NFRs grouped by category in fixed order
|
|
532
|
+
lines.push('## Non-Functional Requirements');
|
|
533
|
+
lines.push('');
|
|
534
|
+
|
|
535
|
+
for (const category of NFR_CATEGORY_ORDER) {
|
|
536
|
+
const categoryNFRs = bundle.nfrs
|
|
537
|
+
.filter((nfr) => nfr.category === category)
|
|
538
|
+
.sort((a, b) => parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10));
|
|
539
|
+
if (categoryNFRs.length === 0) continue;
|
|
540
|
+
|
|
541
|
+
const categoryTitle = category.charAt(0).toUpperCase() + category.slice(1);
|
|
542
|
+
lines.push(`### ${categoryTitle}`);
|
|
543
|
+
lines.push('');
|
|
544
|
+
for (const nfr of categoryNFRs) {
|
|
545
|
+
lines.push(`#### ${nfr.id}: ${nfr.description}`);
|
|
546
|
+
lines.push(`**Threshold:** ${nfr.threshold}`);
|
|
547
|
+
lines.push('');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Traceability table — v1 FRs only, journey refs sorted
|
|
552
|
+
lines.push('## Traceability');
|
|
553
|
+
lines.push('');
|
|
554
|
+
lines.push('| FR | Description | Journeys |');
|
|
555
|
+
lines.push('|----|-------------|----------|');
|
|
556
|
+
for (const fr of v1FRs) {
|
|
557
|
+
const journeyRefs = [...fr.sourceJourneys].sort().join(', ') || '(none)';
|
|
558
|
+
lines.push(`| ${fr.id} | ${fr.description} | ${journeyRefs} |`);
|
|
559
|
+
}
|
|
560
|
+
lines.push('');
|
|
561
|
+
|
|
562
|
+
return lines.join('\n');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Generate ROADMAP.md content from SpecBundle.
|
|
567
|
+
*
|
|
568
|
+
* Structure:
|
|
569
|
+
* - # Roadmap
|
|
570
|
+
* - ## Overview
|
|
571
|
+
* - ## Phases
|
|
572
|
+
* - ### Phase 1-3: journeys grouped by JOURNEY_TYPE_TO_PHASE, sorted by ID
|
|
573
|
+
* - ### Phase 4: observability+security NFRs sorted by ID
|
|
574
|
+
*
|
|
575
|
+
* CRITICAL: Always emit all 4 phases.
|
|
576
|
+
* CRITICAL: No Date, Date.now(), or any time-dependent call.
|
|
577
|
+
* CRITICAL: Ends with exactly one trailing newline.
|
|
578
|
+
*/
|
|
579
|
+
export function generateRoadmapDoc(bundle: SpecBundle): string {
|
|
580
|
+
const lines: string[] = [];
|
|
581
|
+
|
|
582
|
+
lines.push('# Roadmap');
|
|
583
|
+
lines.push('');
|
|
584
|
+
lines.push('## Overview');
|
|
585
|
+
lines.push('');
|
|
586
|
+
lines.push('v1 journeys assigned to 4 phases following deterministic rules.');
|
|
587
|
+
lines.push('');
|
|
588
|
+
lines.push('## Phases');
|
|
589
|
+
lines.push('');
|
|
590
|
+
|
|
591
|
+
// Group journeys by phase (sorted by ID within each phase)
|
|
592
|
+
const phaseJourneys = new Map<number, ParsedJourney[]>();
|
|
593
|
+
for (let i = 1; i <= 4; i++) {
|
|
594
|
+
phaseJourneys.set(i, []);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Sort journeys by numeric ID before grouping for determinism
|
|
598
|
+
const sortedJourneys = [...bundle.journeys].sort(
|
|
599
|
+
(a, b) => parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10)
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
for (const journey of sortedJourneys) {
|
|
603
|
+
const phase = JOURNEY_TYPE_TO_PHASE[journey.type] ?? 3;
|
|
604
|
+
phaseJourneys.get(phase)!.push(journey);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Always emit all 4 phases
|
|
608
|
+
for (let phaseNum = 1; phaseNum <= 4; phaseNum++) {
|
|
609
|
+
lines.push(`### Phase ${phaseNum}: ${PHASE_NAMES[phaseNum]}`);
|
|
610
|
+
lines.push('');
|
|
611
|
+
|
|
612
|
+
if (phaseNum < 4) {
|
|
613
|
+
const journeys = phaseJourneys.get(phaseNum)!;
|
|
614
|
+
if (journeys.length === 0) {
|
|
615
|
+
lines.push('*(no journeys assigned)*');
|
|
616
|
+
} else {
|
|
617
|
+
for (const j of journeys) {
|
|
618
|
+
lines.push(`- **${j.id}**: ${j.name}`);
|
|
619
|
+
const frRefs = [...j.linkedFRs].sort().join(', ');
|
|
620
|
+
if (frRefs) {
|
|
621
|
+
lines.push(` FRs: ${frRefs}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
// Phase 4: observability + security NFRs sorted by ID
|
|
627
|
+
const obsSecNFRs = bundle.nfrs
|
|
628
|
+
.filter((nfr) => nfr.category === 'observability' || nfr.category === 'security')
|
|
629
|
+
.sort((a, b) => parseInt(a.id.split('-')[1], 10) - parseInt(b.id.split('-')[1], 10));
|
|
630
|
+
|
|
631
|
+
if (obsSecNFRs.length === 0) {
|
|
632
|
+
lines.push('*(no NFRs assigned)*');
|
|
633
|
+
} else {
|
|
634
|
+
for (const nfr of obsSecNFRs) {
|
|
635
|
+
lines.push(`- **${nfr.id}**: ${nfr.description}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
lines.push('');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return lines.join('\n');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Validator Types ──────────────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
export interface ValidatorFinding {
|
|
649
|
+
check: 'v1_coverage' | 'orphan_requirement' | 'integration_sanity' | 'required_headings';
|
|
650
|
+
id: string;
|
|
651
|
+
issue: string;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export interface ValidatorResult {
|
|
655
|
+
passed: boolean;
|
|
656
|
+
findings: ValidatorFinding[];
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ─── Contract Validator ───────────────────────────────────────────────────────
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Validate generated contract docs against the spec bundle.
|
|
663
|
+
*
|
|
664
|
+
* 4 checks:
|
|
665
|
+
* 1. v1_coverage: Every v1 FR ID appears in the generated roadmap content
|
|
666
|
+
* 2. orphan_requirement: Every v1 FR has at least 1 source journey (sourceJourneys.length > 0)
|
|
667
|
+
* 3. integration_sanity: Every integration referenced by any journey is approved or deferred-with-fallback
|
|
668
|
+
* 4. required_headings: All 4 generated docs contain their REQUIRED_HEADINGS entries
|
|
669
|
+
*/
|
|
670
|
+
export function validateContracts(
|
|
671
|
+
generatedDocs: { project: string; requirements: string; roadmap: string; state: string },
|
|
672
|
+
bundle: SpecBundle
|
|
673
|
+
): ValidatorResult {
|
|
674
|
+
const findings: ValidatorFinding[] = [];
|
|
675
|
+
|
|
676
|
+
// Check 1: v1_coverage — all v1 FR IDs appear in roadmap content
|
|
677
|
+
for (const fr of bundle.frs) {
|
|
678
|
+
if (fr.scope !== 'v1') continue;
|
|
679
|
+
if (!generatedDocs.roadmap.includes(fr.id)) {
|
|
680
|
+
findings.push({
|
|
681
|
+
check: 'v1_coverage',
|
|
682
|
+
id: fr.id,
|
|
683
|
+
issue: `v1 FR ${fr.id} is not referenced in the generated roadmap`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Check 2: orphan_requirement — all v1 FRs have at least 1 source journey
|
|
689
|
+
for (const fr of bundle.frs) {
|
|
690
|
+
if (fr.scope !== 'v1') continue;
|
|
691
|
+
if (fr.sourceJourneys.length === 0) {
|
|
692
|
+
findings.push({
|
|
693
|
+
check: 'orphan_requirement',
|
|
694
|
+
id: fr.id,
|
|
695
|
+
issue: `v1 FR ${fr.id} is not referenced by any journey`,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Check 3: integration_sanity — integrations referenced by journeys must be approved or deferred-with-fallback
|
|
701
|
+
// Build set of integration IDs referenced by journeys
|
|
702
|
+
const referencedIntegrationIds = new Set<string>();
|
|
703
|
+
for (const journey of bundle.journeys) {
|
|
704
|
+
for (const intId of journey.linkedIntegrations) {
|
|
705
|
+
referencedIntegrationIds.add(intId);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
for (const integration of bundle.integrations) {
|
|
710
|
+
if (!referencedIntegrationIds.has(integration.id)) continue;
|
|
711
|
+
|
|
712
|
+
const isApproved = integration.status === 'approved';
|
|
713
|
+
const isDeferredWithFallback = integration.status === 'deferred' && integration.fallback.trim().length > 0;
|
|
714
|
+
|
|
715
|
+
if (!isApproved && !isDeferredWithFallback) {
|
|
716
|
+
findings.push({
|
|
717
|
+
check: 'integration_sanity',
|
|
718
|
+
id: integration.id,
|
|
719
|
+
issue: `Integration ${integration.id} (status: ${integration.status}) is referenced by a journey but is not approved or deferred-with-fallback`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Check 4: required_headings — validate each generated doc with its file type key
|
|
725
|
+
const docMap: Array<[string, string]> = [
|
|
726
|
+
['PROJECT.md', generatedDocs.project],
|
|
727
|
+
['REQUIREMENTS.md', generatedDocs.requirements],
|
|
728
|
+
['ROADMAP.md', generatedDocs.roadmap],
|
|
729
|
+
['STATE.md', generatedDocs.state],
|
|
730
|
+
];
|
|
731
|
+
|
|
732
|
+
for (const [fileType, content] of docMap) {
|
|
733
|
+
const validation = validateHeadings(content, fileType);
|
|
734
|
+
for (const missing of validation.missing) {
|
|
735
|
+
findings.push({
|
|
736
|
+
check: 'required_headings',
|
|
737
|
+
id: fileType,
|
|
738
|
+
issue: `Generated ${fileType} is missing required heading: ${missing}`,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return { passed: findings.length === 0, findings };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Generate STATE.md content from SpecBundle.
|
|
748
|
+
*
|
|
749
|
+
* Structure:
|
|
750
|
+
* - # Project State
|
|
751
|
+
* - ## Frozen Decisions (numbered list)
|
|
752
|
+
* - ## Approvals (key-value list from bundle.approvals)
|
|
753
|
+
* - ## Open Questions (bulleted)
|
|
754
|
+
* - ## Risks (bulleted)
|
|
755
|
+
*
|
|
756
|
+
* CRITICAL: No Date, Date.now(), or any time-dependent call.
|
|
757
|
+
* CRITICAL: Ends with exactly one trailing newline.
|
|
758
|
+
*/
|
|
759
|
+
export function generateStateDoc(bundle: SpecBundle): string {
|
|
760
|
+
const lines: string[] = [];
|
|
761
|
+
|
|
762
|
+
lines.push('# Project State');
|
|
763
|
+
lines.push('');
|
|
764
|
+
lines.push('## Frozen Decisions');
|
|
765
|
+
lines.push('');
|
|
766
|
+
for (let i = 0; i < bundle.frozenDecisions.length; i++) {
|
|
767
|
+
lines.push(`${i + 1}. ${bundle.frozenDecisions[i]}`);
|
|
768
|
+
}
|
|
769
|
+
if (bundle.frozenDecisions.length === 0) {
|
|
770
|
+
lines.push('*(none)*');
|
|
771
|
+
}
|
|
772
|
+
lines.push('');
|
|
773
|
+
|
|
774
|
+
lines.push('## Approvals');
|
|
775
|
+
lines.push('');
|
|
776
|
+
const approvalKeys = Object.keys(bundle.approvals).sort();
|
|
777
|
+
for (const key of approvalKeys) {
|
|
778
|
+
lines.push(`- **${key}:** ${bundle.approvals[key]}`);
|
|
779
|
+
}
|
|
780
|
+
if (approvalKeys.length === 0) {
|
|
781
|
+
lines.push('*(none)*');
|
|
782
|
+
}
|
|
783
|
+
lines.push('');
|
|
784
|
+
|
|
785
|
+
lines.push('## Open Questions');
|
|
786
|
+
lines.push('');
|
|
787
|
+
for (const question of bundle.openQuestions) {
|
|
788
|
+
lines.push(`- ${question}`);
|
|
789
|
+
}
|
|
790
|
+
if (bundle.openQuestions.length === 0) {
|
|
791
|
+
lines.push('*(none)*');
|
|
792
|
+
}
|
|
793
|
+
lines.push('');
|
|
794
|
+
|
|
795
|
+
lines.push('## Risks');
|
|
796
|
+
lines.push('');
|
|
797
|
+
for (const risk of bundle.risks) {
|
|
798
|
+
lines.push(`- ${risk}`);
|
|
799
|
+
}
|
|
800
|
+
if (bundle.risks.length === 0) {
|
|
801
|
+
lines.push('*(none)*');
|
|
802
|
+
}
|
|
803
|
+
lines.push('');
|
|
804
|
+
|
|
805
|
+
return lines.join('\n');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// ─── Spec Artifact File Reader ────────────────────────────────────────────────
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Read all 6 spec artifact files from a planning directory.
|
|
812
|
+
* Returns a content map keyed by artifact type.
|
|
813
|
+
* Missing files return empty string (does not throw).
|
|
814
|
+
*/
|
|
815
|
+
export function readSpecArtifacts(planningDir: string): Record<string, string> {
|
|
816
|
+
const fileMap: Array<[string, string]> = [
|
|
817
|
+
['IMAGINE.md', 'imagine'],
|
|
818
|
+
['DECISIONS.md', 'decisions'],
|
|
819
|
+
['SPEC.md', 'spec'],
|
|
820
|
+
['NFR.md', 'nfr'],
|
|
821
|
+
['JOURNEYS.md', 'journeys'],
|
|
822
|
+
['INTEGRATIONS.md', 'integrations'],
|
|
823
|
+
];
|
|
824
|
+
|
|
825
|
+
const result: Record<string, string> = {};
|
|
826
|
+
for (const [filename, key] of fileMap) {
|
|
827
|
+
try {
|
|
828
|
+
result[key] = fs.readFileSync(path.join(planningDir, filename), 'utf-8');
|
|
829
|
+
} catch {
|
|
830
|
+
result[key] = '';
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return result;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ─── Workflow Result Type ─────────────────────────────────────────────────────
|
|
838
|
+
|
|
839
|
+
export interface CompileWorkflowResult {
|
|
840
|
+
passed: boolean;
|
|
841
|
+
error?: string;
|
|
842
|
+
validatorResult?: ValidatorResult;
|
|
843
|
+
filesWritten: string[];
|
|
844
|
+
generated?: { project: string; requirements: string; roadmap: string; state: string };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ─── Workflow Orchestrator ────────────────────────────────────────────────────
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Full compile workflow orchestrator.
|
|
851
|
+
*
|
|
852
|
+
* Steps:
|
|
853
|
+
* 1. Check audit gate via checkAuditGate(). If false, return early with error.
|
|
854
|
+
* 2. Update stage status: compile -> in_progress.
|
|
855
|
+
* 3. Read spec artifacts from planningDir.
|
|
856
|
+
* 4. Parse into SpecBundle.
|
|
857
|
+
* 5. Generate all 4 contract docs (pure functions, no I/O).
|
|
858
|
+
* 6. Run validator: validateContracts().
|
|
859
|
+
* 7. If validator FAIL: update state -> fail. Return without writing files.
|
|
860
|
+
* 8. Write all 4 docs atomically via safeWriteFile().
|
|
861
|
+
* 9. Update state: stage_status.compile = 'done', stage = 'compile'.
|
|
862
|
+
* 10. Write checkpoint.
|
|
863
|
+
* 11. Return result with passed=true and filesWritten list.
|
|
864
|
+
*/
|
|
865
|
+
export function runCompileWorkflow(options: {
|
|
866
|
+
planningDir: string;
|
|
867
|
+
statePath: string;
|
|
868
|
+
}): CompileWorkflowResult {
|
|
869
|
+
const { planningDir, statePath } = options;
|
|
870
|
+
|
|
871
|
+
// Step 1: Check audit gate
|
|
872
|
+
if (!checkAuditGate(statePath)) {
|
|
873
|
+
return { passed: false, error: 'Audit gate not passed', filesWritten: [] };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Step 2: Update stage status -> in_progress
|
|
877
|
+
updateStageStatus(statePath, 'compile', 'in_progress');
|
|
878
|
+
|
|
879
|
+
// Step 3: Read spec artifacts
|
|
880
|
+
const files = readSpecArtifacts(planningDir);
|
|
881
|
+
|
|
882
|
+
// Step 4: Parse into SpecBundle
|
|
883
|
+
const bundle = parseSpecBundle(
|
|
884
|
+
{
|
|
885
|
+
imagine: files['imagine'] ?? '',
|
|
886
|
+
decisions: files['decisions'] ?? '',
|
|
887
|
+
spec: files['spec'] ?? '',
|
|
888
|
+
nfr: files['nfr'] ?? '',
|
|
889
|
+
journeys: files['journeys'] ?? '',
|
|
890
|
+
integrations: files['integrations'] ?? '',
|
|
891
|
+
},
|
|
892
|
+
statePath
|
|
893
|
+
);
|
|
894
|
+
|
|
895
|
+
// Step 5: Generate all 4 docs (pure functions)
|
|
896
|
+
const generated = {
|
|
897
|
+
project: generateProjectDoc(bundle),
|
|
898
|
+
requirements: generateRequirementsDoc(bundle),
|
|
899
|
+
roadmap: generateRoadmapDoc(bundle),
|
|
900
|
+
state: generateStateDoc(bundle),
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
// Step 6: Run validator
|
|
904
|
+
const validatorResult = validateContracts(generated, bundle);
|
|
905
|
+
|
|
906
|
+
// Step 7: If validator FAIL, update state -> fail and return early
|
|
907
|
+
if (!validatorResult.passed) {
|
|
908
|
+
updateStageStatus(statePath, 'compile', 'fail');
|
|
909
|
+
return { passed: false, validatorResult, filesWritten: [] };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Step 8: Write all 4 docs atomically
|
|
913
|
+
const fileOutputMap: Array<[string, string]> = [
|
|
914
|
+
['PROJECT.md', generated.project],
|
|
915
|
+
['REQUIREMENTS.md', generated.requirements],
|
|
916
|
+
['ROADMAP.md', generated.roadmap],
|
|
917
|
+
['STATE.md', generated.state],
|
|
918
|
+
];
|
|
919
|
+
|
|
920
|
+
const filesWritten: string[] = [];
|
|
921
|
+
for (const [filename, content] of fileOutputMap) {
|
|
922
|
+
const filePath = path.join(planningDir, filename);
|
|
923
|
+
safeWriteFile(filePath, content);
|
|
924
|
+
filesWritten.push(filePath);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Step 9: Update state: stage_status.compile = 'done', stage = 'compile'
|
|
928
|
+
updateStageStatus(statePath, 'compile', 'done');
|
|
929
|
+
const state = readState(statePath);
|
|
930
|
+
if (state) {
|
|
931
|
+
state.stage = 'compile';
|
|
932
|
+
writeState(statePath, state);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Step 10: Write checkpoint
|
|
936
|
+
writeCheckpoint(statePath, 'gswd/compile', 'compile-done');
|
|
937
|
+
|
|
938
|
+
// Step 11: Return result
|
|
939
|
+
return { passed: true, validatorResult, filesWritten, generated };
|
|
940
|
+
}
|