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.
Files changed (42) hide show
  1. package/agents/gswd/architecture-drafter.md +70 -0
  2. package/agents/gswd/brainstorm-alternatives.md +60 -0
  3. package/agents/gswd/devils-advocate.md +57 -0
  4. package/agents/gswd/icp-persona.md +58 -0
  5. package/agents/gswd/integrations-checker.md +68 -0
  6. package/agents/gswd/journey-mapper.md +69 -0
  7. package/agents/gswd/market-researcher.md +54 -0
  8. package/agents/gswd/positioning.md +54 -0
  9. package/bin/gswd-tools.cjs +716 -0
  10. package/lib/audit.ts +959 -0
  11. package/lib/bootstrap.ts +617 -0
  12. package/lib/compile.ts +940 -0
  13. package/lib/config.ts +164 -0
  14. package/lib/imagine-agents.ts +154 -0
  15. package/lib/imagine-gate.ts +156 -0
  16. package/lib/imagine-input.ts +242 -0
  17. package/lib/imagine-synthesis.ts +402 -0
  18. package/lib/imagine.ts +433 -0
  19. package/lib/parse.ts +196 -0
  20. package/lib/render.ts +200 -0
  21. package/lib/specify-agents.ts +332 -0
  22. package/lib/specify-journeys.ts +410 -0
  23. package/lib/specify-nfr.ts +208 -0
  24. package/lib/specify-roles.ts +122 -0
  25. package/lib/specify.ts +773 -0
  26. package/lib/state.ts +305 -0
  27. package/package.json +26 -0
  28. package/templates/gswd/ARCHITECTURE.template.md +17 -0
  29. package/templates/gswd/AUDIT.template.md +31 -0
  30. package/templates/gswd/COMPETITION.template.md +18 -0
  31. package/templates/gswd/DECISIONS.template.md +18 -0
  32. package/templates/gswd/GTM.template.md +18 -0
  33. package/templates/gswd/ICP.template.md +18 -0
  34. package/templates/gswd/IMAGINE.template.md +24 -0
  35. package/templates/gswd/INTEGRATIONS.template.md +7 -0
  36. package/templates/gswd/JOURNEYS.template.md +7 -0
  37. package/templates/gswd/NFR.template.md +7 -0
  38. package/templates/gswd/PROJECT.template.md +21 -0
  39. package/templates/gswd/REQUIREMENTS.template.md +31 -0
  40. package/templates/gswd/ROADMAP.template.md +21 -0
  41. package/templates/gswd/SPEC.template.md +19 -0
  42. package/templates/gswd/STATE.template.md +15 -0
