gswd 1.0.1 → 1.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 (85) hide show
  1. package/bin/gswd-tools.cjs +228 -0
  2. package/commands/gswd/imagine.md +7 -1
  3. package/commands/gswd/start.md +507 -32
  4. package/dist/lib/audit.d.ts +205 -0
  5. package/dist/lib/audit.js +805 -0
  6. package/dist/lib/bootstrap.d.ts +103 -0
  7. package/dist/lib/bootstrap.js +563 -0
  8. package/dist/lib/compile.d.ts +239 -0
  9. package/dist/lib/compile.js +1152 -0
  10. package/dist/lib/config.d.ts +49 -0
  11. package/dist/lib/config.js +150 -0
  12. package/dist/lib/imagine-agents.d.ts +54 -0
  13. package/dist/lib/imagine-agents.js +185 -0
  14. package/dist/lib/imagine-gate.d.ts +47 -0
  15. package/dist/lib/imagine-gate.js +131 -0
  16. package/dist/lib/imagine-input.d.ts +46 -0
  17. package/dist/lib/imagine-input.js +233 -0
  18. package/dist/lib/imagine-synthesis.d.ts +90 -0
  19. package/dist/lib/imagine-synthesis.js +453 -0
  20. package/dist/lib/imagine.d.ts +56 -0
  21. package/dist/lib/imagine.js +413 -0
  22. package/dist/lib/intake.d.ts +27 -0
  23. package/dist/lib/intake.js +82 -0
  24. package/dist/lib/parse.d.ts +59 -0
  25. package/dist/lib/parse.js +171 -0
  26. package/dist/lib/render.d.ts +309 -0
  27. package/dist/lib/render.js +624 -0
  28. package/dist/lib/specify-agents.d.ts +120 -0
  29. package/dist/lib/specify-agents.js +269 -0
  30. package/dist/lib/specify-journeys.d.ts +124 -0
  31. package/dist/lib/specify-journeys.js +279 -0
  32. package/dist/lib/specify-nfr.d.ts +45 -0
  33. package/dist/lib/specify-nfr.js +159 -0
  34. package/dist/lib/specify-roles.d.ts +46 -0
  35. package/dist/lib/specify-roles.js +88 -0
  36. package/dist/lib/specify.d.ts +70 -0
  37. package/dist/lib/specify.js +676 -0
  38. package/dist/lib/state.d.ts +140 -0
  39. package/dist/lib/state.js +340 -0
  40. package/dist/tests/audit.test.d.ts +4 -0
  41. package/dist/tests/audit.test.js +1579 -0
  42. package/dist/tests/bootstrap.test.d.ts +5 -0
  43. package/dist/tests/bootstrap.test.js +611 -0
  44. package/dist/tests/compile.test.d.ts +4 -0
  45. package/dist/tests/compile.test.js +862 -0
  46. package/dist/tests/config.test.d.ts +4 -0
  47. package/dist/tests/config.test.js +191 -0
  48. package/dist/tests/imagine-agents.test.d.ts +6 -0
  49. package/dist/tests/imagine-agents.test.js +179 -0
  50. package/dist/tests/imagine-gate.test.d.ts +6 -0
  51. package/dist/tests/imagine-gate.test.js +264 -0
  52. package/dist/tests/imagine-input.test.d.ts +6 -0
  53. package/dist/tests/imagine-input.test.js +283 -0
  54. package/dist/tests/imagine-synthesis.test.d.ts +7 -0
  55. package/dist/tests/imagine-synthesis.test.js +380 -0
  56. package/dist/tests/imagine.test.d.ts +8 -0
  57. package/dist/tests/imagine.test.js +406 -0
  58. package/dist/tests/parse.test.d.ts +4 -0
  59. package/dist/tests/parse.test.js +285 -0
  60. package/dist/tests/render.test.d.ts +4 -0
  61. package/dist/tests/render.test.js +236 -0
  62. package/dist/tests/specify-agents.test.d.ts +4 -0
  63. package/dist/tests/specify-agents.test.js +352 -0
  64. package/dist/tests/specify-journeys.test.d.ts +5 -0
  65. package/dist/tests/specify-journeys.test.js +440 -0
  66. package/dist/tests/specify-nfr.test.d.ts +4 -0
  67. package/dist/tests/specify-nfr.test.js +205 -0
  68. package/dist/tests/specify-roles.test.d.ts +4 -0
  69. package/dist/tests/specify-roles.test.js +136 -0
  70. package/dist/tests/specify.test.d.ts +9 -0
  71. package/dist/tests/specify.test.js +544 -0
  72. package/dist/tests/state.test.d.ts +4 -0
  73. package/dist/tests/state.test.js +316 -0
  74. package/lib/bootstrap.ts +37 -11
  75. package/lib/compile.ts +426 -4
  76. package/lib/imagine-agents.ts +53 -7
  77. package/lib/imagine-synthesis.ts +170 -6
  78. package/lib/imagine.ts +59 -5
  79. package/lib/intake.ts +60 -0
  80. package/lib/parse.ts +2 -1
  81. package/lib/render.ts +566 -5
  82. package/lib/specify-agents.ts +25 -3
  83. package/lib/state.ts +115 -0
  84. package/package.json +3 -2
  85. package/templates/gswd/DECISIONS.template.md +3 -0
