@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.
- package/.claude-plugin/marketplace.json +9 -12
- package/.claude-plugin/plugin.json +8 -31
- package/CHANGELOG.md +200 -0
- package/README.md +48 -7
- package/bin/gdd-sdk +55 -0
- package/hooks/_hook-emit.js +81 -0
- package/hooks/gdd-bash-guard.js +8 -0
- package/hooks/gdd-decision-injector.js +2 -0
- package/hooks/gdd-protected-paths.js +8 -0
- package/hooks/gdd-trajectory-capture.js +64 -0
- package/hooks/hooks.json +9 -0
- package/package.json +19 -47
- package/reference/codex-tools.md +53 -0
- package/reference/gemini-tools.md +53 -0
- package/reference/registry.json +14 -0
- package/scripts/cli/gdd-events.mjs +283 -0
- package/scripts/e2e/run-headless.ts +514 -0
- package/scripts/lib/cli/commands/audit.ts +382 -0
- package/scripts/lib/cli/commands/init.ts +217 -0
- package/scripts/lib/cli/commands/query.ts +329 -0
- package/scripts/lib/cli/commands/run.ts +656 -0
- package/scripts/lib/cli/commands/stage.ts +468 -0
- package/scripts/lib/cli/index.ts +167 -0
- package/scripts/lib/cli/parse-args.ts +336 -0
- package/scripts/lib/connection-probe/index.cjs +263 -0
- package/scripts/lib/context-engine/index.ts +116 -0
- package/scripts/lib/context-engine/manifest.ts +69 -0
- package/scripts/lib/context-engine/truncate.ts +282 -0
- package/scripts/lib/context-engine/types.ts +59 -0
- package/scripts/lib/discuss-parallel-runner/aggregator.ts +448 -0
- package/scripts/lib/discuss-parallel-runner/discussants.ts +430 -0
- package/scripts/lib/discuss-parallel-runner/index.ts +223 -0
- package/scripts/lib/discuss-parallel-runner/types.ts +184 -0
- package/scripts/lib/event-chain.cjs +177 -0
- package/scripts/lib/event-stream/index.ts +31 -1
- package/scripts/lib/event-stream/reader.ts +139 -0
- package/scripts/lib/event-stream/types.ts +155 -1
- package/scripts/lib/event-stream/writer.ts +65 -8
- package/scripts/lib/explore-parallel-runner/index.ts +294 -0
- package/scripts/lib/explore-parallel-runner/mappers.ts +290 -0
- package/scripts/lib/explore-parallel-runner/synthesizer.ts +295 -0
- package/scripts/lib/explore-parallel-runner/types.ts +139 -0
- package/scripts/lib/harness/detect.ts +90 -0
- package/scripts/lib/harness/index.ts +64 -0
- package/scripts/lib/harness/tool-map.ts +142 -0
- package/scripts/lib/init-runner/index.ts +396 -0
- package/scripts/lib/init-runner/researchers.ts +245 -0
- package/scripts/lib/init-runner/scaffold.ts +224 -0
- package/scripts/lib/init-runner/synthesizer.ts +224 -0
- package/scripts/lib/init-runner/types.ts +143 -0
- package/scripts/lib/logger/index.ts +251 -0
- package/scripts/lib/logger/sinks.ts +269 -0
- package/scripts/lib/logger/types.ts +110 -0
- package/scripts/lib/pipeline-runner/human-gate.ts +134 -0
- package/scripts/lib/pipeline-runner/index.ts +527 -0
- package/scripts/lib/pipeline-runner/stage-handlers.ts +339 -0
- package/scripts/lib/pipeline-runner/state-machine.ts +144 -0
- package/scripts/lib/pipeline-runner/types.ts +183 -0
- package/scripts/lib/redact.cjs +122 -0
- package/scripts/lib/session-runner/errors.ts +406 -0
- package/scripts/lib/session-runner/index.ts +715 -0
- package/scripts/lib/session-runner/transcript.ts +189 -0
- package/scripts/lib/session-runner/types.ts +144 -0
- package/scripts/lib/tool-scoping/index.ts +219 -0
- package/scripts/lib/tool-scoping/parse-agent-tools.ts +207 -0
- package/scripts/lib/tool-scoping/stage-scopes.ts +139 -0
- package/scripts/lib/tool-scoping/types.ts +77 -0
- package/scripts/lib/trajectory/index.cjs +126 -0
- 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
|
+
}
|