@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,448 @@
1
+ // scripts/lib/discuss-parallel-runner/aggregator.ts — Plan 21-07 (SDK-19).
2
+ //
3
+ // Aggregator session + AggregatedDiscussion parser.
4
+ //
5
+ // Public surface:
6
+ // * buildAggregatorPrompt — construct the aggregator prompt from N
7
+ // DiscussionContributions (instructs dedup /
8
+ // cluster / rank / emit JSON block).
9
+ // * spawnAggregator — session-runner call → parse output → write
10
+ // Markdown to outputPath.
11
+ // * parseAggregatorOutput — parse a final_text containing a Markdown
12
+ // discussion + trailing ```json fence with
13
+ // { themes, questions }.
14
+ //
15
+ // Key format: AggregatedQuestion.key is SHA-256 of the normalized
16
+ // question text (lowercase, whitespace-collapsed) truncated to 8 hex
17
+ // chars. This function is available on `computeQuestionKey` for tests
18
+ // that want to assert stable keys.
19
+
20
+ import { createHash } from 'node:crypto';
21
+ import { mkdirSync, writeFileSync } from 'node:fs';
22
+ import { dirname } from 'node:path';
23
+
24
+ import { run as defaultRun } from '../session-runner/index.ts';
25
+ import type {
26
+ BudgetCap,
27
+ SessionResult,
28
+ SessionRunnerOptions,
29
+ } from '../session-runner/types.ts';
30
+ import { ValidationError } from '../gdd-errors/index.ts';
31
+ import { getLogger } from '../logger/index.ts';
32
+
33
+ import type {
34
+ AggregatedDiscussion,
35
+ AggregatedQuestion,
36
+ DiscussantName,
37
+ DiscussionContribution,
38
+ Severity,
39
+ } from './types.ts';
40
+
41
+ /** Shared run-override shape. */
42
+ export type AggregatorRunOverride = (
43
+ opts: SessionRunnerOptions,
44
+ ) => Promise<SessionResult>;
45
+
46
+ /** Options for `spawnAggregator`. */
47
+ export interface SpawnAggregatorOptions {
48
+ budget: BudgetCap;
49
+ maxTurns: number;
50
+ runOverride?: AggregatorRunOverride;
51
+ cwd: string;
52
+ customPrompt?: string;
53
+ /** Output path for the aggregated Markdown. Default: `.design/DISCUSSION.md`. */
54
+ outputPath?: string;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Default aggregator prompt
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Default aggregator prompt. Instructs the model to dedupe, cluster,
63
+ * rank, and emit both Markdown + a machine-readable JSON fence.
64
+ *
65
+ * The prompt is a literal so tests can assert its structure without
66
+ * cross-referencing another file.
67
+ */
68
+ const DEFAULT_AGGREGATOR_INSTRUCTION = [
69
+ 'You are the discussion aggregator. Below are N discussant contributions, each',
70
+ 'listing questions + concerns from a different angle. Your job:',
71
+ '',
72
+ '1. Dedupe: collapse near-duplicate questions into one.',
73
+ '2. Cluster: assign each merged question to a named theme.',
74
+ '3. Rank: order questions by severity (blocker > major > minor > nice-to-have),',
75
+ ' breaking ties by frequency (how many discussants raised it).',
76
+ '4. Emit Markdown at .design/DISCUSSION.md with theme sections + ranked question',
77
+ ' list.',
78
+ '5. Append a machine-readable JSON block at the end:',
79
+ '',
80
+ '```json',
81
+ '{',
82
+ ' "themes": [{"name": "...", "summary": "..."}],',
83
+ ' "questions": [{"key": "hash", "text": "...", "severity": "...",',
84
+ ' "raised_by": ["..."], "theme": "...", "rank": 0}]',
85
+ '}',
86
+ '```',
87
+ '',
88
+ 'Key rule: `key` is the SHA-256 of the normalized question text (lowercase,',
89
+ 'whitespace-collapsed) truncated to 8 hex chars. Use this for stable cross-run',
90
+ 'identity.',
91
+ '',
92
+ 'Contributions follow:',
93
+ ].join('\n');
94
+
95
+ /** Marker string used to separate instruction from contribution payload. */
96
+ const CONTRIBUTIONS_SEPARATOR = '\n\n---\n\n';
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // buildAggregatorPrompt
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Build an aggregator prompt from N DiscussionContributions. The
104
+ * instruction block is the default above (or `customPrompt` if
105
+ * supplied), followed by `---` separator, followed by each contribution
106
+ * serialized with a header:
107
+ *
108
+ * ### Discussant: <name>
109
+ * <raw body>
110
+ *
111
+ * Contributions with empty `raw` still emit the header so the aggregator
112
+ * sees every discussant's presence.
113
+ */
114
+ export function buildAggregatorPrompt(
115
+ contributions: readonly DiscussionContribution[],
116
+ customPrompt?: string,
117
+ ): string {
118
+ const instruction = customPrompt !== undefined && customPrompt !== ''
119
+ ? customPrompt
120
+ : DEFAULT_AGGREGATOR_INSTRUCTION;
121
+
122
+ const body: string[] = [];
123
+ for (const c of contributions) {
124
+ body.push(`### Discussant: ${c.discussant}`);
125
+ body.push('');
126
+ body.push(c.raw);
127
+ body.push('');
128
+ }
129
+
130
+ if (body.length === 0) {
131
+ return instruction;
132
+ }
133
+
134
+ return instruction + CONTRIBUTIONS_SEPARATOR + body.join('\n').trimEnd();
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // spawnAggregator
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Spawn the aggregator session via `session-runner.run()`. Parses the
143
+ * final_text with `parseAggregatorOutput`. Always writes the parsed
144
+ * Markdown to `opts.outputPath` (defaults to `.design/DISCUSSION.md`).
145
+ *
146
+ * Throws `ValidationError('AGGREGATOR_PARSE_ERROR')` when the JSON
147
+ * fence is absent/malformed; the caller decides whether that's fatal.
148
+ * Session-level failures (budget / turn cap / abort / error) also
149
+ * throw `OperationFailedError`-shaped errors via an
150
+ * `AGGREGATOR_SESSION_FAILED` code.
151
+ */
152
+ export async function spawnAggregator(
153
+ contributions: readonly DiscussionContribution[],
154
+ opts: SpawnAggregatorOptions,
155
+ ): Promise<AggregatedDiscussion> {
156
+ const logger = getLogger();
157
+ const runImpl = opts.runOverride ?? defaultRun;
158
+ const outputPath = opts.outputPath ?? '.design/DISCUSSION.md';
159
+
160
+ const prompt = buildAggregatorPrompt(
161
+ contributions,
162
+ opts.customPrompt,
163
+ );
164
+
165
+ const sessionOpts: SessionRunnerOptions = {
166
+ prompt,
167
+ stage: 'custom',
168
+ budget: opts.budget,
169
+ turnCap: { maxTurns: opts.maxTurns },
170
+ };
171
+
172
+ logger.info('discuss.aggregator.started', {
173
+ contributions: contributions.length,
174
+ output_path: outputPath,
175
+ });
176
+
177
+ const result = await runImpl(sessionOpts);
178
+
179
+ if (result.status !== 'completed') {
180
+ const code = 'AGGREGATOR_SESSION_FAILED';
181
+ const message = `aggregator session ended with status: ${result.status}`;
182
+ logger.error('discuss.aggregator.session_failed', {
183
+ status: result.status,
184
+ code,
185
+ });
186
+ throw new ValidationError(message, code, {
187
+ status: result.status,
188
+ ...(result.error !== undefined ? { session_error: result.error } : {}),
189
+ });
190
+ }
191
+
192
+ const aggregated = parseAggregatorOutput(
193
+ result.final_text ?? '',
194
+ outputPath,
195
+ );
196
+
197
+ // Overwrite usage with this session's numbers (parseAggregatorOutput
198
+ // returns {0,0,0} since it has no session context).
199
+ const withUsage: AggregatedDiscussion = {
200
+ themes: aggregated.themes,
201
+ questions: aggregated.questions,
202
+ output_path: aggregated.output_path,
203
+ usage: { ...result.usage },
204
+ };
205
+
206
+ logger.info('discuss.aggregator.completed', {
207
+ themes: withUsage.themes.length,
208
+ questions: withUsage.questions.length,
209
+ output_path: outputPath,
210
+ });
211
+
212
+ return withUsage;
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // parseAggregatorOutput
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /**
220
+ * Parse a ```json fenced block containing { themes, questions } into
221
+ * an AggregatedDiscussion. Writes the Markdown portion (everything
222
+ * before the LAST json fence) to `outputPath` as a side effect.
223
+ *
224
+ * Parse rules:
225
+ * * LAST `` ```json ... ``` `` fence wins (the prompt may show an
226
+ * example fence earlier; the final answer is always last).
227
+ * * JSON.parse the fence body. Validates:
228
+ * themes: array of { name, summary }
229
+ * questions: array of { key, text, severity, raised_by, theme, rank }
230
+ * * On malformed JSON or missing fields: throws
231
+ * `ValidationError('AGGREGATOR_PARSE_ERROR')` with the final-text
232
+ * tail in context for operator debugging.
233
+ * * `usage` in the return value is zeroed — the caller (spawnAggregator)
234
+ * overwrites with real session usage. parseAggregatorOutput is a
235
+ * pure text→structure function except for the side-effect write.
236
+ */
237
+ export function parseAggregatorOutput(
238
+ finalText: string,
239
+ outputPath: string,
240
+ ): AggregatedDiscussion {
241
+ // Locate the LAST ```json ... ``` fence. The fence opener may have
242
+ // optional whitespace before the triple backticks.
243
+ const fenceRe = /```json\s*\r?\n([\s\S]*?)\r?\n```/g;
244
+ let lastMatch: RegExpExecArray | null = null;
245
+ let m: RegExpExecArray | null;
246
+ while ((m = fenceRe.exec(finalText)) !== null) {
247
+ lastMatch = m;
248
+ }
249
+
250
+ if (lastMatch === null) {
251
+ throw new ValidationError(
252
+ 'aggregator output missing ```json fence',
253
+ 'AGGREGATOR_PARSE_ERROR',
254
+ { final_text_tail: tail(finalText, 500) },
255
+ );
256
+ }
257
+
258
+ const jsonBody = lastMatch[1] ?? '';
259
+
260
+ let parsed: unknown;
261
+ try {
262
+ parsed = JSON.parse(jsonBody);
263
+ } catch (err) {
264
+ const msg = err instanceof Error ? err.message : String(err);
265
+ throw new ValidationError(
266
+ `aggregator JSON.parse failed: ${msg}`,
267
+ 'AGGREGATOR_PARSE_ERROR',
268
+ { final_text_tail: tail(finalText, 500) },
269
+ );
270
+ }
271
+
272
+ const validated = validateAggregatorShape(parsed, finalText);
273
+
274
+ // Extract the Markdown portion (everything BEFORE the last fence).
275
+ const markdown = finalText.slice(0, lastMatch.index).trimEnd() + '\n';
276
+
277
+ // Write Markdown to outputPath (create parent dir if needed).
278
+ try {
279
+ mkdirSync(dirname(outputPath), { recursive: true });
280
+ writeFileSync(outputPath, markdown, 'utf8');
281
+ } catch (err) {
282
+ // Write failures shouldn't abort the parse — they're an I/O
283
+ // problem, not a validation problem. But we log them loudly.
284
+ getLogger().error('discuss.aggregator.write_failed', {
285
+ output_path: outputPath,
286
+ error: err instanceof Error ? err.message : String(err),
287
+ });
288
+ }
289
+
290
+ return {
291
+ themes: validated.themes,
292
+ questions: validated.questions,
293
+ output_path: outputPath,
294
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
295
+ };
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // computeQuestionKey — public helper for stable key generation
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /**
303
+ * Compute a stable AggregatedQuestion.key for a given question text.
304
+ * SHA-256 of the normalized text (lowercase, whitespace-collapsed),
305
+ * truncated to 8 hex chars. Deterministic across runs.
306
+ */
307
+ export function computeQuestionKey(questionText: string): string {
308
+ const normalized = questionText.trim().toLowerCase().replace(/\s+/g, ' ');
309
+ const hash = createHash('sha256').update(normalized, 'utf8').digest('hex');
310
+ return hash.slice(0, 8);
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Internal validators
315
+ // ---------------------------------------------------------------------------
316
+
317
+ interface ValidatedShape {
318
+ themes: readonly { name: string; summary: string }[];
319
+ questions: readonly AggregatedQuestion[];
320
+ }
321
+
322
+ function validateAggregatorShape(raw: unknown, finalText: string): ValidatedShape {
323
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
324
+ throw new ValidationError(
325
+ 'aggregator JSON root must be an object',
326
+ 'AGGREGATOR_PARSE_ERROR',
327
+ { final_text_tail: tail(finalText, 500) },
328
+ );
329
+ }
330
+ const obj = raw as Record<string, unknown>;
331
+
332
+ const rawThemes = obj['themes'];
333
+ if (!Array.isArray(rawThemes)) {
334
+ throw new ValidationError(
335
+ 'aggregator JSON.themes must be an array',
336
+ 'AGGREGATOR_PARSE_ERROR',
337
+ { final_text_tail: tail(finalText, 500) },
338
+ );
339
+ }
340
+ const themes: Array<{ name: string; summary: string }> = [];
341
+ for (let i = 0; i < rawThemes.length; i += 1) {
342
+ const t = rawThemes[i];
343
+ if (t === null || typeof t !== 'object' || Array.isArray(t)) {
344
+ throw new ValidationError(
345
+ `aggregator JSON.themes[${i}] must be an object`,
346
+ 'AGGREGATOR_PARSE_ERROR',
347
+ { final_text_tail: tail(finalText, 500) },
348
+ );
349
+ }
350
+ const th = t as Record<string, unknown>;
351
+ const name = th['name'];
352
+ const summary = th['summary'];
353
+ if (typeof name !== 'string' || typeof summary !== 'string') {
354
+ throw new ValidationError(
355
+ `aggregator JSON.themes[${i}] requires string name + summary`,
356
+ 'AGGREGATOR_PARSE_ERROR',
357
+ { final_text_tail: tail(finalText, 500) },
358
+ );
359
+ }
360
+ themes.push({ name, summary });
361
+ }
362
+
363
+ const rawQuestions = obj['questions'];
364
+ if (!Array.isArray(rawQuestions)) {
365
+ throw new ValidationError(
366
+ 'aggregator JSON.questions must be an array',
367
+ 'AGGREGATOR_PARSE_ERROR',
368
+ { final_text_tail: tail(finalText, 500) },
369
+ );
370
+ }
371
+ const questions: AggregatedQuestion[] = [];
372
+ for (let i = 0; i < rawQuestions.length; i += 1) {
373
+ const q = rawQuestions[i];
374
+ if (q === null || typeof q !== 'object' || Array.isArray(q)) {
375
+ throw new ValidationError(
376
+ `aggregator JSON.questions[${i}] must be an object`,
377
+ 'AGGREGATOR_PARSE_ERROR',
378
+ { final_text_tail: tail(finalText, 500) },
379
+ );
380
+ }
381
+ const qr = q as Record<string, unknown>;
382
+ const key = qr['key'];
383
+ const text = qr['text'];
384
+ const severity = qr['severity'];
385
+ const raisedBy = qr['raised_by'];
386
+ const theme = qr['theme'];
387
+ const rank = qr['rank'];
388
+ if (
389
+ typeof key !== 'string' ||
390
+ typeof text !== 'string' ||
391
+ typeof severity !== 'string' ||
392
+ !Array.isArray(raisedBy) ||
393
+ typeof theme !== 'string' ||
394
+ typeof rank !== 'number'
395
+ ) {
396
+ throw new ValidationError(
397
+ `aggregator JSON.questions[${i}] missing required fields`,
398
+ 'AGGREGATOR_PARSE_ERROR',
399
+ { final_text_tail: tail(finalText, 500) },
400
+ );
401
+ }
402
+ // Validate raised_by entries are strings.
403
+ const rbStrings: DiscussantName[] = [];
404
+ for (let j = 0; j < raisedBy.length; j += 1) {
405
+ const v = raisedBy[j];
406
+ if (typeof v !== 'string') {
407
+ throw new ValidationError(
408
+ `aggregator JSON.questions[${i}].raised_by[${j}] must be a string`,
409
+ 'AGGREGATOR_PARSE_ERROR',
410
+ { final_text_tail: tail(finalText, 500) },
411
+ );
412
+ }
413
+ rbStrings.push(v);
414
+ }
415
+ // Coerce severity to the union (lenient — treat unknowns as 'minor').
416
+ const sev = coerceSeverity(severity);
417
+ questions.push({
418
+ key,
419
+ text,
420
+ severity: sev,
421
+ raised_by: Object.freeze(rbStrings),
422
+ theme,
423
+ rank,
424
+ });
425
+ }
426
+
427
+ return {
428
+ themes: Object.freeze(themes),
429
+ questions: Object.freeze(questions),
430
+ };
431
+ }
432
+
433
+ function coerceSeverity(raw: string): Severity {
434
+ const v = raw.trim().toLowerCase();
435
+ if (v === 'blocker') return 'blocker';
436
+ if (v === 'major') return 'major';
437
+ if (v === 'minor') return 'minor';
438
+ if (v === 'nice-to-have' || v === 'nice to have') return 'nice-to-have';
439
+ // Lenient fallback — unknown severity values become 'minor' rather
440
+ // than throwing. The aggregator prompt constrains the model, but we
441
+ // shouldn't make the entire parse fail over a severity typo.
442
+ return 'minor';
443
+ }
444
+
445
+ function tail(s: string, n: number): string {
446
+ if (s.length <= n) return s;
447
+ return s.slice(s.length - n);
448
+ }