@hegemonart/get-design-done 1.20.0 → 1.22.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 (69) hide show
  1. package/.claude-plugin/marketplace.json +9 -12
  2. package/.claude-plugin/plugin.json +8 -31
  3. package/CHANGELOG.md +200 -0
  4. package/README.md +48 -7
  5. package/bin/gdd-sdk +55 -0
  6. package/hooks/_hook-emit.js +81 -0
  7. package/hooks/gdd-bash-guard.js +8 -0
  8. package/hooks/gdd-decision-injector.js +2 -0
  9. package/hooks/gdd-protected-paths.js +8 -0
  10. package/hooks/gdd-trajectory-capture.js +64 -0
  11. package/hooks/hooks.json +9 -0
  12. package/package.json +19 -47
  13. package/reference/codex-tools.md +53 -0
  14. package/reference/gemini-tools.md +53 -0
  15. package/reference/registry.json +14 -0
  16. package/scripts/cli/gdd-events.mjs +283 -0
  17. package/scripts/e2e/run-headless.ts +514 -0
  18. package/scripts/lib/cli/commands/audit.ts +382 -0
  19. package/scripts/lib/cli/commands/init.ts +217 -0
  20. package/scripts/lib/cli/commands/query.ts +329 -0
  21. package/scripts/lib/cli/commands/run.ts +656 -0
  22. package/scripts/lib/cli/commands/stage.ts +468 -0
  23. package/scripts/lib/cli/index.ts +167 -0
  24. package/scripts/lib/cli/parse-args.ts +336 -0
  25. package/scripts/lib/connection-probe/index.cjs +263 -0
  26. package/scripts/lib/context-engine/index.ts +116 -0
  27. package/scripts/lib/context-engine/manifest.ts +69 -0
  28. package/scripts/lib/context-engine/truncate.ts +282 -0
  29. package/scripts/lib/context-engine/types.ts +59 -0
  30. package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
  31. package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
  32. package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
  33. package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
  34. package/scripts/lib/event-chain.cjs +177 -0
  35. package/scripts/lib/event-stream/index.ts +31 -1
  36. package/scripts/lib/event-stream/reader.ts +139 -0
  37. package/scripts/lib/event-stream/types.ts +155 -1
  38. package/scripts/lib/event-stream/writer.ts +65 -8
  39. package/scripts/lib/explore-parallel-runner/index.ts +294 -0
  40. package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
  41. package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
  42. package/scripts/lib/explore-parallel-runner/types.ts +139 -0
  43. package/scripts/lib/harness/detect.ts +90 -0
  44. package/scripts/lib/harness/index.ts +64 -0
  45. package/scripts/lib/harness/tool-map.ts +142 -0
  46. package/scripts/lib/init-runner/index.ts +396 -0
  47. package/scripts/lib/init-runner/researchers.ts +245 -0
  48. package/scripts/lib/init-runner/scaffold.ts +224 -0
  49. package/scripts/lib/init-runner/synthesizer.ts +224 -0
  50. package/scripts/lib/init-runner/types.ts +143 -0
  51. package/scripts/lib/logger/index.ts +251 -0
  52. package/scripts/lib/logger/sinks.ts +269 -0
  53. package/scripts/lib/logger/types.ts +110 -0
  54. package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
  55. package/scripts/lib/pipeline-runner/index.ts +527 -0
  56. package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
  57. package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
  58. package/scripts/lib/pipeline-runner/types.ts +183 -0
  59. package/scripts/lib/redact.cjs +122 -0
  60. package/scripts/lib/session-runner/errors.ts +406 -0
  61. package/scripts/lib/session-runner/index.ts +715 -0
  62. package/scripts/lib/session-runner/transcript.ts +189 -0
  63. package/scripts/lib/session-runner/types.ts +144 -0
  64. package/scripts/lib/tool-scoping/index.ts +219 -0
  65. package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
  66. package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
  67. package/scripts/lib/tool-scoping/types.ts +77 -0
  68. package/scripts/lib/trajectory/index.cjs +126 -0
  69. package/scripts/lib/transports/ws.cjs +179 -0