package/lib/render.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * Schema: GSWD_SPEC.md Section 7.1-7.3
8
8
  */
9
9
 
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+
10
13
  // ─── Status Symbols ──────────────────────────────────────────────────────────
11
14
 
12
15
  export const SYMBOLS = {
@@ -18,6 +21,82 @@ export const SYMBOLS = {
18
21
  warning: '\u26A0', // ⚠
19
22
  } as const;
20
23
 
24
+ // ─── Colors ──────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * ANSI color codes for GSWD branding.
28
+ * Electric orange/amber is the brand color (per CONTEXT.md locked decision).
29
+ * 256-color code \x1b[38;5;208m; dim uses \x1b[2m.
30
+ */
31
+ export const COLORS = {
32
+ orange: '\x1b[38;5;208m',
33
+ dim: '\x1b[2m',
34
+ reset: '\x1b[0m',
35
+ } as const;
36
+
37
+ /**
38
+ * Wrap text in ANSI color. No-op when stdout is not a TTY (CI/Docker/pipe safety).
39
+ * Respects FORCE_COLOR env var to override TTY detection.
40
+ */
41
+ export function colorize(text: string, color: keyof typeof COLORS): string {
42
+ if (!process.stdout.isTTY && !process.env.FORCE_COLOR) {
43
+ return text;
44
+ }
45
+ return `${COLORS[color]}${text}${COLORS.reset}`;
46
+ }
47
+
48
+ // ─── Logo ────────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Render the GSWD ASCII logo with catchphrase.
52
+ * Logo shape matches GSD's block-letter style from install.js.
53
+ * Static string — no figlet (STATE.md architectural decision).
54
+ *
55
+ * @param version - Optional version string (e.g., "1.0.1")
56
+ */
57
+ export function renderLogo(version?: string): string {
58
+ const lines = [
59
+ ' \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557 ',
60
+ ' \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557',
61
+ ' \u2588\u2588\u2551 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551',
62
+ ' \u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551',
63
+ ' \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2554\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D',
64
+ ' \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D ',
65
+ ].join('\n');
66
+
67
+ const coloredLogo = colorize(lines, 'orange');
68
+ const tagline = ' Spec clarity before code.';
69
+ const versionStr = version ? colorize(` v${version}`, 'dim') : '';
70
+
71
+ return [coloredLogo, '', tagline, versionStr].filter(Boolean).join('\n');
72
+ }
73
+
74
+ // ─── First Run Guide ─────────────────────────────────────────────────────────
75
+
76
+ /**
77
+ * Render the condensed first-run command guide.
78
+ * Per CONTEXT.md: primary commands only, no descriptions.
79
+ * Followed by automatic /gswd:start proposal for zero-friction onboarding.
80
+ */
81
+ export function renderFirstRunGuide(): string {
82
+ const commands = [
83
+ '/gswd:start',
84
+ '/gswd:imagine',
85
+ '/gswd:specify',
86
+ '/gswd:compile',
87
+ ];
88
+
89
+ const lines = [
90
+ 'Commands:',
91
+ '',
92
+ ...commands.map(cmd => ` ${colorize(cmd, 'orange')}`),
93
+ '',
94
+ 'Run /gswd:start to begin.',
95
+ ];
96
+
97
+ return lines.join('\n');
98
+ }
99
+
21
100
  // ─── Helpers ─────────────────────────────────────────────────────────────────
22
101
 
23
102
  /**
@@ -46,7 +125,7 @@ export function padRight(str: string, width: number): string {
46
125
  */
47
126
  export function renderBanner(stageName: string): string {
48
127
  const BANNER_WIDTH = 55;
49
- const border = '\u2501'.repeat(BANNER_WIDTH);
128
+ const border = colorize('\u2501'.repeat(BANNER_WIDTH), 'orange');
50
129
  const title = ` GSWD \u25B6 ${stageName.toUpperCase()}`;
51
130
  return `${border}\n${title}\n${border}`;
52
131
  }
@@ -73,13 +152,13 @@ export function renderCheckpoint(
73
152
  const BOX_WIDTH = 56;
74
153
  const INNER_WIDTH = BOX_WIDTH - 4; // 52 (accounting for "│ " and " │")
75
154
 
76
- const topBorder = `\u250C${'\u2500'.repeat(BOX_WIDTH - 2)}\u2510`;
77
- const midBorder = `\u251C${'\u2500'.repeat(BOX_WIDTH - 2)}\u2524`;
78
- const bottomBorder = `\u2514${'\u2500'.repeat(BOX_WIDTH - 2)}\u2518`;
155
+ const topBorder = colorize(`\u250C${'\u2500'.repeat(BOX_WIDTH - 2)}\u2510`, 'orange');
156
+ const midBorder = colorize(`\u251C${'\u2500'.repeat(BOX_WIDTH - 2)}\u2524`, 'orange');
157
+ const bottomBorder = colorize(`\u2514${'\u2500'.repeat(BOX_WIDTH - 2)}\u2518`, 'orange');
79
158
 
80
159
  const line = (text: string): string => {
81
160
  const padded = padRight(text, INNER_WIDTH);
82
- return `\u2502 ${padded} \u2502`;
161
+ return `${colorize('\u2502', 'orange')} ${padded} ${colorize('\u2502', 'orange')}`;
83
162
  };
84
163
 
85
164
  const blankLine = line('');
@@ -198,3 +277,485 @@ export function renderProgressBar(
198
277
  const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);
199
278
  return `Progress: ${bar} ${percent}%`;
200
279
  }
280
+
281
+ // ─── Stage Summary ────────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Input for stage summary rendering.
285
+ * Used at manual-mode checkpoints to show what a stage produced.
286
+ */
287
+ export interface StageSummarySection {
288
+ name: string;
289
+ summary: string;
290
+ }
291
+
292
+ export interface StageSummaryInput {
293
+ stageName: string;
294
+ headline: string;
295
+ sections: StageSummarySection[];
296
+ filesCreated: string[];
297
+ }
298
+
299
+ /**
300
+ * Render a structured stage summary for manual-mode checkpoints.
301
+ * Consistent template per CONTEXT.md locked decision:
302
+ * Headline -> section paragraphs -> file list
303
+ *
304
+ * ```
305
+ * ## Imagine Complete
306
+ *
307
+ * Imagine produced 3 product directions with ICP, GTM, and competition analysis.
308
+ *
309
+ * **Key outputs:**
310
+ * - ICP Analysis: Identified 2 primary personas with distinct pain points...
311
+ * - GTM Strategy: Recommended freemium launch with enterprise upgrade path...
312
+ *
313
+ * **Created:** IMAGINE.md, ICP.md, GTM.md, COMPETITION.md, DECISIONS.md
314
+ * ```
315
+ */
316
+ // ─── Research Summary ────────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Research summary section for post-agent display.
320
+ * Mirrors ResearchSummarySection from imagine-synthesis.ts.
321
+ */
322
+ export interface RenderResearchSection {
323
+ displayName: string;
324
+ takeaways: string[];
325
+ bridge: string;
326
+ }
327
+
328
+ /**
329
+ * Render structured research summary after agent completion.
330
+ * Product-contextualized: section intros reference user's product.
331
+ * Per CONTEXT.md: 3-5 takeaway bullets per section, bridging paragraph per section.
332
+ *
333
+ * @param sections - Research summary sections from buildResearchSummary
334
+ * @param productContext - Brief description of the product (from brief.vision)
335
+ * @param targetUser - Who the product is for (from brief.target_user)
336
+ */
337
+ export function renderResearchSummary(
338
+ sections: RenderResearchSection[],
339
+ productContext: string,
340
+ targetUser: string,
341
+ ): string {
342
+ const parts: string[] = [];
343
+ parts.push('## Research Summary');
344
+ parts.push('');
345
+
346
+ for (const section of sections) {
347
+ // Product-contextualized intro
348
+ parts.push(`### ${section.displayName}`);
349
+ parts.push(`*For a product helping ${targetUser}:*`);
350
+ parts.push('');
351
+
352
+ // Takeaway bullets (3-5)
353
+ for (const takeaway of section.takeaways) {
354
+ parts.push(`- ${takeaway}`);
355
+ }
356
+ parts.push('');
357
+
358
+ // Bridging paragraph
359
+ parts.push(section.bridge);
360
+ parts.push('');
361
+ }
362
+
363
+ return parts.join('\n');
364
+ }
365
+
366
+ // ─── Direction Cards ─────────────────────────────────────────────────────────
367
+
368
+ /**
369
+ * Direction card input for rendering.
370
+ */
371
+ export interface RenderDirectionInput {
372
+ label: string;
373
+ rationale: string[];
374
+ icp_summary: string;
375
+ problem_framing: string;
376
+ wedge: string;
377
+ differentiator: string;
378
+ risks: string[];
379
+ }
380
+
381
+ /**
382
+ * Render a direction card with "Why this makes sense" rationale section.
383
+ * Per CONTEXT.md: rationale section comes FIRST, before existing fields.
384
+ * Recommended direction gets a visual marker.
385
+ *
386
+ * @param direction - Direction data
387
+ * @param index - 0-based index for display numbering
388
+ * @param isRecommended - Whether this is the proposed/recommended direction
389
+ */
390
+ export function renderDirectionCard(
391
+ direction: RenderDirectionInput,
392
+ index: number,
393
+ isRecommended: boolean,
394
+ ): string {
395
+ const marker = isRecommended ? ' **(Recommended)**' : '';
396
+ const parts: string[] = [];
397
+
398
+ parts.push(`### ${direction.label}${marker}`);
399
+ parts.push('');
400
+
401
+ // Rationale section FIRST per CONTEXT.md locked decision
402
+ if (direction.rationale.length > 0) {
403
+ parts.push('**Why this makes sense:**');
404
+ for (const r of direction.rationale) {
405
+ parts.push(`- ${r}`);
406
+ }
407
+ parts.push('');
408
+ }
409
+
410
+ // Existing fields
411
+ parts.push(`**ICP:** ${direction.icp_summary}`);
412
+ parts.push(`**Problem:** ${direction.problem_framing}`);
413
+ parts.push(`**Wedge:** ${direction.wedge}`);
414
+ parts.push(`**Differentiator:** ${direction.differentiator}`);
415
+ parts.push(`**Risks:** ${direction.risks.join('; ')}`);
416
+
417
+ return parts.join('\n');
418
+ }
419
+
420
+ // ─── Stage Summary ───────────────────────────────────────────────────────────
421
+
422
+ export function renderStageSummary(input: StageSummaryInput): string {
423
+ const parts: string[] = [];
424
+
425
+ // Headline
426
+ parts.push(`## ${input.stageName} Complete`);
427
+ parts.push('');
428
+ parts.push(input.headline);
429
+ parts.push('');
430
+
431
+ // Key outputs (section summaries)
432
+ if (input.sections.length > 0) {
433
+ parts.push('**Key outputs:**');
434
+ for (const section of input.sections) {
435
+ parts.push(`- **${section.name}:** ${section.summary}`);
436
+ }
437
+ parts.push('');
438
+ }
439
+
440
+ // Files created
441
+ if (input.filesCreated.length > 0) {
442
+ parts.push(`**Created:** ${input.filesCreated.join(', ')}`);
443
+ }
444
+
445
+ return parts.join('\n');
446
+ }
447
+
448
+ // ─── Product Context ────────────────────────────────────────────────────────
449
+
450
+ /**
451
+ * Minimal product context for contextual messaging throughout the pipeline.
452
+ * Extracted from INTAKE.json and DECISIONS.md with graceful fallbacks.
453
+ */
454
+ export interface ProductContext {
455
+ /** Product domain (e.g., "spec-writing tool") */
456
+ domain: string;
457
+ /** Target user (e.g., "solo founders") */
458
+ targetUser: string;
459
+ /** Stage-specific context string */
460
+ stageContext: string;
461
+ /** Whether this is a re-run (changes banner messaging) */
462
+ isRerun: boolean;
463
+ /** Re-run feedback context (if re-run) */
464
+ rerunContext?: string;
465
+ }
466
+
467
+ /**
468
+ * Extract product context from DECISIONS.md and INTAKE.json.
469
+ * DECISIONS.md is preferred (more precise, available after Imagine).
470
+ * INTAKE.json is fallback (available from pipeline start).
471
+ * Returns safe defaults if neither file exists.
472
+ *
473
+ * @param planningDir - Path to .planning/ directory
474
+ * @param isRerun - Whether this is a re-run
475
+ */
476
+ export function extractProductContext(planningDir: string, isRerun: boolean = false): ProductContext {
477
+ let domain = 'your product';
478
+ let targetUser = 'your users';
479
+
480
+ // Try DECISIONS.md first (more precise, available after Imagine)
481
+ try {
482
+ const decisionsPath = path.join(planningDir, 'DECISIONS.md');
483
+ const decisions = fs.readFileSync(decisionsPath, 'utf-8');
484
+ const targetMatch = decisions.match(/\*\*Target User:\*\*\s*(?:Auto-chosen:\s*)?(.+)/);
485
+ if (targetMatch) targetUser = targetMatch[1].trim();
486
+ const directionMatch = decisions.match(/\*\*Product Direction:\*\*\s*(?:Auto-chosen:\s*)?(.+)/);
487
+ if (directionMatch) domain = directionMatch[1].trim();
488
+ } catch {
489
+ // Fall through to INTAKE.json
490
+ }
491
+
492
+ // Fallback to INTAKE.json (available from pipeline start)
493
+ if (domain === 'your product') {
494
+ try {
495
+ const gswdDir = path.join(planningDir, 'gswd');
496
+ const intakePath = path.join(gswdDir, 'INTAKE.json');
497
+ const intake = JSON.parse(fs.readFileSync(intakePath, 'utf-8'));
498
+ if (intake.product_description) {
499
+ // Use first sentence as domain
500
+ const firstSentence = intake.product_description.split('.')[0];
501
+ if (firstSentence && firstSentence.trim().length > 0) {
502
+ domain = firstSentence.trim();
503
+ }
504
+ }
505
+ } catch {
506
+ // Use defaults
507
+ }
508
+ }
509
+
510
+ return { domain, targetUser, stageContext: '', isRerun, rerunContext: undefined };
511
+ }
512
+
513
+ // ─── Stage Context Templates ────────────────────────────────────────────────
514
+
515
+ /**
516
+ * Stage-specific context line generators for contextual banners.
517
+ * Each returns a product-contextualized one-liner for the banner.
518
+ * Compile is deliberately excluded (per CONTEXT.md: deterministic, no banner needed).
519
+ */
520
+ export const STAGE_CONTEXT_TEMPLATES: Record<string, (ctx: ProductContext) => string> = {
521
+ imagine: (ctx) => ctx.isRerun
522
+ ? `Refining directions based on your feedback${ctx.rerunContext ? ` about ${ctx.rerunContext}` : ''}`
523
+ : `Validating product direction for ${ctx.domain} targeting ${ctx.targetUser}`,
524
+ specify: (ctx) => `Building the execution-grade spec: journeys, requirements, and architecture for ${ctx.domain}`,
525
+ audit: (ctx) => `Verifying spec completeness \u2014 every journey, requirement, and edge case for ${ctx.domain} must trace`,
526
+ };
527
+
528
+ // ─── Agent Briefing Templates ───────────────────────────────────────────────
529
+
530
+ /**
531
+ * Product-contextualized one-liner descriptions for Imagine research agents.
532
+ * Each function receives ProductContext and returns a purpose string.
533
+ */
534
+ export const IMAGINE_AGENT_BRIEFINGS: Record<string, (ctx: ProductContext) => string> = {
535
+ 'market-researcher': (ctx) => `mapping the ${ctx.domain} landscape to find gaps`,
536
+ 'icp-persona': (ctx) => `profiling ${ctx.targetUser} who need ${ctx.domain}`,
537
+ 'positioning': (ctx) => `finding the angle that makes ${ctx.domain} obviously necessary`,
538
+ 'brainstorm-alternatives': (ctx) => `exploring 3 distinct product directions for ${ctx.domain}`,
539
+ 'devils-advocate': (ctx) => `stress-testing assumptions before you commit to a direction`,
540
+ };
541
+
542
+ /**
543
+ * Product-contextualized one-liner descriptions for Specify agents.
544
+ */
545
+ export const SPECIFY_AGENT_BRIEFINGS: Record<string, (ctx: ProductContext) => string> = {
546
+ 'journey-mapper': (ctx) => `mapping user journeys for ${ctx.targetUser} through ${ctx.domain}`,
547
+ 'architecture-drafter': (ctx) => `designing the system architecture for ${ctx.domain}`,
548
+ 'integrations-checker': (ctx) => `evaluating external services ${ctx.domain} needs to connect with`,
549
+ };
550
+
551
+ // ─── Contextual Banner ──────────────────────────────────────────────────────
552
+
553
+ /**
554
+ * Render a stage banner with a product-contextualized line below the title.
555
+ * Extension of renderBanner() for consultant-style messaging (Phase 16).
556
+ *
557
+ * ```
558
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
559
+ * GSWD ► IMAGINE
560
+ * Validating product direction for a spec-writing tool targeting founders
561
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
562
+ * ```
563
+ *
564
+ * @param stageName - Stage name (will be uppercased)
565
+ * @param contextLine - Product-contextualized one-liner
566
+ */
567
+ export function renderContextBanner(stageName: string, contextLine: string): string {
568
+ const BANNER_WIDTH = 55;
569
+ const border = colorize('\u2501'.repeat(BANNER_WIDTH), 'orange');
570
+ const title = ` GSWD \u25B6 ${stageName.toUpperCase()}`;
571
+ const context = ` ${contextLine}`;
572
+ return `${border}\n${title}\n${context}\n${border}`;
573
+ }
574
+
575
+ // ─── Agent Briefing ─────────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * Input for agent pre-briefing display.
579
+ */
580
+ export interface AgentBriefingEntry {
581
+ name: string;
582
+ productPurpose: string;
583
+ }
584
+
585
+ /**
586
+ * Render agent pre-briefing block before spawning.
587
+ * Technical transparency: name each agent explicitly with product-specific purpose.
588
+ *
589
+ * ```
590
+ * ◆ Spawning 5 research agents:
591
+ * → market-researcher: mapping the spec-tooling landscape to find gaps
592
+ * → icp-persona: profiling founders who struggle with product specification
593
+ * ```
594
+ *
595
+ * @param agents - Array of agent names with product-contextualized purposes
596
+ * @param stageContext - Optional stage context description
597
+ */
598
+ export function renderAgentBriefing(agents: AgentBriefingEntry[], stageContext?: string): string {
599
+ const parts: string[] = [];
600
+ parts.push(`${SYMBOLS.inProgress} Spawning ${agents.length} research agents:`);
601
+
602
+ for (const agent of agents) {
603
+ parts.push(` \u2192 ${agent.name}: ${agent.productPurpose}`);
604
+ }
605
+
606
+ if (stageContext) {
607
+ parts.push('');
608
+ parts.push(stageContext);
609
+ }
610
+
611
+ return parts.join('\n');
612
+ }
613
+
614
+ // ─── Agent Completion ───────────────────────────────────────────────────────
615
+
616
+ /**
617
+ * Render a single agent completion line with its key finding headline.
618
+ *
619
+ * ✓ market-researcher: 3 direct competitors in the spec-tooling space
620
+ * ✗ icp-persona: Agent failed — timeout
621
+ *
622
+ * @param agentName - Agent name
623
+ * @param headline - Key finding or error message
624
+ * @param status - Agent completion status
625
+ */
626
+ export function renderAgentCompletion(
627
+ agentName: string,
628
+ headline: string,
629
+ status: 'complete' | 'failed',
630
+ ): string {
631
+ const symbol = status === 'complete' ? SYMBOLS.complete : SYMBOLS.failed;
632
+ return `${symbol} ${agentName}: ${headline}`;
633
+ }
634
+
635
+ // ─── Agent Progress ─────────────────────────────────────────────────────────
636
+
637
+ /**
638
+ * Render full agent progress block showing pending and completed agents.
639
+ * Pending agents show ○, completed show ✓ with headline, failed show ✗.
640
+ *
641
+ * ✓ market-researcher: 3 direct competitors found
642
+ * ✓ icp-persona: primary persona is technical founder
643
+ * ○ positioning (pending)
644
+ * ○ brainstorm-alternatives (pending)
645
+ * ○ devils-advocate (pending)
646
+ */
647
+ export function renderAgentProgress(
648
+ agents: Array<{ name: string; status: 'pending' | 'complete' | 'failed'; headline?: string }>,
649
+ ): string {
650
+ const lines: string[] = [];
651
+
652
+ for (const agent of agents) {
653
+ switch (agent.status) {
654
+ case 'complete':
655
+ lines.push(`${SYMBOLS.complete} ${agent.name}: ${agent.headline || 'complete'}`);
656
+ break;
657
+ case 'failed':
658
+ lines.push(`${SYMBOLS.failed} ${agent.name}: ${agent.headline || 'failed'}`);
659
+ break;
660
+ case 'pending':
661
+ lines.push(`${SYMBOLS.pending} ${agent.name} (pending)`);
662
+ break;
663
+ }
664
+ }
665
+
666
+ return lines.join('\n');
667
+ }
668
+
669
+ // ─── File Inventory ──────────────────────────────────────────────────────────
670
+
671
+ /**
672
+ * Render a file inventory table listing all generated files with descriptions.
673
+ * Used at pipeline end to give users a deliverable checklist.
674
+ *
675
+ * @param files - Array of { path, description } objects
676
+ * @returns Markdown table string
677
+ */
678
+ export function renderFileInventory(files: Array<{ path: string; description: string }>): string {
679
+ if (files.length === 0) return '';
680
+
681
+ const lines: string[] = [];
682
+ lines.push('| File | Description |');
683
+ lines.push('|------|-------------|');
684
+ for (const file of files) {
685
+ lines.push(`| \`${file.path}\` | ${file.description} |`);
686
+ }
687
+ return lines.join('\n');
688
+ }
689
+
690
+ // ─── Headline Extraction ────────────────────────────────────────────────────
691
+
692
+ /**
693
+ * Extract a one-line headline from agent output for completion display.
694
+ * Multi-strategy extraction with robust fallback.
695
+ *
696
+ * Strategy priority:
697
+ * 1. First sentence after "## Summary" heading
698
+ * 2. Text after "Key finding:" prefix
699
+ * 3. First non-heading, non-empty line
700
+ * 4. Fallback: "{agentName} analysis complete"
701
+ *
702
+ * Truncates to 80 chars. Never returns empty string.
703
+ *
704
+ * @param agentOutput - Raw agent output content
705
+ * @param agentName - Agent name (for fallback)
706
+ */
707
+ export function extractHeadline(agentOutput: string, agentName: string): string {
708
+ const MAX_LENGTH = 80;
709
+
710
+ if (!agentOutput || agentOutput.trim().length === 0) {
711
+ return `${agentName} analysis complete`;
712
+ }
713
+
714
+ const lines = agentOutput.split('\n');
715
+
716
+ // Strategy 1: First sentence after "## Summary" heading
717
+ for (let i = 0; i < lines.length; i++) {
718
+ if (/^##\s+Summary\s*$/i.test(lines[i].trim())) {
719
+ // Look at next non-empty lines for the first sentence
720
+ for (let j = i + 1; j < lines.length; j++) {
721
+ const line = lines[j].trim();
722
+ if (line.length === 0) continue;
723
+ if (line.startsWith('#')) break; // next heading, stop
724
+ // Take up to first period
725
+ const periodIdx = line.indexOf('.');
726
+ const sentence = periodIdx > 0 ? line.slice(0, periodIdx + 1) : line;
727
+ return truncateHeadline(sentence, MAX_LENGTH);
728
+ }
729
+ }
730
+ }
731
+
732
+ // Strategy 2: "Key finding:" prefix
733
+ for (const line of lines) {
734
+ const match = line.match(/Key finding:\s*(.+)/i);
735
+ if (match) {
736
+ return truncateHeadline(match[1].trim(), MAX_LENGTH);
737
+ }
738
+ }
739
+
740
+ // Strategy 3: First non-heading, non-empty line
741
+ for (const line of lines) {
742
+ const trimmed = line.trim();
743
+ if (trimmed.length === 0) continue;
744
+ if (trimmed.startsWith('#')) continue;
745
+ if (trimmed.startsWith('---')) continue;
746
+ if (trimmed.startsWith('```')) continue;
747
+ return truncateHeadline(trimmed, MAX_LENGTH);
748
+ }
749
+
750
+ // Strategy 4: Fallback
751
+ return `${agentName} analysis complete`;
752
+ }
753
+
754
+ /**
755
+ * Truncate a string to maxLength, adding "..." if truncated.
756
+ */
757
+ function truncateHeadline(text: string, maxLength: number): string {
758
+ const cleaned = text.trim();
759
+ if (cleaned.length <= maxLength) return cleaned;
760
+ return cleaned.slice(0, maxLength - 3) + '...';
761
+ }
@@ -11,6 +11,8 @@
11
11
 
12
12
  import * as fs from 'node:fs';
13
13
  import type { Journey, FunctionalRequirement } from './specify-journeys.js';
14
+ import { extractHeadline } from './render.js';
15
+ import type { OnAgentComplete } from './imagine-agents.js';
14
16
 
15
17
  // ─── Types ───────────────────────────────────────────────────────────────────
16
18
 
@@ -204,6 +206,7 @@ export async function orchestrateSpecifyAgents(
204
206
  agents: SpecifyAgentDefinition[],
205
207
  context: SpecifyAgentContext,
206
208
  spawnFn: SpawnFn,
209
+ onComplete?: OnAgentComplete,
207
210
  ): Promise<AgentResult[]> {
208
211
  const results: AgentResult[] = [];
209
212
 
@@ -212,14 +215,33 @@ export async function orchestrateSpecifyAgents(
212
215
  for (const agent of sequentialAgents) {
213
216
  const result = await runSingleAgent(agent, context, spawnFn);
214
217
  results.push(result);
218
+ if (onComplete) {
219
+ onComplete({
220
+ agent: result.agent,
221
+ headline: result.status === 'complete'
222
+ ? extractHeadline(result.content, result.agent)
223
+ : (result.error || 'Agent failed'),
224
+ status: result.status,
225
+ });
226
+ }
215
227
  }
216
228
 
217
229
  // Phase 2: Parallel agents
218
230
  const parallelAgents = agents.filter((a) => a.phase === 'parallel');
219
231
  if (parallelAgents.length > 0) {
220
- const parallelPromises = parallelAgents.map((agent) =>
221
- runSingleAgent(agent, context, spawnFn)
222
- );
232
+ const parallelPromises = parallelAgents.map(async (agent) => {
233
+ const result = await runSingleAgent(agent, context, spawnFn);
234
+ if (onComplete) {
235
+ onComplete({
236
+ agent: result.agent,
237
+ headline: result.status === 'complete'
238
+ ? extractHeadline(result.content, result.agent)
239
+ : (result.error || 'Agent failed'),
240
+ status: result.status,
241
+ });
242
+ }
243
+ return result;
244
+ });
223
245
  const parallelResults = await Promise.all(parallelPromises);
224
246
  results.push(...parallelResults);
225
247
  }