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/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
|
+
}
|