@@ -0,0 +1,430 @@
1
+ // scripts/lib/discuss-parallel-runner/discussants.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Discussant dispatch + DISCUSSION COMPLETE block parser.
4
+ //
5
+ // Public surface:
6
+ // * spawnDiscussant — one session per spec, parses final_text
7
+ // into DiscussionItem[].
8
+ // * spawnDiscussantsParallel — N sessions with semaphore concurrency.
9
+ // * parseDiscussionBlock — pure parser for the DISCUSSION COMPLETE
10
+ // block (used standalone + by spawnDiscussant).
11
+ //
12
+ // Block grammar (lenient, regex-based — no YAML dep):
13
+ //
14
+ // ## DISCUSSION COMPLETE
15
+ //
16
+ // ### Questions
17
+ // - Q: <text>
18
+ // Concern: <stakeholder>
19
+ // Severity: <blocker|major|minor|nice-to-have>
20
+ // Rationale: <one sentence>
21
+ //
22
+ // ### Concerns
23
+ // - C: <text>
24
+ // Area: <scope>
25
+ // Severity: <...>
26
+ //
27
+ // Parse rules:
28
+ // * DISCUSSION COMPLETE heading match is case-insensitive.
29
+ // * Items start with `- Q:` or `- C:`.
30
+ // * Field lines (Concern/Area/Severity/Rationale) continue until the
31
+ // next `- Q:`/`- C:` item or the next heading.
32
+ // * Severity normalization:
33
+ // blocker / critical → 'blocker'
34
+ // major / high → 'major'
35
+ // minor / low → 'minor'
36
+ // nice-to-have / nice to have / nth → 'nice-to-have'
37
+ // unknown / missing → 'minor' (default)
38
+ // * Empty `- Q:` / `- C:` (no text after the colon) is skipped with
39
+ // a logger warn.
40
+
41
+ import { run as defaultRun } from '../session-runner/index.ts';
42
+ import type {
43
+ BudgetCap,
44
+ SessionResult,
45
+ SessionRunnerOptions,
46
+ } from '../session-runner/types.ts';
47
+ import { getLogger } from '../logger/index.ts';
48
+ import { parseAgentTools } from '../tool-scoping/parse-agent-tools.ts';
49
+ import type {
50
+ DiscussantSpec,
51
+ DiscussionContribution,
52
+ DiscussionItem,
53
+ Severity,
54
+ } from './types.ts';
55
+
56
+ /** Shared run-override shape consumed by both spawn* functions. */
57
+ export type DiscussantRunOverride = (
58
+ opts: SessionRunnerOptions,
59
+ ) => Promise<SessionResult>;
60
+
61
+ /** Options for `spawnDiscussant`. */
62
+ export interface SpawnDiscussantOptions {
63
+ budget: BudgetCap;
64
+ maxTurns: number;
65
+ runOverride?: DiscussantRunOverride;
66
+ cwd: string;
67
+ }
68
+
69
+ /** Options for `spawnDiscussantsParallel`. */
70
+ export interface SpawnDiscussantsParallelOptions {
71
+ concurrency: number;
72
+ budget: BudgetCap;
73
+ maxTurns: number;
74
+ runOverride?: DiscussantRunOverride;
75
+ cwd: string;
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // spawnDiscussant
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Spawn one discussant session via `session-runner.run()`. Parses the
84
+ * DISCUSSION COMPLETE block from `session.final_text` into
85
+ * `DiscussionItem[]`. Parse failures surface as `status: 'parse-error'`
86
+ * with `raw` preserved. Session failures surface as `status: 'error'`
87
+ * with `error` populated and `items: []`.
88
+ *
89
+ * NEVER throws — every failure mode becomes a typed Contribution.
90
+ */
91
+ export async function spawnDiscussant(
92
+ spec: DiscussantSpec,
93
+ opts: SpawnDiscussantOptions,
94
+ ): Promise<DiscussionContribution> {
95
+ const logger = getLogger();
96
+ const runImpl = opts.runOverride ?? defaultRun;
97
+
98
+ // Resolve per-discussant allowedTools from agent frontmatter (if any).
99
+ // `undefined` = stage default; empty list = MCP-only.
100
+ let allowedTools: readonly string[] | undefined;
101
+ if (spec.agentPath !== undefined && spec.agentPath !== '') {
102
+ const parsed = parseAgentTools(spec.agentPath);
103
+ // parseAgentTools returns null when the file is missing or the
104
+ // frontmatter is absent — we treat null the same as undefined
105
+ // (no override → stage default).
106
+ if (parsed !== null) {
107
+ allowedTools = parsed;
108
+ }
109
+ }
110
+
111
+ const sessionOpts: SessionRunnerOptions = {
112
+ prompt: spec.prompt,
113
+ stage: 'custom',
114
+ budget: opts.budget,
115
+ turnCap: { maxTurns: opts.maxTurns },
116
+ };
117
+ if (allowedTools !== undefined) {
118
+ sessionOpts.allowedTools = [...allowedTools];
119
+ }
120
+
121
+ logger.info('discuss.discussant.started', {
122
+ discussant: spec.name,
123
+ cwd: opts.cwd,
124
+ });
125
+
126
+ const result = await runImpl(sessionOpts);
127
+
128
+ // Session-level failures (budget, turn cap, aborted, error) all
129
+ // surface as status: 'error'. Items stay empty.
130
+ if (result.status !== 'completed') {
131
+ const contribution: DiscussionContribution = {
132
+ discussant: spec.name,
133
+ items: Object.freeze([]),
134
+ raw: result.final_text ?? '',
135
+ usage: { ...result.usage },
136
+ status: 'error',
137
+ };
138
+ if (result.error !== undefined) {
139
+ contribution.error = {
140
+ code: result.error.code,
141
+ message: result.error.message,
142
+ };
143
+ } else {
144
+ contribution.error = {
145
+ code: 'SESSION_FAILED',
146
+ message: `session ended with status: ${result.status}`,
147
+ };
148
+ }
149
+ logger.warn('discuss.discussant.error', {
150
+ discussant: spec.name,
151
+ status: result.status,
152
+ code: contribution.error.code,
153
+ });
154
+ return contribution;
155
+ }
156
+
157
+ const raw = result.final_text ?? '';
158
+ const parsed = parseDiscussionBlock(raw);
159
+
160
+ if (parsed === null) {
161
+ logger.warn('discuss.discussant.parse_error', {
162
+ discussant: spec.name,
163
+ reason: 'missing or malformed DISCUSSION COMPLETE block',
164
+ });
165
+ return {
166
+ discussant: spec.name,
167
+ items: Object.freeze([]),
168
+ raw,
169
+ usage: { ...result.usage },
170
+ status: 'parse-error',
171
+ };
172
+ }
173
+
174
+ logger.info('discuss.discussant.completed', {
175
+ discussant: spec.name,
176
+ items: parsed.length,
177
+ });
178
+
179
+ return {
180
+ discussant: spec.name,
181
+ items: parsed,
182
+ raw,
183
+ usage: { ...result.usage },
184
+ status: 'completed',
185
+ };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // spawnDiscussantsParallel
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /**
193
+ * Spawn N discussants with a semaphore bounding concurrency. All
194
+ * discussants START as soon as a slot is free; none cascade on error.
195
+ *
196
+ * Output order: matches input `specs` order (NOT completion order).
197
+ * Implementation detail: each slot runs `spawnDiscussant`; the outer
198
+ * `Promise.all` preserves index → contribution mapping.
199
+ *
200
+ * NEVER throws — per-discussant failures are captured as contribution
201
+ * records with `status: 'error'` or `'parse-error'`.
202
+ */
203
+ export async function spawnDiscussantsParallel(
204
+ specs: readonly DiscussantSpec[],
205
+ opts: SpawnDiscussantsParallelOptions,
206
+ ): Promise<readonly DiscussionContribution[]> {
207
+ const concurrency = Math.max(1, Math.floor(opts.concurrency));
208
+ const results: Array<DiscussionContribution | undefined> = new Array(specs.length);
209
+
210
+ // Semaphore via next-pointer: each worker pulls the next unclaimed
211
+ // index until the list is exhausted. This preserves order in
212
+ // `results[]` because workers write to their claimed index.
213
+ let nextIndex = 0;
214
+ const total = specs.length;
215
+
216
+ async function worker(): Promise<void> {
217
+ while (true) {
218
+ const idx = nextIndex;
219
+ nextIndex += 1;
220
+ if (idx >= total) return;
221
+ const spec = specs[idx];
222
+ if (spec === undefined) continue;
223
+ const spawnOpts: SpawnDiscussantOptions = {
224
+ budget: opts.budget,
225
+ maxTurns: opts.maxTurns,
226
+ cwd: opts.cwd,
227
+ };
228
+ if (opts.runOverride !== undefined) {
229
+ spawnOpts.runOverride = opts.runOverride;
230
+ }
231
+ // spawnDiscussant never throws, so the worker never aborts on
232
+ // one discussant's failure.
233
+ const contribution = await spawnDiscussant(spec, spawnOpts);
234
+ results[idx] = contribution;
235
+ }
236
+ }
237
+
238
+ const workers: Array<Promise<void>> = [];
239
+ const workerCount = Math.min(concurrency, total);
240
+ for (let i = 0; i < workerCount; i += 1) {
241
+ workers.push(worker());
242
+ }
243
+ await Promise.all(workers);
244
+
245
+ // Replace any undefined slots (shouldn't happen, but defensively
246
+ // surface as an error contribution so callers never see undefined).
247
+ const finalized: DiscussionContribution[] = [];
248
+ for (let i = 0; i < results.length; i += 1) {
249
+ const entry = results[i];
250
+ if (entry === undefined) {
251
+ const spec = specs[i];
252
+ finalized.push({
253
+ discussant: spec?.name ?? 'unknown',
254
+ items: Object.freeze([]),
255
+ raw: '',
256
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
257
+ status: 'error',
258
+ error: {
259
+ code: 'INTERNAL_SCHEDULER_ERROR',
260
+ message: 'discussant slot was never claimed',
261
+ },
262
+ });
263
+ } else {
264
+ finalized.push(entry);
265
+ }
266
+ }
267
+ return Object.freeze(finalized);
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // parseDiscussionBlock — pure parser
272
+ // ---------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Parse a DISCUSSION COMPLETE block from the given text. Returns null
276
+ * when the block is absent. Malformed individual items are skipped
277
+ * with a logger.warn; the function never throws.
278
+ *
279
+ * Heading match is case-insensitive; the block extends from the
280
+ * `## DISCUSSION COMPLETE` line to end-of-text (or the next top-level
281
+ * `## ` heading — whichever is first).
282
+ */
283
+ export function parseDiscussionBlock(text: string): readonly DiscussionItem[] | null {
284
+ const logger = getLogger();
285
+
286
+ // Locate the DISCUSSION COMPLETE heading, case-insensitive.
287
+ // Use a regex that anchors on a `## ` or start-of-line with optional
288
+ // leading whitespace for robustness.
289
+ const headingRe = /^[ \t]*##[ \t]+DISCUSSION[ \t]+COMPLETE[ \t]*$/im;
290
+ const headingMatch = headingRe.exec(text);
291
+ if (headingMatch === null) return null;
292
+
293
+ // Slice from after the heading.
294
+ const afterHeading = text.slice(headingMatch.index + headingMatch[0].length);
295
+
296
+ // Find the end of the block: next top-level `## ` heading, else end.
297
+ // We allow `###` subheadings inside the block.
298
+ const nextBlockRe = /^[ \t]*##[ \t]+(?!#)/m;
299
+ const endMatch = nextBlockRe.exec(afterHeading);
300
+ const blockText = endMatch === null
301
+ ? afterHeading
302
+ : afterHeading.slice(0, endMatch.index);
303
+
304
+ // Split into Questions + Concerns subsections.
305
+ const lines = blockText.split(/\r?\n/);
306
+ const items: DiscussionItem[] = [];
307
+
308
+ // Walk line-by-line, tracking active subsection kind.
309
+ // 'question' when we're in a ### Questions subsection
310
+ // 'concern' when we're in a ### Concerns subsection
311
+ // null outside any subsection
312
+ let sectionKind: 'question' | 'concern' | null = null;
313
+ let pending: PendingItem | null = null;
314
+
315
+ const flushPending = (): void => {
316
+ if (pending === null) return;
317
+ const text = pending.text.trim();
318
+ if (text === '') {
319
+ logger.warn('discuss.parse.skipped_item', {
320
+ reason: 'empty text',
321
+ kind: pending.kind,
322
+ });
323
+ pending = null;
324
+ return;
325
+ }
326
+ const item: DiscussionItem = {
327
+ kind: pending.kind,
328
+ text,
329
+ severity: normalizeSeverity(pending.severity),
330
+ };
331
+ if (pending.tag !== undefined) item.tag = pending.tag;
332
+ if (pending.rationale !== undefined) item.rationale = pending.rationale;
333
+ items.push(item);
334
+ pending = null;
335
+ };
336
+
337
+ for (const rawLine of lines) {
338
+ const line = rawLine;
339
+ const trimmed = line.trim();
340
+
341
+ // Subsection heading: ### Questions / ### Concerns
342
+ const subMatch = /^[ \t]*###[ \t]+(Questions|Concerns)[ \t]*$/i.exec(trimmed);
343
+ if (subMatch !== null) {
344
+ flushPending();
345
+ const label = (subMatch[1] ?? '').toLowerCase();
346
+ sectionKind = label === 'questions' ? 'question' : 'concern';
347
+ continue;
348
+ }
349
+
350
+ // Item start: - Q: ... or - C: ... (honor explicit kind marker over section)
351
+ const itemMatch = /^[ \t]*-[ \t]+([QqCc]):[ \t]*(.*)$/.exec(line);
352
+ if (itemMatch !== null) {
353
+ flushPending();
354
+ const markerRaw = itemMatch[1] ?? '';
355
+ const marker = markerRaw.toUpperCase();
356
+ const textRest = (itemMatch[2] ?? '').trim();
357
+ // If inside a section, trust the section kind; otherwise infer
358
+ // from the marker. The explicit marker is also honored even
359
+ // when it disagrees with the section (e.g., a `- Q:` inside
360
+ // Concerns is treated as a question).
361
+ const kind: 'question' | 'concern' =
362
+ marker === 'Q' ? 'question'
363
+ : marker === 'C' ? 'concern'
364
+ : sectionKind ?? 'question';
365
+ pending = {
366
+ kind,
367
+ text: textRest,
368
+ };
369
+ continue;
370
+ }
371
+
372
+ // Field line: indented Concern: / Area: / Severity: / Rationale:
373
+ const fieldMatch = /^[ \t]+(Concern|Area|Severity|Rationale):[ \t]*(.*)$/.exec(line);
374
+ if (fieldMatch !== null && pending !== null) {
375
+ const fieldRaw = fieldMatch[1] ?? '';
376
+ const field = fieldRaw.toLowerCase();
377
+ const value = (fieldMatch[2] ?? '').trim();
378
+ if (field === 'concern' || field === 'area') {
379
+ if (value !== '') pending.tag = value;
380
+ } else if (field === 'severity') {
381
+ pending.severity = value;
382
+ } else if (field === 'rationale') {
383
+ if (value !== '') pending.rationale = value;
384
+ }
385
+ continue;
386
+ }
387
+
388
+ // Blank lines or irrelevant prose: leave pending open so a trailing
389
+ // field on the next line still attaches to the same item.
390
+ if (trimmed === '') continue;
391
+
392
+ // Unrecognized non-blank line inside a section terminates the current
393
+ // item but does not abort parsing — this is the "lenient" rule.
394
+ // We flush and keep walking.
395
+ if (pending !== null && !line.startsWith(' ') && !line.startsWith('\t')) {
396
+ flushPending();
397
+ }
398
+ }
399
+
400
+ flushPending();
401
+
402
+ return Object.freeze(items);
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // Internal helpers
407
+ // ---------------------------------------------------------------------------
408
+
409
+ interface PendingItem {
410
+ kind: 'question' | 'concern';
411
+ text: string;
412
+ tag?: string;
413
+ severity?: string;
414
+ rationale?: string;
415
+ }
416
+
417
+ /**
418
+ * Normalize a raw severity string to the `Severity` union. Unknown /
419
+ * empty / missing values fall back to `'minor'` per the parse rules.
420
+ */
421
+ function normalizeSeverity(raw: string | undefined): Severity {
422
+ if (raw === undefined) return 'minor';
423
+ const v = raw.trim().toLowerCase();
424
+ if (v === '') return 'minor';
425
+ if (v === 'blocker' || v === 'critical') return 'blocker';
426
+ if (v === 'major' || v === 'high') return 'major';
427
+ if (v === 'minor' || v === 'low') return 'minor';
428
+ if (v === 'nice-to-have' || v === 'nice to have' || v === 'nth') return 'nice-to-have';
429
+ return 'minor';
430
+ }
@@ -0,0 +1,223 @@
1
+ // scripts/lib/discuss-parallel-runner/index.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Top-level orchestrator for the parallel discussion runner.
4
+ //
5
+ // Public surface:
6
+ // * run(opts) — the entry point. Spawns N discussants,
7
+ // aggregates their contributions, returns
8
+ // typed DiscussRunnerResult.
9
+ // * DEFAULT_DISCUSSANTS — the 4-variant default roster (frozen).
10
+ // * Re-exports — every type + named function from the
11
+ // three internal modules so consumers need
12
+ // only one import site.
13
+ //
14
+ // Algorithm (per PLAN.md Task 4):
15
+ // 1. specs = opts.discussants ?? DEFAULT_DISCUSSANTS.
16
+ // 2. Spawn all via `spawnDiscussantsParallel` with concurrency
17
+ // default 4.
18
+ // 3. Keep ALL contributions in the return value (successful + failed).
19
+ // The aggregator only receives the successful ones.
20
+ // 4. If zero successful contributions → throw OperationFailedError
21
+ // code 'NO_DISCUSSANTS_SUCCEEDED'.
22
+ // 5. Run `spawnAggregator(successfulContributions, {...})` with the
23
+ // separate aggregator budget + max turns.
24
+ // 6. Aggregate usage: sum per-discussant usage + aggregator usage.
25
+ // 7. Return DiscussRunnerResult.
26
+ //
27
+ // Consumers: `discuss` skill (Plan 21-08 / future) + `gdd-sdk discuss`
28
+ // CLI subcommand (Plan 21-09).
29
+
30
+ import { OperationFailedError } from '../gdd-errors/index.ts';
31
+ import { getLogger } from '../logger/index.ts';
32
+
33
+ import {
34
+ spawnAggregator,
35
+ } from './aggregator.ts';
36
+ import {
37
+ spawnDiscussantsParallel,
38
+ } from './discussants.ts';
39
+ import type {
40
+ DiscussantSpec,
41
+ DiscussionContribution,
42
+ DiscussRunnerOptions,
43
+ DiscussRunnerResult,
44
+ } from './types.ts';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Re-exports — one import site for consumers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type {
51
+ AggregatedDiscussion,
52
+ AggregatedQuestion,
53
+ DiscussantName,
54
+ DiscussantSpec,
55
+ DiscussionContribution,
56
+ DiscussionItem,
57
+ DiscussRunnerOptions,
58
+ DiscussRunnerResult,
59
+ Severity,
60
+ } from './types.ts';
61
+
62
+ export {
63
+ parseDiscussionBlock,
64
+ spawnDiscussant,
65
+ spawnDiscussantsParallel,
66
+ } from './discussants.ts';
67
+ export type {
68
+ DiscussantRunOverride,
69
+ SpawnDiscussantOptions,
70
+ SpawnDiscussantsParallelOptions,
71
+ } from './discussants.ts';
72
+
73
+ export {
74
+ buildAggregatorPrompt,
75
+ computeQuestionKey,
76
+ parseAggregatorOutput,
77
+ spawnAggregator,
78
+ } from './aggregator.ts';
79
+ export type {
80
+ AggregatorRunOverride,
81
+ SpawnAggregatorOptions,
82
+ } from './aggregator.ts';
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // DEFAULT_DISCUSSANTS
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Default discussant roster — four variants covering user-journey,
90
+ * technical-constraint, brand-fit, accessibility angles. Frozen so
91
+ * callers can safely spread into new arrays without worrying about
92
+ * mutation.
93
+ */
94
+ export const DEFAULT_DISCUSSANTS: readonly DiscussantSpec[] = Object.freeze([
95
+ Object.freeze({
96
+ name: 'user-journey',
97
+ prompt:
98
+ 'You are a UX researcher reviewing the design brief. Surface friction points in the user journey you would want to validate. Emit the DISCUSSION COMPLETE block at the end.',
99
+ }),
100
+ Object.freeze({
101
+ name: 'technical-constraint',
102
+ prompt:
103
+ 'You are a senior engineer reviewing the design brief. Surface feasibility, performance, and cross-platform concerns. Emit the DISCUSSION COMPLETE block at the end.',
104
+ }),
105
+ Object.freeze({
106
+ name: 'brand-fit',
107
+ prompt:
108
+ 'You are a brand director reviewing the design brief. Surface brand-archetype misalignment or visual-tone questions. Emit the DISCUSSION COMPLETE block at the end.',
109
+ }),
110
+ Object.freeze({
111
+ name: 'accessibility',
112
+ prompt:
113
+ 'You are an accessibility specialist reviewing the design brief. Surface inclusion concerns you would need answered. Emit the DISCUSSION COMPLETE block at the end.',
114
+ }),
115
+ ]);
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // run — top-level orchestrator
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * Orchestrate a parallel discussion run.
123
+ *
124
+ * Failure modes:
125
+ * * Zero successful discussants → `OperationFailedError` code
126
+ * `'NO_DISCUSSANTS_SUCCEEDED'` (with per-discussant errors in context).
127
+ * * Aggregator parse failure → `ValidationError` code
128
+ * `'AGGREGATOR_PARSE_ERROR'` (propagated from spawnAggregator).
129
+ * * Aggregator session failure → `ValidationError` code
130
+ * `'AGGREGATOR_SESSION_FAILED'`.
131
+ *
132
+ * Per-discussant failures do NOT abort the run — they surface as
133
+ * `status !== 'completed'` contributions in the return value.
134
+ */
135
+ export async function run(
136
+ opts: DiscussRunnerOptions,
137
+ ): Promise<DiscussRunnerResult> {
138
+ const logger = getLogger();
139
+ const specs = opts.discussants ?? DEFAULT_DISCUSSANTS;
140
+ const concurrency = opts.concurrency !== undefined && opts.concurrency > 0
141
+ ? opts.concurrency
142
+ : 4;
143
+ const cwd = opts.cwd ?? process.cwd();
144
+
145
+ logger.info('discuss.runner.started', {
146
+ discussants: specs.length,
147
+ concurrency,
148
+ cwd,
149
+ });
150
+
151
+ const contributions = await spawnDiscussantsParallel(specs, {
152
+ concurrency,
153
+ budget: opts.budget,
154
+ maxTurns: opts.maxTurnsPerDiscussant,
155
+ ...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
156
+ cwd,
157
+ });
158
+
159
+ const successful: DiscussionContribution[] = contributions.filter(
160
+ (c): c is DiscussionContribution => c.status === 'completed',
161
+ );
162
+
163
+ if (successful.length === 0) {
164
+ // Collect per-discussant error details for the operator.
165
+ const errorSummary = contributions.map((c) => ({
166
+ discussant: c.discussant,
167
+ status: c.status,
168
+ error: c.error ?? null,
169
+ }));
170
+ logger.error('discuss.runner.no_successes', {
171
+ attempted: contributions.length,
172
+ errors: errorSummary,
173
+ });
174
+ throw new OperationFailedError(
175
+ `all ${contributions.length} discussants failed — aggregator cannot run`,
176
+ 'NO_DISCUSSANTS_SUCCEEDED',
177
+ { attempted: contributions.length, contributions: errorSummary },
178
+ );
179
+ }
180
+
181
+ const aggregated = await spawnAggregator(successful, {
182
+ budget: opts.aggregatorBudget,
183
+ maxTurns: opts.aggregatorMaxTurns,
184
+ ...(opts.runOverride !== undefined ? { runOverride: opts.runOverride } : {}),
185
+ cwd,
186
+ ...(opts.aggregatorPrompt !== undefined
187
+ ? { customPrompt: opts.aggregatorPrompt }
188
+ : {}),
189
+ });
190
+
191
+ // Aggregate usage = sum(contributions.usage) + aggregated.usage.
192
+ let totalInput = 0;
193
+ let totalOutput = 0;
194
+ let totalCost = 0;
195
+ for (const c of contributions) {
196
+ totalInput += c.usage.input_tokens;
197
+ totalOutput += c.usage.output_tokens;
198
+ totalCost += c.usage.usd_cost;
199
+ }
200
+ totalInput += aggregated.usage.input_tokens;
201
+ totalOutput += aggregated.usage.output_tokens;
202
+ totalCost += aggregated.usage.usd_cost;
203
+
204
+ logger.info('discuss.runner.completed', {
205
+ attempted: contributions.length,
206
+ successful: successful.length,
207
+ themes: aggregated.themes.length,
208
+ questions: aggregated.questions.length,
209
+ total_input_tokens: totalInput,
210
+ total_output_tokens: totalOutput,
211
+ total_usd_cost: totalCost,
212
+ });
213
+
214
+ return {
215
+ contributions,
216
+ aggregated,
217
+ total_usage: {
218
+ input_tokens: totalInput,
219
+ output_tokens: totalOutput,
220
+ usd_cost: totalCost,
221
+ },
222
+ };
223
+ }