package/lib/imagine.ts ADDED
@@ -0,0 +1,433 @@
1
+ /**
2
+ * GSWD Imagine Workflow Orchestrator
3
+ *
4
+ * Full pipeline: input -> agents -> synthesis -> gate -> artifacts -> state
5
+ * Implements GSWD_SPEC Section 8.2 end-to-end.
6
+ *
7
+ * Both interactive and auto modes are supported:
8
+ * - Interactive: presents direction checkpoint, awaits selection
9
+ * - Auto: scores directions via pain x WTP x reachability, selects highest
10
+ *
11
+ * Schema: GSWD_SPEC.md Section 8.2
12
+ */
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+
17
+ import { safeWriteFile, readState, writeState, writeCheckpoint, allocateIdRange } from './state.js';
18
+ import { getGswdConfig } from './config.js';
19
+ import { parseIdeaFile, buildFromIntake, validateBrief } from './imagine-input.js';
20
+ import type { StarterBrief, IntakeAnswers } from './imagine-input.js';
21
+ import { IMAGINE_AGENTS, orchestrateAgents } from './imagine-agents.js';
22
+ import type { AgentResult, SpawnFn } from './imagine-agents.js';
23
+ import { synthesizeDirections, autoSelectDirection, scoreIcpOptions } from './imagine-synthesis.js';
24
+ import type { Direction, AutoDecision, SynthesisResult } from './imagine-synthesis.js';
25
+ import { validateDecisionGate } from './imagine-gate.js';
26
+ import type { GateResult } from './imagine-gate.js';
27
+
28
+ // ─── Types ───────────────────────────────────────────────────────────────────
29
+
30
+ export interface ImagineOptions {
31
+ ideaFilePath?: string; // Path to @idea.md (optional)
32
+ intakeAnswers?: IntakeAnswers; // 3-question answers (optional)
33
+ autoMode?: boolean; // Use auto policy for decisions
34
+ skipResearch?: boolean; // Skip agent spawning
35
+ planningDir?: string; // Override .planning/ path (for testing)
36
+ configPath?: string; // Override config.json path (for testing)
37
+ spawnFn?: SpawnFn; // Task() wrapper (injectable for testing)
38
+ selectedDirectionIndex?: number; // Manual direction selection (0-based)
39
+ }
40
+
41
+ export interface ImagineResult {
42
+ status: 'complete' | 'gate_failed' | 'error';
43
+ artifacts_written: string[];
44
+ gate_result?: GateResult;
45
+ auto_decisions?: AutoDecision[];
46
+ selected_direction?: Direction;
47
+ error?: string;
48
+ }
49
+
50
+ // ─── Artifact Building ──────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Build IMAGINE.md content from selected direction and synthesis data.
54
+ */
55
+ function buildImagineContent(
56
+ brief: StarterBrief,
57
+ selected: Direction,
58
+ synthesis: SynthesisResult,
59
+ autoDecisions?: AutoDecision[],
60
+ ): string {
61
+ const alternatives = synthesis.alternatives
62
+ .map((alt, i) => `### Alternative ${i + 1}: ${alt.label}\n- **ICP:** ${alt.icp_summary}\n- **Problem:** ${alt.problem_framing}\n- **Wedge:** ${alt.wedge}\n- **Differentiator:** ${alt.differentiator}`)
63
+ .join('\n\n');
64
+
65
+ const metrics = autoDecisions
66
+ ? autoDecisions.filter(d => d.type === 'metric').map(d => `- ${d.chosen}`).join('\n')
67
+ : '- To be defined during decision gate';
68
+
69
+ return `# Imagine
70
+
71
+ ## Vision
72
+ ${brief.vision}
73
+
74
+ ## Target User
75
+ ${brief.target_user}
76
+
77
+ ## Problem Statement
78
+ ${selected.problem_framing}
79
+
80
+ ## Product Direction
81
+ ### ${selected.label}
82
+ - **ICP:** ${selected.icp_summary}
83
+ - **Problem:** ${selected.problem_framing}
84
+ - **Wedge:** ${selected.wedge}
85
+ - **Differentiator:** ${selected.differentiator}
86
+ - **Risks:** ${selected.risks.join('; ')}
87
+
88
+ ## Alternatives
89
+ ${alternatives}
90
+
91
+ ## Wedge / MVP Boundary
92
+ ${selected.wedge}
93
+
94
+ ## Success Metrics
95
+ ${metrics}
96
+ `;
97
+ }
98
+
99
+ /**
100
+ * Sanitize a value for use in a single-line markdown bullet.
101
+ * Collapses newlines, strips heading markers, and trims to prevent
102
+ * multi-line content from breaking section boundaries in DECISIONS.md.
103
+ */
104
+ function sanitizeBulletValue(value: string): string {
105
+ return value
106
+ .replace(/\n+/g, ' ') // collapse newlines to spaces
107
+ .replace(/#+\s*/g, '') // strip markdown heading markers
108
+ .replace(/\s{2,}/g, ' ') // collapse multiple spaces
109
+ .trim();
110
+ }
111
+
112
+ /**
113
+ * Build DECISIONS.md content from selected direction, auto decisions, and risks.
114
+ */
115
+ function buildDecisionsContent(
116
+ selected: Direction,
117
+ synthesis: SynthesisResult,
118
+ brief: StarterBrief,
119
+ autoDecisions?: AutoDecision[],
120
+ ): string {
121
+ // Build frozen decisions (need >= 8)
122
+ // Sanitize all interpolated values to prevent multi-line content from breaking section structure
123
+ const decisionPrefix = autoDecisions ? 'Auto-chosen: ' : '';
124
+ const frozenDecisions = [
125
+ `- ${decisionPrefix}**ICP:** ${sanitizeBulletValue(selected.icp_summary)}`,
126
+ `- ${decisionPrefix}**Problem Statement:** ${sanitizeBulletValue(selected.problem_framing)}`,
127
+ `- ${decisionPrefix}**Product Direction:** ${sanitizeBulletValue(selected.label)}`,
128
+ `- ${decisionPrefix}**Wedge / Entry Point:** ${sanitizeBulletValue(selected.wedge)}`,
129
+ `- ${decisionPrefix}**Differentiator:** ${sanitizeBulletValue(selected.differentiator)}`,
130
+ `- ${decisionPrefix}**Target User:** ${sanitizeBulletValue(brief.target_user)}`,
131
+ `- ${decisionPrefix}**Timing Rationale:** ${sanitizeBulletValue(brief.why_now)}`,
132
+ `- ${decisionPrefix}**MVP Boundary:** ${sanitizeBulletValue(selected.wedge)} (full product expansion deferred to post-validation)`,
133
+ ];
134
+
135
+ // Success metrics
136
+ const metricDecision = autoDecisions?.find(d => d.type === 'metric');
137
+ const metricsItems = metricDecision
138
+ ? metricDecision.chosen.split(',').map(m => `- ${m.trim()}`)
139
+ : ['- Activation rate', '- Week-1 retention'];
140
+
141
+ // Out of scope
142
+ const outOfScope = [
143
+ '- Features beyond MVP wedge scope',
144
+ '- Paid integrations (unless pre-approved)',
145
+ '- Multi-user / team collaboration (v1 is single-user)',
146
+ ];
147
+
148
+ // Risks from devils-advocate agent and direction risks
149
+ const risksContent = synthesis.raw_agent_outputs['devils-advocate'] || '';
150
+ const riskLines = extractRiskItems(risksContent, selected.risks);
151
+
152
+ // Open questions
153
+ const openQuestions = synthesis.agent_warnings.length > 0
154
+ ? synthesis.agent_warnings.map(w => `- ${w}`)
155
+ : ['- None at this time'];
156
+
157
+ return `# Decisions
158
+
159
+ ## Frozen Decisions
160
+ ${frozenDecisions.join('\n')}
161
+
162
+ ## Success Metrics
163
+ ${metricsItems.join('\n')}
164
+
165
+ ## Out of Scope
166
+ ${outOfScope.join('\n')}
167
+
168
+ ## Risks & Mitigations
169
+ ${riskLines.join('\n')}
170
+
171
+ ## Open Questions
172
+ ${openQuestions.join('\n')}
173
+ `;
174
+ }
175
+
176
+ /**
177
+ * Extract risk items from devils-advocate output, ensuring >= 5 items.
178
+ */
179
+ function extractRiskItems(risksContent: string, directionRisks: string[]): string[] {
180
+ const items: string[] = [];
181
+
182
+ if (risksContent) {
183
+ // Extract from ## Risks section
184
+ const risksMatch = risksContent.match(/##\s*Risks\s*\n([\s\S]*?)(?=\n##|$)/);
185
+ if (risksMatch) {
186
+ const lines = risksMatch[1].split('\n')
187
+ .filter(l => l.trim().startsWith('-'))
188
+ .map(l => l.trim())
189
+ .slice(0, 8);
190
+ items.push(...lines);
191
+ }
192
+
193
+ // Extract from ## Mitigations section
194
+ const mitigationsMatch = risksContent.match(/##\s*Mitigations\s*\n([\s\S]*?)(?=\n##|$)/);
195
+ if (mitigationsMatch && items.length < 5) {
196
+ const lines = mitigationsMatch[1].split('\n')
197
+ .filter(l => l.trim().startsWith('-'))
198
+ .map(l => l.trim())
199
+ .slice(0, 5 - items.length);
200
+ items.push(...lines);
201
+ }
202
+ }
203
+
204
+ // Add direction risks if we need more
205
+ for (const risk of directionRisks) {
206
+ if (items.length >= 5) break;
207
+ items.push(`- **Direction risk:** ${risk}`);
208
+ }
209
+
210
+ // Ensure minimum 5
211
+ while (items.length < 5) {
212
+ items.push(`- Risk ${items.length + 1}: To be identified during Specify stage`);
213
+ }
214
+
215
+ return items;
216
+ }
217
+
218
+ /**
219
+ * Build artifact content from agent raw output, falling back to a message.
220
+ */
221
+ function buildAgentArtifact(agentName: string, rawOutputs: Record<string, string>, fallback: string): string {
222
+ const content = rawOutputs[agentName];
223
+ return content || fallback;
224
+ }
225
+
226
+ // ─── Main Workflow ──────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Run the Imagine workflow end-to-end.
230
+ *
231
+ * Implements GSWD_SPEC Section 8.2 Steps 1-6:
232
+ * 1. Load state and config
233
+ * 2. Collect founder input (file or intake)
234
+ * 3. Spawn parallel agents
235
+ * 4. Synthesize into proposed direction + 2 alternatives
236
+ * 5. Decision gate (must freeze)
237
+ * 6. Write docs and update state
238
+ */
239
+ export async function runImagine(options: ImagineOptions): Promise<ImagineResult> {
240
+ const planningDir = options.planningDir || path.join(process.cwd(), '.planning');
241
+ const gswdDir = path.join(planningDir, 'gswd');
242
+ const statePath = path.join(gswdDir, 'STATE.json');
243
+ const configPath = options.configPath || path.join(planningDir, 'config.json');
244
+
245
+ try {
246
+ // ── Step 1: Load state and config ──────────────────────────────────
247
+ const state = readState(statePath);
248
+ if (!state) {
249
+ return { status: 'error', artifacts_written: [], error: 'No STATE.json found. Run init first.' };
250
+ }
251
+
252
+ const config = getGswdConfig(configPath);
253
+
254
+ // Mark imagine as in_progress
255
+ state.stage = 'imagine';
256
+ state.stage_status.imagine = 'in_progress';
257
+ writeState(statePath, state);
258
+
259
+ // ── Step 2: Collect input ──────────────────────────────────────────
260
+ let brief: StarterBrief;
261
+
262
+ if (options.ideaFilePath) {
263
+ brief = parseIdeaFile(options.ideaFilePath);
264
+ } else if (options.intakeAnswers) {
265
+ brief = buildFromIntake(options.intakeAnswers);
266
+ } else {
267
+ return {
268
+ status: 'error',
269
+ artifacts_written: [],
270
+ error: 'No input: provide ideaFilePath or intakeAnswers',
271
+ };
272
+ }
273
+
274
+ const validation = validateBrief(brief);
275
+ if (!validation.valid) {
276
+ return {
277
+ status: 'error',
278
+ artifacts_written: [],
279
+ error: `Invalid brief: missing fields [${validation.missing.join(', ')}]`,
280
+ };
281
+ }
282
+
283
+ // ── Step 3: Spawn agents ──────────────────────────────────────────
284
+ let agentResults: AgentResult[];
285
+
286
+ if (options.skipResearch) {
287
+ // Create minimal results from brief content
288
+ agentResults = IMAGINE_AGENTS.map(agent => ({
289
+ agent: agent.name,
290
+ content: `## ${agent.requiredHeadings[0]?.replace('## ', '') || 'Output'}\nGenerated from starter brief.\n\n${brief.vision}\n${brief.target_user}\n${brief.why_now}`,
291
+ status: 'complete' as const,
292
+ duration_ms: 0,
293
+ }));
294
+ } else {
295
+ const spawnFn = options.spawnFn;
296
+ if (!spawnFn) {
297
+ return {
298
+ status: 'error',
299
+ artifacts_written: [],
300
+ error: 'No spawnFn provided for agent orchestration',
301
+ };
302
+ }
303
+ // Allocate ID ranges for imagine agents before spawning (FNDN-05)
304
+ if (spawnFn) {
305
+ try {
306
+ allocateIdRange(statePath, 'J', 'journey-mapper', 50);
307
+ allocateIdRange(statePath, 'FR', 'market-researcher', 50);
308
+ } catch {
309
+ // Non-fatal — ID allocation failure should not block imagine
310
+ }
311
+ }
312
+ agentResults = await orchestrateAgents(
313
+ IMAGINE_AGENTS,
314
+ config.max_parallel_agents,
315
+ brief,
316
+ spawnFn,
317
+ );
318
+ }
319
+
320
+ // Mid-stage checkpoint: agents complete (RESM-02)
321
+ writeCheckpoint(statePath, 'gswd/imagine', 'agents-complete');
322
+
323
+ // ── Step 4: Synthesize directions ─────────────────────────────────
324
+ const synthesis = synthesizeDirections(agentResults);
325
+
326
+ // ── Step 5: Direction selection ───────────────────────────────────
327
+ let selected: Direction;
328
+ let autoDecisions: AutoDecision[] | undefined;
329
+
330
+ if (options.autoMode) {
331
+ const autoResult = autoSelectDirection(synthesis);
332
+ selected = autoResult.selected;
333
+ autoDecisions = autoResult.decisions;
334
+ } else if (options.selectedDirectionIndex !== undefined) {
335
+ const allDirections = [synthesis.proposed, ...synthesis.alternatives];
336
+ const idx = Math.max(0, Math.min(options.selectedDirectionIndex, allDirections.length - 1));
337
+ selected = allDirections[idx];
338
+ } else {
339
+ // Default to proposed direction
340
+ selected = synthesis.proposed;
341
+ }
342
+
343
+ // Mid-stage checkpoint: direction selected (RESM-02)
344
+ writeCheckpoint(statePath, 'gswd/imagine', 'direction-selected');
345
+
346
+ // ── Step 6: Build artifacts ──────────────────────────────────────
347
+ const imagineContent = buildImagineContent(brief, selected, synthesis, autoDecisions);
348
+ const decisionsContent = buildDecisionsContent(selected, synthesis, brief, autoDecisions);
349
+
350
+ const icpContent = buildAgentArtifact(
351
+ 'icp-persona',
352
+ synthesis.raw_agent_outputs,
353
+ `# Ideal Customer Profile\n\n## ICP Profile\n${selected.icp_summary}\n\n## Pain Points\nDerived from direction analysis.\n`,
354
+ );
355
+
356
+ const gtmContent = buildAgentArtifact(
357
+ 'positioning',
358
+ synthesis.raw_agent_outputs,
359
+ `# Go-to-Market\n\n## Value Proposition\n${selected.differentiator}\n\n## Go-to-Market Angle\nTo be defined in Specify stage.\n`,
360
+ );
361
+
362
+ const competitionContent = buildAgentArtifact(
363
+ 'market-researcher',
364
+ synthesis.raw_agent_outputs,
365
+ `# Competition\n\n## Market Overview\nMarket research data unavailable.\n\n## Competitors\nTo be researched.\n`,
366
+ );
367
+
368
+ // ── Step 7: Validate decision gate ───────────────────────────────
369
+ const gateResult = validateDecisionGate(decisionsContent);
370
+
371
+ if (!gateResult.passed) {
372
+ // Do NOT update state to done
373
+ return {
374
+ status: 'gate_failed',
375
+ artifacts_written: [],
376
+ gate_result: gateResult,
377
+ selected_direction: selected,
378
+ error: `Decision gate failed: ${gateResult.summary}`,
379
+ };
380
+ }
381
+
382
+ // ── Step 8: Write artifacts ──────────────────────────────────────
383
+ const artifacts: { name: string; content: string }[] = [
384
+ { name: 'IMAGINE.md', content: imagineContent },
385
+ { name: 'ICP.md', content: icpContent },
386
+ { name: 'GTM.md', content: gtmContent },
387
+ { name: 'COMPETITION.md', content: competitionContent },
388
+ { name: 'DECISIONS.md', content: decisionsContent },
389
+ ];
390
+
391
+ const artifactsWritten: string[] = [];
392
+
393
+ for (const artifact of artifacts) {
394
+ const artifactPath = path.join(planningDir, artifact.name);
395
+ safeWriteFile(artifactPath, artifact.content);
396
+ artifactsWritten.push(artifactPath);
397
+ }
398
+
399
+ // ── Step 9: Update state ─────────────────────────────────────────
400
+ const finalState = readState(statePath);
401
+ if (finalState) {
402
+ finalState.stage_status.imagine = 'done';
403
+ finalState.last_checkpoint = {
404
+ workflow: 'gswd/imagine',
405
+ checkpoint_id: 'complete',
406
+ timestamp: new Date().toISOString(),
407
+ };
408
+
409
+ // Record auto decisions in state
410
+ if (autoDecisions) {
411
+ (finalState.auto as Record<string, unknown>).decisions = autoDecisions;
412
+ }
413
+
414
+ writeState(statePath, finalState);
415
+ }
416
+
417
+ // ── Step 10: Return result ───────────────────────────────────────
418
+ return {
419
+ status: 'complete',
420
+ artifacts_written: artifactsWritten,
421
+ gate_result: gateResult,
422
+ auto_decisions: autoDecisions,
423
+ selected_direction: selected,
424
+ };
425
+ } catch (err: unknown) {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ return {
428
+ status: 'error',
429
+ artifacts_written: [],
430
+ error: message,
431
+ };
432
+ }
433
+ }
package/lib/parse.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * GSWD Parse Module — ID extraction, heading validation, normalization
3
+ *
4
+ * Parses GSWD artifact files for IDs (J-NNN, FR-NNN, NFR-NNN, I-NNN, C-NNN),
5
+ * validates required heading structure, and normalizes malformed IDs.
6
+ *
7
+ * Schema: GSWD_SPEC.md Section 6.1-6.3
8
+ */
9
+
10
+ // ─── Required Headings ──────────────────────────────────────────────────────
11
+
12
+ /**
13
+ * Required headings per artifact file type, from GSWD_SPEC Section 6.3.
14
+ * These are the stable anchors used by audit/compile parsers.
15
+ */
16
+ export const REQUIRED_HEADINGS: Record<string, string[]> = {
17
+ 'JOURNEYS.md': ['## Journeys'],
18
+ 'SPEC.md': ['## Roles & Permissions', '## Functional Requirements', '## Acceptance Criteria'],
19
+ 'NFR.md': ['## Non-Functional Requirements'],
20
+ 'INTEGRATIONS.md': ['## Integrations'],
21
+ 'ARCHITECTURE.md': ['## Architecture', '### Components', '### Data Model'],
22
+ 'DECISIONS.md': [
23
+ '## Frozen Decisions',
24
+ '## Success Metrics',
25
+ '## Out of Scope',
26
+ '## Risks & Mitigations',
27
+ '## Open Questions',
28
+ ],
29
+ 'AUDIT.md': ['## Coverage Matrix', '## Check Results'],
30
+ 'PROJECT.md': ['## What This Is', '## Target User', '## Problem Statement', '## Wedge / MVP Boundary', '## Success Metrics', '## Out of Scope'],
31
+ 'REQUIREMENTS.md': ['## Functional Requirements', '## Non-Functional Requirements', '## Traceability'],
32
+ 'ROADMAP.md': ['## Overview', '## Phases'],
33
+ 'STATE.md': ['## Frozen Decisions', '## Approvals', '## Open Questions', '## Risks'],
34
+ };
35
+
36
+ // ─── ID Normalization ────────────────────────────────────────────────────────
37
+
38
+ /** Valid ID prefixes */
39
+ const ID_PREFIXES = ['J', 'FR', 'NFR', 'I', 'C'] as const;
40
+
41
+ /**
42
+ * Normalize an ID to canonical format: PREFIX-NNN (3-digit minimum, zero-padded).
43
+ *
44
+ * - FR-1 -> FR-001
45
+ * - FR-01 -> FR-001
46
+ * - FR-001 -> FR-001 (no change)
47
+ * - FR-1000 -> FR-1000 (4+ digits kept as-is)
48
+ * - INVALID -> INVALID (unrecognized format returned as-is)
49
+ */
50
+ export function normalizeId(rawId: string): string {
51
+ if (!rawId) return rawId;
52
+
53
+ const match = rawId.match(/^(J|FR|NFR|I|C)-(\d+)$/);
54
+ if (!match) return rawId;
55
+
56
+ const prefix = match[1];
57
+ const num = parseInt(match[2], 10);
58
+ const padded = num.toString().padStart(3, '0');
59
+ return `${prefix}-${padded}`;
60
+ }
61
+
62
+ // ─── ID Extraction ───────────────────────────────────────────────────────────
63
+
64
+ export interface ExtractedId {
65
+ id: string; // Normalized ID
66
+ raw: string; // Original text as found
67
+ normalized: boolean; // Whether normalization changed the ID
68
+ }
69
+
70
+ /**
71
+ * Extract IDs from content using regex.
72
+ *
73
+ * Regex: /\b(J|FR|NFR|I|C)-(\d{1,4})\b/g
74
+ * - Word boundary prevents partial matches (e.g., INFRASTRUCTURE-001)
75
+ * - Returns deduplicated array sorted by normalized ID ascending
76
+ * - Optional filter by idType (e.g., 'FR')
77
+ */
78
+ export function extractIds(content: string, idType?: string): ExtractedId[] {
79
+ const regex = /\b(J|FR|NFR|I|C)-(\d{1,4})\b/g;
80
+ const seen = new Map<string, ExtractedId>();
81
+
82
+ let match: RegExpExecArray | null;
83
+ while ((match = regex.exec(content)) !== null) {
84
+ const raw = match[0];
85
+ const prefix = match[1];
86
+
87
+ // Filter by ID type if specified
88
+ if (idType && prefix !== idType) continue;
89
+
90
+ const normalized = normalizeId(raw);
91
+ if (!seen.has(normalized)) {
92
+ seen.set(normalized, {
93
+ id: normalized,
94
+ raw,
95
+ normalized: raw !== normalized,
96
+ });
97
+ }
98
+ }
99
+
100
+ // Sort by normalized ID ascending
101
+ return Array.from(seen.values()).sort((a, b) => {
102
+ // Split into prefix and number for proper sorting
103
+ const aParts = a.id.match(/^(.+)-(\d+)$/);
104
+ const bParts = b.id.match(/^(.+)-(\d+)$/);
105
+ if (!aParts || !bParts) return a.id.localeCompare(b.id);
106
+
107
+ // Sort by prefix first, then by number
108
+ if (aParts[1] !== bParts[1]) return aParts[1].localeCompare(bParts[1]);
109
+ return parseInt(aParts[2], 10) - parseInt(bParts[2], 10);
110
+ });
111
+ }
112
+
113
+ // ─── Heading Validation ──────────────────────────────────────────────────────
114
+
115
+ export interface HeadingValidation {
116
+ valid: boolean;
117
+ missing: string[];
118
+ present: string[];
119
+ }
120
+
121
+ /**
122
+ * Validate that a file contains all required headings for its type.
123
+ *
124
+ * - Looks up required headings from REQUIRED_HEADINGS[fileType]
125
+ * - Case-insensitive search as safety layer
126
+ * - Returns which headings are present and which are missing
127
+ * - Unknown file types are always valid (no required headings)
128
+ */
129
+ export function validateHeadings(content: string, fileType: string): HeadingValidation {
130
+ const required = REQUIRED_HEADINGS[fileType];
131
+
132
+ // Unknown file type — no required headings, always valid
133
+ if (!required) {
134
+ return { valid: true, missing: [], present: [] };
135
+ }
136
+
137
+ const contentLower = content.toLowerCase();
138
+ const missing: string[] = [];
139
+ const present: string[] = [];
140
+
141
+ for (const heading of required) {
142
+ if (contentLower.includes(heading.toLowerCase())) {
143
+ present.push(heading);
144
+ } else {
145
+ missing.push(heading);
146
+ }
147
+ }
148
+
149
+ return {
150
+ valid: missing.length === 0,
151
+ missing,
152
+ present,
153
+ };
154
+ }
155
+
156
+ // ─── Heading Content Extraction ──────────────────────────────────────────────
157
+
158
+ /**
159
+ * Extract content between a heading and the next heading of same or higher level.
160
+ *
161
+ * @param content - Full file content
162
+ * @param heading - The heading to extract content from (e.g., "## Section A")
163
+ * @returns Content string (trimmed) or null if heading not found
164
+ */
165
+ export function extractHeadingContent(content: string, heading: string): string | null {
166
+ const lines = content.split('\n');
167
+ const headingLevel = (heading.match(/^#+/) || [''])[0].length;
168
+
169
+ let capturing = false;
170
+ let startIdx = -1;
171
+ let endIdx = lines.length;
172
+
173
+ for (let i = 0; i < lines.length; i++) {
174
+ const line = lines[i];
175
+
176
+ if (!capturing) {
177
+ // Look for the target heading
178
+ if (line.trim().toLowerCase() === heading.toLowerCase()) {
179
+ capturing = true;
180
+ startIdx = i + 1;
181
+ }
182
+ } else {
183
+ // Check if we hit another heading of same or higher level
184
+ const lineHeadingMatch = line.match(/^(#+)\s/);
185
+ if (lineHeadingMatch && lineHeadingMatch[1].length <= headingLevel) {
186
+ endIdx = i;
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (startIdx === -1) return null;
193
+
194
+ const extracted = lines.slice(startIdx, endIdx).join('\n').trim();
195
+ return extracted;
196
+ }