@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,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
|
+
}
|