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/audit.ts ADDED
@@ -0,0 +1,959 @@
1
+ /**
2
+ * GSWD Audit Module — Coverage matrix, checks, report generation, auto-fix, workflow
3
+ *
4
+ * Mechanically checks spec artifacts for coverage gaps, cross-reference integrity,
5
+ * and heading compliance. Produces PASS or actionable FAIL checklist in AUDIT.md.
6
+ *
7
+ * Schema: GSWD_SPEC.md Section 8.4 (audit workflow), 6.1-6.3 (IDs and headings)
8
+ */
9
+
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {
13
+ extractIds,
14
+ validateHeadings,
15
+ extractHeadingContent,
16
+ normalizeId,
17
+ REQUIRED_HEADINGS,
18
+ type ExtractedId,
19
+ } from './parse.js';
20
+ import { readState, writeState, updateStageStatus, writeCheckpoint, safeWriteFile } from './state.js';
21
+
22
+ // ─── Types ───────────────────────────────────────────────────────────────────
23
+
24
+ export interface Finding {
25
+ id: string;
26
+ issue: string;
27
+ fix: string;
28
+ severity: 'error' | 'warning';
29
+ }
30
+
31
+ export interface CheckResult {
32
+ check: string;
33
+ passed: boolean;
34
+ findings: Finding[];
35
+ }
36
+
37
+ export interface CoverageMatrix {
38
+ journeyToFRs: Map<string, Set<string>>;
39
+ frToJourneys: Map<string, Set<string>>;
40
+ journeyToTests: Map<string, string[]>;
41
+ journeyToIntegrations: Map<string, Set<string>>;
42
+ integrationStatus: Map<string, { status: string; hasFallback: boolean }>;
43
+ frScopes: Map<string, string>;
44
+ allJourneyIds: string[];
45
+ allFRIds: string[];
46
+ allNFRIds: string[];
47
+ allIntegrationIds: string[];
48
+ }
49
+
50
+ export interface AuditResult {
51
+ passed: boolean;
52
+ checks: CheckResult[];
53
+ matrix: CoverageMatrix;
54
+ summary: { total_checks: number; passed: number; failed: number; findings_count: number };
55
+ }
56
+
57
+ // ─── ID Extraction ──────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Extract all ID types from artifact content using parse.ts extractIds().
61
+ * Works on content strings (not file paths) for testability.
62
+ */
63
+ export function extractArtifactIds(content: string): {
64
+ journeyIds: ExtractedId[];
65
+ frIds: ExtractedId[];
66
+ nfrIds: ExtractedId[];
67
+ integrationIds: ExtractedId[];
68
+ } {
69
+ if (!content) {
70
+ return { journeyIds: [], frIds: [], nfrIds: [], integrationIds: [] };
71
+ }
72
+
73
+ return {
74
+ journeyIds: extractIds(content, 'J'),
75
+ frIds: extractIds(content, 'FR'),
76
+ nfrIds: extractIds(content, 'NFR'),
77
+ integrationIds: extractIds(content, 'I'),
78
+ };
79
+ }
80
+
81
+ // ─── Journey Section Parsing ────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Split JOURNEYS.md by ### J-NNN headings.
85
+ * Returns map of normalized journey ID -> section content.
86
+ */
87
+ export function parseJourneySections(journeysContent: string): Map<string, string> {
88
+ const sections = new Map<string, string>();
89
+ if (!journeysContent) return sections;
90
+
91
+ const journeyRegex = /^### (J-\d{1,4})[:.]?\s/gm;
92
+ const positions: { id: string; start: number }[] = [];
93
+
94
+ let match: RegExpExecArray | null;
95
+ while ((match = journeyRegex.exec(journeysContent)) !== null) {
96
+ positions.push({ id: normalizeId(match[1]), start: match.index });
97
+ }
98
+
99
+ for (let i = 0; i < positions.length; i++) {
100
+ const start = positions[i].start;
101
+ const end = i + 1 < positions.length ? positions[i + 1].start : journeysContent.length;
102
+ sections.set(positions[i].id, journeysContent.slice(start, end).trim());
103
+ }
104
+
105
+ return sections;
106
+ }
107
+
108
+ // ─── FR Scope Parsing ───────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * Extract FR scope tags from SPEC.md content.
112
+ * Looks for patterns: **Scope:** v1, Scope: v2, etc.
113
+ * Defaults to "v1" if scope not found (conservative).
114
+ */
115
+ export function parseFRScope(specContent: string): Map<string, string> {
116
+ const scopes = new Map<string, string>();
117
+ if (!specContent) return scopes;
118
+
119
+ // First, find all FR IDs in the content
120
+ const frIds = extractIds(specContent, 'FR');
121
+
122
+ // Split by FR headings to get per-FR sections
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
+ // Extract scope from each FR section
132
+ for (let i = 0; i < positions.length; i++) {
133
+ const start = positions[i].start;
134
+ const end = i + 1 < positions.length ? positions[i + 1].start : specContent.length;
135
+ const section = specContent.slice(start, end);
136
+
137
+ const scopeMatch = section.match(/\*{0,2}Scope:?\*{0,2}:?\s*(v1|v2|out)/i);
138
+ if (scopeMatch) {
139
+ scopes.set(positions[i].id, scopeMatch[1].toLowerCase());
140
+ } else {
141
+ scopes.set(positions[i].id, 'v1'); // Conservative default
142
+ }
143
+ }
144
+
145
+ // Also catch any FR IDs found by extractIds that weren't in heading positions
146
+ for (const fr of frIds) {
147
+ if (!scopes.has(fr.id)) {
148
+ scopes.set(fr.id, 'v1'); // Conservative default
149
+ }
150
+ }
151
+
152
+ return scopes;
153
+ }
154
+
155
+ // ─── Integration Status Parsing ─────────────────────────────────────────────
156
+
157
+ /**
158
+ * Parse integration statuses from INTEGRATIONS.md.
159
+ * Looks for Status: and Fallback: fields per integration.
160
+ */
161
+ export function parseIntegrationStatus(
162
+ integrationsContent: string
163
+ ): Map<string, { status: string; hasFallback: boolean }> {
164
+ const statuses = new Map<string, { status: string; hasFallback: boolean }>();
165
+ if (!integrationsContent) return statuses;
166
+
167
+ // Split by integration headings (### I-NNN)
168
+ const intRegex = /^###?\s+(I-\d{1,4})[:.]?\s/gm;
169
+ const positions: { id: string; start: number }[] = [];
170
+
171
+ let match: RegExpExecArray | null;
172
+ while ((match = intRegex.exec(integrationsContent)) !== null) {
173
+ positions.push({ id: normalizeId(match[1]), start: match.index });
174
+ }
175
+
176
+ for (let i = 0; i < positions.length; i++) {
177
+ const start = positions[i].start;
178
+ const end = i + 1 < positions.length ? positions[i + 1].start : integrationsContent.length;
179
+ const section = integrationsContent.slice(start, end);
180
+
181
+ const statusMatch = section.match(/\*{0,2}Status:?\*{0,2}:?\s*(\w+)/i);
182
+ const fallbackMatch = section.match(/^\*{0,2}Fallback:?\*{0,2}:?\s*(.+)/im);
183
+
184
+ const status = statusMatch ? statusMatch[1].toLowerCase() : 'unknown';
185
+ const hasFallback = fallbackMatch !== null && fallbackMatch[1].trim().length > 0;
186
+
187
+ statuses.set(positions[i].id, { status, hasFallback });
188
+ }
189
+
190
+ return statuses;
191
+ }
192
+
193
+ // ─── Acceptance Test Parsing ────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Parse acceptance tests from a journey section.
197
+ * Looks for "Acceptance Test" heading or bullet items after it.
198
+ */
199
+ function parseAcceptanceTests(journeySection: string): string[] {
200
+ const tests: string[] = [];
201
+ if (!journeySection) return tests;
202
+
203
+ // Look for acceptance test section (various heading patterns)
204
+ const testSectionRegex = /(?:####?\s*Acceptance\s+Tests?|####?\s*Tests?)(?:\n|\r\n?)([\s\S]*?)(?=####?\s|\n##\s|$)/i;
205
+ const sectionMatch = journeySection.match(testSectionRegex);
206
+
207
+ if (sectionMatch) {
208
+ const testContent = sectionMatch[1];
209
+ // Extract bullet items
210
+ const bullets = testContent.match(/^[-*]\s+.+/gm);
211
+ if (bullets) {
212
+ for (const bullet of bullets) {
213
+ const text = bullet.replace(/^[-*]\s+/, '').trim();
214
+ if (text.length > 0) {
215
+ tests.push(text);
216
+ }
217
+ }
218
+ }
219
+ // Also check for checkbox items
220
+ const checkboxes = testContent.match(/^[-*]\s+\[[ x]\]\s+.+/gm);
221
+ if (checkboxes) {
222
+ for (const cb of checkboxes) {
223
+ const text = cb.replace(/^[-*]\s+\[[ x]\]\s+/, '').trim();
224
+ if (text.length > 0 && !tests.includes(text)) {
225
+ tests.push(text);
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ return tests;
232
+ }
233
+
234
+ // ─── Coverage Matrix Construction ───────────────────────────────────────────
235
+
236
+ /**
237
+ * Build the full coverage matrix from spec artifact contents.
238
+ */
239
+ export function buildCoverageMatrix(files: {
240
+ journeys?: string;
241
+ spec?: string;
242
+ nfr?: string;
243
+ integrations?: string;
244
+ architecture?: string;
245
+ }): CoverageMatrix {
246
+ const journeyToFRs = new Map<string, Set<string>>();
247
+ const frToJourneys = new Map<string, Set<string>>();
248
+ const journeyToTests = new Map<string, string[]>();
249
+ const journeyToIntegrations = new Map<string, Set<string>>();
250
+
251
+ // Parse journey sections
252
+ const journeySections = parseJourneySections(files.journeys || '');
253
+
254
+ for (const [journeyId, section] of journeySections) {
255
+ // Extract FR references in this journey section
256
+ const frRefs = extractIds(section, 'FR');
257
+ const frSet = new Set(frRefs.map((f) => f.id));
258
+ journeyToFRs.set(journeyId, frSet);
259
+
260
+ // Build reverse map
261
+ for (const frId of frSet) {
262
+ if (!frToJourneys.has(frId)) {
263
+ frToJourneys.set(frId, new Set());
264
+ }
265
+ frToJourneys.get(frId)!.add(journeyId);
266
+ }
267
+
268
+ // Extract acceptance tests
269
+ const tests = parseAcceptanceTests(section);
270
+ journeyToTests.set(journeyId, tests);
271
+
272
+ // Extract integration references
273
+ const intRefs = extractIds(section, 'I');
274
+ const intSet = new Set(intRefs.map((i) => i.id));
275
+ journeyToIntegrations.set(journeyId, intSet);
276
+ }
277
+
278
+ // Parse FR scopes from SPEC.md
279
+ const frScopes = parseFRScope(files.spec || '');
280
+
281
+ // Parse integration statuses
282
+ const integrationStatus = parseIntegrationStatus(files.integrations || '');
283
+
284
+ // Collect all unique IDs
285
+ const allJourneyIds = Array.from(journeySections.keys()).sort();
286
+ const allFRIdSet = new Set<string>();
287
+ const allNFRIdSet = new Set<string>();
288
+ const allIntIdSet = new Set<string>();
289
+
290
+ // From all files
291
+ for (const content of Object.values(files)) {
292
+ if (!content) continue;
293
+ for (const id of extractIds(content, 'FR')) allFRIdSet.add(id.id);
294
+ for (const id of extractIds(content, 'NFR')) allNFRIdSet.add(id.id);
295
+ for (const id of extractIds(content, 'I')) allIntIdSet.add(id.id);
296
+ }
297
+
298
+ return {
299
+ journeyToFRs,
300
+ frToJourneys,
301
+ journeyToTests,
302
+ journeyToIntegrations,
303
+ integrationStatus,
304
+ frScopes,
305
+ allJourneyIds,
306
+ allFRIds: Array.from(allFRIdSet).sort(),
307
+ allNFRIds: Array.from(allNFRIdSet).sort(),
308
+ allIntegrationIds: Array.from(allIntIdSet).sort(),
309
+ };
310
+ }
311
+
312
+ // ─── Audit Checks ───────────────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Check: Every journey references >= 1 FR (AUDT-02 partial)
316
+ */
317
+ export function checkJourneyFRCoverage(matrix: CoverageMatrix): CheckResult {
318
+ const findings: Finding[] = [];
319
+
320
+ for (const journeyId of matrix.allJourneyIds) {
321
+ const frSet = matrix.journeyToFRs.get(journeyId);
322
+ if (!frSet || frSet.size === 0) {
323
+ findings.push({
324
+ id: journeyId,
325
+ issue: 'Journey references no functional requirements',
326
+ fix: `Add FR-XXX references to journey ${journeyId} in JOURNEYS.md`,
327
+ severity: 'error',
328
+ });
329
+ }
330
+ }
331
+
332
+ return { check: 'journey_fr_coverage', passed: findings.length === 0, findings };
333
+ }
334
+
335
+ /**
336
+ * Check: Every journey has >= 1 acceptance test (AUDT-02 partial)
337
+ */
338
+ export function checkJourneyTestCoverage(matrix: CoverageMatrix): CheckResult {
339
+ const findings: Finding[] = [];
340
+
341
+ for (const journeyId of matrix.allJourneyIds) {
342
+ const tests = matrix.journeyToTests.get(journeyId);
343
+ if (!tests || tests.length === 0) {
344
+ findings.push({
345
+ id: journeyId,
346
+ issue: 'Journey has no acceptance tests',
347
+ fix: `Add at least one acceptance test to journey ${journeyId} in JOURNEYS.md`,
348
+ severity: 'error',
349
+ });
350
+ }
351
+ }
352
+
353
+ return { check: 'journey_test_coverage', passed: findings.length === 0, findings };
354
+ }
355
+
356
+ /**
357
+ * Check: Every Scope:v1 FR is referenced by >= 1 journey (AUDT-03)
358
+ * Skips v2 and out-of-scope FRs.
359
+ */
360
+ export function checkOrphanV1FRs(matrix: CoverageMatrix): CheckResult {
361
+ const findings: Finding[] = [];
362
+
363
+ for (const frId of matrix.allFRIds) {
364
+ const scope = matrix.frScopes.get(frId) || 'v1'; // Default to v1 (conservative)
365
+ if (scope !== 'v1') continue; // Only check v1 FRs
366
+
367
+ const journeys = matrix.frToJourneys.get(frId);
368
+ if (!journeys || journeys.size === 0) {
369
+ findings.push({
370
+ id: frId,
371
+ issue: 'v1 FR not referenced by any journey',
372
+ fix: `Add reference to ${frId} in a relevant journey in JOURNEYS.md, or change scope to v2 if not v1 priority`,
373
+ severity: 'error',
374
+ });
375
+ }
376
+ }
377
+
378
+ return { check: 'orphan_v1_frs', passed: findings.length === 0, findings };
379
+ }
380
+
381
+ /**
382
+ * Check: No unapproved integration referenced by v1 journey without fallback (AUDT-04)
383
+ */
384
+ export function checkIntegrationApproval(matrix: CoverageMatrix): CheckResult {
385
+ const findings: Finding[] = [];
386
+ const reported = new Set<string>();
387
+
388
+ for (const [journeyId, intSet] of matrix.journeyToIntegrations) {
389
+ for (const intId of intSet) {
390
+ if (reported.has(intId)) continue;
391
+
392
+ const intStatus = matrix.integrationStatus.get(intId);
393
+ if (!intStatus) {
394
+ // Integration referenced but not found in INTEGRATIONS.md
395
+ findings.push({
396
+ id: intId,
397
+ issue: `Integration referenced by journey ${journeyId} but not defined in INTEGRATIONS.md`,
398
+ fix: `Add integration ${intId} section to INTEGRATIONS.md with Status and Fallback fields`,
399
+ severity: 'error',
400
+ });
401
+ reported.add(intId);
402
+ continue;
403
+ }
404
+
405
+ const isApproved = intStatus.status === 'approved';
406
+ const isDeferredWithFallback = intStatus.status === 'deferred' && intStatus.hasFallback;
407
+
408
+ if (!isApproved && !isDeferredWithFallback) {
409
+ findings.push({
410
+ id: intId,
411
+ issue: `Unapproved integration referenced by v1 journey ${journeyId} without fallback`,
412
+ fix: `Approve integration ${intId} in INTEGRATIONS.md, or add a fallback strategy (Status: deferred, Fallback: [description])`,
413
+ severity: 'error',
414
+ });
415
+ reported.add(intId);
416
+ }
417
+ }
418
+ }
419
+
420
+ return { check: 'integration_approval', passed: findings.length === 0, findings };
421
+ }
422
+
423
+ /**
424
+ * Check: All required headings exist in artifact files (AUDT-05)
425
+ */
426
+ export function checkRequiredHeadings(files: Record<string, string>): CheckResult {
427
+ const findings: Finding[] = [];
428
+
429
+ for (const fileType of Object.keys(REQUIRED_HEADINGS)) {
430
+ const content = files[fileType];
431
+ // Only check files that were provided in the map
432
+ if (content === undefined) continue;
433
+ if (content === null || content === '') {
434
+ findings.push({
435
+ id: fileType,
436
+ issue: `Artifact file ${fileType} is missing or empty`,
437
+ fix: `Create ${fileType} with required headings`,
438
+ severity: 'error',
439
+ });
440
+ continue;
441
+ }
442
+
443
+ const validation = validateHeadings(content, fileType);
444
+ if (!validation.valid) {
445
+ for (const heading of validation.missing) {
446
+ findings.push({
447
+ id: fileType,
448
+ issue: `Missing required heading: ${heading}`,
449
+ fix: `Add '${heading}' section to ${fileType}`,
450
+ severity: 'error',
451
+ });
452
+ }
453
+ }
454
+ }
455
+
456
+ return { check: 'required_headings', passed: findings.length === 0, findings };
457
+ }
458
+
459
+ // ─── Audit Orchestrator ─────────────────────────────────────────────────────
460
+
461
+ /**
462
+ * Run all audit checks and return structured result.
463
+ */
464
+ export function runAudit(files: {
465
+ journeys?: string;
466
+ spec?: string;
467
+ nfr?: string;
468
+ integrations?: string;
469
+ architecture?: string;
470
+ }): AuditResult {
471
+ // Build coverage matrix
472
+ const matrix = buildCoverageMatrix(files);
473
+
474
+ // Run all 5 checks
475
+ const checks: CheckResult[] = [
476
+ checkJourneyFRCoverage(matrix),
477
+ checkJourneyTestCoverage(matrix),
478
+ checkOrphanV1FRs(matrix),
479
+ checkIntegrationApproval(matrix),
480
+ checkRequiredHeadings({
481
+ 'JOURNEYS.md': files.journeys || '',
482
+ 'SPEC.md': files.spec || '',
483
+ 'NFR.md': files.nfr || '',
484
+ 'INTEGRATIONS.md': files.integrations || '',
485
+ // Only check ARCHITECTURE.md when it is present and non-empty (file is optional)
486
+ ...(files.architecture ? { 'ARCHITECTURE.md': files.architecture } : {}),
487
+ }),
488
+ ];
489
+
490
+ // Compute summary
491
+ const passedChecks = checks.filter((c) => c.passed).length;
492
+ const failedChecks = checks.filter((c) => !c.passed).length;
493
+ const totalFindings = checks.reduce((sum, c) => sum + c.findings.length, 0);
494
+
495
+ return {
496
+ passed: failedChecks === 0,
497
+ checks,
498
+ matrix,
499
+ summary: {
500
+ total_checks: checks.length,
501
+ passed: passedChecks,
502
+ failed: failedChecks,
503
+ findings_count: totalFindings,
504
+ },
505
+ };
506
+ }
507
+
508
+ // ─── Report Generation (Plan 04-02) ─────────────────────────────────────────
509
+
510
+ /** Human-readable check name mapping */
511
+ const CHECK_NAMES: Record<string, string> = {
512
+ journey_fr_coverage: 'Journey FR Coverage',
513
+ journey_test_coverage: 'Journey Test Coverage',
514
+ orphan_v1_frs: 'Orphan v1 FRs',
515
+ integration_approval: 'Integration Approval',
516
+ required_headings: 'Required Headings',
517
+ };
518
+
519
+ /**
520
+ * Format coverage matrix as a markdown table.
521
+ * Sorted by journey ID ascending.
522
+ */
523
+ export function formatCoverageMatrixTable(matrix: CoverageMatrix): string {
524
+ const header = '| Journey | Linked FRs | Linked NFRs | Acceptance Tests | Integrations | Status |';
525
+ const separator = '|---------|------------|-------------|------------------|--------------|--------|';
526
+ const rows: string[] = [];
527
+
528
+ for (const journeyId of matrix.allJourneyIds) {
529
+ const frs = matrix.journeyToFRs.get(journeyId);
530
+ const tests = matrix.journeyToTests.get(journeyId) || [];
531
+ const integrations = matrix.journeyToIntegrations.get(journeyId);
532
+
533
+ // Collect NFRs referenced in this journey (from all content — approximate via allNFRIds for now)
534
+ const frList = frs && frs.size > 0 ? Array.from(frs).sort().join(', ') : '(none)';
535
+ const nfrList = '(none)'; // NFR cross-reference is not tracked per-journey in the matrix
536
+ const testCount = `${tests.length} test${tests.length !== 1 ? 's' : ''}`;
537
+ const intList = integrations && integrations.size > 0 ? Array.from(integrations).sort().join(', ') : '-';
538
+
539
+ // Status: PASS if has FRs and tests, FAIL with reason otherwise
540
+ const hasFRs = frs && frs.size > 0;
541
+ const hasTests = tests.length > 0;
542
+ let status: string;
543
+ if (hasFRs && hasTests) {
544
+ status = 'PASS';
545
+ } else if (!hasFRs && !hasTests) {
546
+ status = 'FAIL: no FRs, no tests';
547
+ } else if (!hasFRs) {
548
+ status = 'FAIL: no FRs';
549
+ } else {
550
+ status = 'FAIL: no tests';
551
+ }
552
+
553
+ rows.push(`| ${journeyId} | ${frList} | ${nfrList} | ${testCount} | ${intList} | ${status} |`);
554
+ }
555
+
556
+ return [header, separator, ...rows].join('\n');
557
+ }
558
+
559
+ /**
560
+ * Format a single check result as a markdown section.
561
+ */
562
+ export function formatCheckSection(result: CheckResult): string {
563
+ const name = CHECK_NAMES[result.check] || result.check;
564
+ const lines: string[] = [];
565
+
566
+ lines.push(`### ${name}`);
567
+
568
+ if (result.passed) {
569
+ lines.push(`**Status:** PASS`);
570
+ } else {
571
+ lines.push(`**Status:** FAIL`);
572
+ lines.push('');
573
+ lines.push('**Issues:**');
574
+ for (const finding of result.findings) {
575
+ lines.push(`- [ ] **${finding.id}**: ${finding.issue}`);
576
+ lines.push(` Fix: ${finding.fix}`);
577
+ }
578
+ }
579
+
580
+ return lines.join('\n');
581
+ }
582
+
583
+ /**
584
+ * Generate full AUDIT.md report content from audit result.
585
+ * Groups findings by check type. FAIL includes actionable checklist.
586
+ */
587
+ export function generateAuditReport(result: AuditResult): string {
588
+ const lines: string[] = [];
589
+
590
+ // Header
591
+ lines.push('# Audit Report');
592
+ lines.push('');
593
+ lines.push(`**Date:** ${new Date().toISOString().split('T')[0]}`);
594
+ lines.push(`**Result:** ${result.passed ? 'PASS' : 'FAIL'}`);
595
+ lines.push(`**Summary:** ${result.summary.passed}/${result.summary.total_checks} checks passed, ${result.summary.findings_count} issue(s) found`);
596
+ lines.push('');
597
+
598
+ // Coverage Matrix
599
+ lines.push('## Coverage Matrix');
600
+ lines.push('');
601
+ lines.push(formatCoverageMatrixTable(result.matrix));
602
+ lines.push('');
603
+
604
+ // Coverage Statistics
605
+ lines.push('## Coverage Statistics');
606
+ lines.push('');
607
+ lines.push('| Metric | Count |');
608
+ lines.push('|--------|-------|');
609
+ lines.push(`| Journeys | ${result.matrix.allJourneyIds.length} |`);
610
+ lines.push(`| Functional Requirements | ${result.matrix.allFRIds.length} |`);
611
+ lines.push(`| Non-Functional Requirements | ${result.matrix.allNFRIds.length} |`);
612
+ lines.push(`| Integrations | ${result.matrix.allIntegrationIds.length} |`);
613
+ lines.push('');
614
+
615
+ // Check Results (grouped by check type)
616
+ lines.push('## Check Results');
617
+ lines.push('');
618
+ for (const check of result.checks) {
619
+ lines.push(formatCheckSection(check));
620
+ lines.push('');
621
+ }
622
+
623
+ // Actionable Checklist (only on FAIL)
624
+ if (!result.passed) {
625
+ lines.push('## Actionable Checklist');
626
+ lines.push('');
627
+ for (const check of result.checks) {
628
+ if (!check.passed) {
629
+ for (const finding of check.findings) {
630
+ lines.push(`- [ ] **${finding.id}**: ${finding.issue}`);
631
+ lines.push(` Fix: ${finding.fix}`);
632
+ }
633
+ }
634
+ }
635
+ lines.push('');
636
+ }
637
+
638
+ return lines.join('\n');
639
+ }
640
+
641
+ // ─── Auto-Fix (Plan 04-02) ──────────────────────────────────────────────────
642
+
643
+ export interface AutoFixChange {
644
+ file: string;
645
+ change: string;
646
+ finding: Finding;
647
+ }
648
+
649
+ export interface AutoFixResult {
650
+ applied: AutoFixChange[];
651
+ skipped: Finding[];
652
+ updatedFiles: Map<string, string>;
653
+ }
654
+
655
+ /**
656
+ * Apply surgical auto-fixes to spec artifacts.
657
+ *
658
+ * CAN do:
659
+ * - Add stub acceptance tests for journeys missing them
660
+ * - Add fallback stubs for unapproved integrations
661
+ *
662
+ * CANNOT do:
663
+ * - Invent new FRs, journeys, or integrations
664
+ * - Approve paid integrations
665
+ * - Change existing content (only append)
666
+ */
667
+ export function applyAutoFix(
668
+ result: AuditResult,
669
+ files: Record<string, string>
670
+ ): AutoFixResult {
671
+ const applied: AutoFixChange[] = [];
672
+ const skipped: Finding[] = [];
673
+ const updatedFiles = new Map<string, string>();
674
+
675
+ // Initialize updatedFiles with copies of originals
676
+ for (const [name, content] of Object.entries(files)) {
677
+ updatedFiles.set(name, content);
678
+ }
679
+
680
+ for (const check of result.checks) {
681
+ if (check.passed) continue;
682
+
683
+ for (const finding of check.findings) {
684
+ switch (check.check) {
685
+ case 'journey_test_coverage': {
686
+ // Can fix: add stub acceptance test
687
+ const journeyId = finding.id;
688
+ let journeysContent = updatedFiles.get('JOURNEYS.md') || '';
689
+
690
+ // Find the journey section and append a stub test
691
+ const journeyHeadingRegex = new RegExp(
692
+ `(### ${journeyId.replace('-', '\\-')}[:\\.]?\\s[^\\n]*\\n)`,
693
+ 'm'
694
+ );
695
+ const headingMatch = journeysContent.match(journeyHeadingRegex);
696
+
697
+ if (headingMatch) {
698
+ // Find where this journey section ends (next ### or end of file)
699
+ const headingIdx = journeysContent.indexOf(headingMatch[0]);
700
+ const nextHeadingIdx = journeysContent.indexOf('\n### ', headingIdx + headingMatch[0].length);
701
+ const insertPoint = nextHeadingIdx !== -1 ? nextHeadingIdx : journeysContent.length;
702
+
703
+ const stubTest = `\n#### Acceptance Tests\n- [ ] Verify that ${journeyId} completes successfully\n`;
704
+ journeysContent =
705
+ journeysContent.slice(0, insertPoint) + stubTest + journeysContent.slice(insertPoint);
706
+
707
+ updatedFiles.set('JOURNEYS.md', journeysContent);
708
+ applied.push({
709
+ file: 'JOURNEYS.md',
710
+ change: `Added stub acceptance test for ${journeyId}`,
711
+ finding,
712
+ });
713
+ } else {
714
+ skipped.push(finding);
715
+ }
716
+ break;
717
+ }
718
+
719
+ case 'integration_approval': {
720
+ // Can fix: add fallback stub (NOT approval)
721
+ const intId = finding.id;
722
+ let intContent = updatedFiles.get('INTEGRATIONS.md') || '';
723
+
724
+ // Find the integration section
725
+ const intHeadingRegex = new RegExp(
726
+ `(### ${intId.replace('-', '\\-')}[:\\.]?\\s[^\\n]*\\n)`,
727
+ 'm'
728
+ );
729
+ const intMatch = intContent.match(intHeadingRegex);
730
+
731
+ if (intMatch) {
732
+ const intHeadingIdx = intContent.indexOf(intMatch[0]);
733
+ const nextIntHeading = intContent.indexOf('\n### ', intHeadingIdx + intMatch[0].length);
734
+ const insertPoint = nextIntHeading !== -1 ? nextIntHeading : intContent.length;
735
+
736
+ const fallbackStub = `\n**Fallback:** manual setup\n`;
737
+ intContent =
738
+ intContent.slice(0, insertPoint) + fallbackStub + intContent.slice(insertPoint);
739
+
740
+ updatedFiles.set('INTEGRATIONS.md', intContent);
741
+ applied.push({
742
+ file: 'INTEGRATIONS.md',
743
+ change: `Added fallback stub for unapproved integration ${intId}`,
744
+ finding,
745
+ });
746
+ } else {
747
+ skipped.push(finding);
748
+ }
749
+ break;
750
+ }
751
+
752
+ case 'orphan_v1_frs': {
753
+ // CANNOT fix: would require inventing journey references or new journeys
754
+ skipped.push(finding);
755
+ break;
756
+ }
757
+
758
+ case 'journey_fr_coverage': {
759
+ // CANNOT fix: would require inventing new FR references
760
+ skipped.push(finding);
761
+ break;
762
+ }
763
+
764
+ case 'required_headings': {
765
+ // CANNOT fix reliably: would require inventing content
766
+ skipped.push(finding);
767
+ break;
768
+ }
769
+
770
+ default: {
771
+ skipped.push(finding);
772
+ break;
773
+ }
774
+ }
775
+ }
776
+ }
777
+
778
+ return { applied, skipped, updatedFiles };
779
+ }
780
+
781
+ // ─── Hard Gate Enforcement (Plan 04-02) ──────────────────────────────────────
782
+
783
+ /**
784
+ * Check audit gate: returns true ONLY when audit status is "pass".
785
+ * Fail-closed: returns false for any non-"pass" state, including read errors.
786
+ */
787
+ export function checkAuditGate(statePath: string): boolean {
788
+ try {
789
+ const state = readState(statePath);
790
+ if (!state) return false;
791
+ return state.stage_status.audit === 'pass';
792
+ } catch {
793
+ return false; // Fail-closed
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Update STATE.json with audit result.
799
+ * Sets stage_status.audit to "pass" or "fail".
800
+ * On pass: also updates stage to "audit".
801
+ */
802
+ export function updateAuditState(statePath: string, passed: boolean): void {
803
+ updateStageStatus(statePath, 'audit', passed ? 'pass' : 'fail');
804
+
805
+ if (passed) {
806
+ const state = readState(statePath);
807
+ if (state) {
808
+ state.stage = 'audit';
809
+ writeState(statePath, state);
810
+ }
811
+ }
812
+ }
813
+
814
+ // ─── Workflow Orchestrator (Plan 04-03) ───────────────────────────────────────
815
+
816
+ export interface AuditWorkflowResult {
817
+ passed: boolean;
818
+ auditResult: AuditResult;
819
+ reportPath: string; // Path to written AUDIT.md
820
+ autoFixCycles: number; // How many auto-fix cycles ran (0 if no auto-fix)
821
+ autoFixChanges: AutoFixChange[]; // All changes made across cycles
822
+ }
823
+
824
+ /**
825
+ * Read all spec artifact files from a planning directory.
826
+ * Returns a content map. Missing files get empty string (audit checks will flag them).
827
+ */
828
+ export function readArtifactFiles(planningDir: string): Record<string, string> {
829
+ const fileMap: Array<[string, string]> = [
830
+ ['JOURNEYS.md', 'journeys'],
831
+ ['SPEC.md', 'spec'],
832
+ ['NFR.md', 'nfr'],
833
+ ['INTEGRATIONS.md', 'integrations'],
834
+ ['ARCHITECTURE.md', 'architecture'], // optional
835
+ ];
836
+
837
+ const result: Record<string, string> = {};
838
+ for (const [filename, key] of fileMap) {
839
+ try {
840
+ result[key] = fs.readFileSync(path.join(planningDir, filename), 'utf-8');
841
+ } catch {
842
+ result[key] = '';
843
+ }
844
+ }
845
+
846
+ return result;
847
+ }
848
+
849
+ /**
850
+ * Write AUDIT.md atomically to the planning directory.
851
+ * Returns the written file path.
852
+ */
853
+ export function writeAuditReport(planningDir: string, reportContent: string): string {
854
+ const reportPath = path.join(planningDir, 'AUDIT.md');
855
+ safeWriteFile(reportPath, reportContent);
856
+ return reportPath;
857
+ }
858
+
859
+ /**
860
+ * Returns the list of artifact file names that audit checks.
861
+ */
862
+ export function getAuditableFileTypes(): string[] {
863
+ return ['JOURNEYS.md', 'SPEC.md', 'NFR.md', 'INTEGRATIONS.md'];
864
+ }
865
+
866
+ /**
867
+ * Full audit workflow orchestrator.
868
+ *
869
+ * Steps:
870
+ * 1. Update state -> in_progress
871
+ * 2. Read all artifact files
872
+ * 3. Run audit
873
+ * 4. If PASS -> generate report, write AUDIT.md, update state -> pass, write checkpoint, return
874
+ * 5. If FAIL and autoFix enabled:
875
+ * - Run auto-fix cycles (max 2 by default per GSWD_SPEC Section 10.4)
876
+ * - Write fixed files to disk after each cycle
877
+ * - Re-read files and re-run audit
878
+ * 6. Generate report, write AUDIT.md, update state -> pass or fail, write checkpoint
879
+ */
880
+ export function runAuditWorkflow(options: {
881
+ planningDir: string;
882
+ statePath: string;
883
+ autoFix?: boolean;
884
+ maxCycles?: number;
885
+ }): AuditWorkflowResult {
886
+ const { planningDir, statePath, autoFix = false, maxCycles = 2 } = options;
887
+ const allAutoFixChanges: AutoFixChange[] = [];
888
+ let autoFixCycles = 0;
889
+
890
+ // Step 1: Update state -> in_progress
891
+ updateStageStatus(statePath, 'audit', 'in_progress');
892
+
893
+ // Step 2: Read all artifact files
894
+ let files = readArtifactFiles(planningDir);
895
+
896
+ // Build the file name map used for auto-fix (keyed by filename, not key)
897
+ const fileNameKeys: Record<string, string> = {
898
+ journeys: 'JOURNEYS.md',
899
+ spec: 'SPEC.md',
900
+ nfr: 'NFR.md',
901
+ integrations: 'INTEGRATIONS.md',
902
+ architecture: 'ARCHITECTURE.md',
903
+ };
904
+
905
+ // Step 3: Run audit
906
+ let auditResult = runAudit(files);
907
+
908
+ // Step 4: If PASS or no auto-fix, skip loop
909
+ if (!auditResult.passed && autoFix) {
910
+ // Step 5: Auto-fix loop
911
+ while (autoFixCycles < maxCycles && !auditResult.passed) {
912
+ // Build the named-file map for applyAutoFix (uses filename keys)
913
+ const namedFiles: Record<string, string> = {};
914
+ for (const [key, filename] of Object.entries(fileNameKeys)) {
915
+ if (files[key] !== undefined) {
916
+ namedFiles[filename] = files[key];
917
+ }
918
+ }
919
+
920
+ // Apply auto-fix
921
+ const fixResult = applyAutoFix(auditResult, namedFiles);
922
+ allAutoFixChanges.push(...fixResult.applied);
923
+
924
+ // Write fixed files to disk
925
+ for (const [filename, content] of fixResult.updatedFiles) {
926
+ const filePath = path.join(planningDir, filename);
927
+ safeWriteFile(filePath, content);
928
+ }
929
+
930
+ autoFixCycles++;
931
+
932
+ // Re-read files (they may have been modified)
933
+ files = readArtifactFiles(planningDir);
934
+
935
+ // Re-run audit
936
+ auditResult = runAudit(files);
937
+ }
938
+ }
939
+
940
+ // Generate report (with final result)
941
+ const reportContent = generateAuditReport(auditResult);
942
+
943
+ // Write AUDIT.md
944
+ const reportPath = writeAuditReport(planningDir, reportContent);
945
+
946
+ // Update state -> pass or fail
947
+ updateAuditState(statePath, auditResult.passed);
948
+
949
+ // Write checkpoint
950
+ writeCheckpoint(statePath, 'gswd/audit-spec', `audit-${auditResult.passed ? 'pass' : 'fail'}`);
951
+
952
+ return {
953
+ passed: auditResult.passed,
954
+ auditResult,
955
+ reportPath,
956
+ autoFixCycles,
957
+ autoFixChanges: allAutoFixChanges,
958
+ };
959
+ }