ravensight-playtest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,658 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parseJsonArray, parseJsonObject } from './json.js';
5
+ import { callStepWithRetry, ProxyError, STEPS } from './model.js';
6
+ import { JOB_FILES } from './paths.js';
7
+ import { assertNoSecrets } from './secretScan.js';
8
+
9
+ /**
10
+ * The aggregate: every terminal run of a job merged into the one report a
11
+ * developer actually reads (the `aggregate-report` skill, spec 11 section 3).
12
+ *
13
+ * Two passes, and only the first of them needs a model:
14
+ *
15
+ * - **Pass 1, dedup.** Candidate clusters are built locally from a
16
+ * normalised signature (category plus the title's key nouns, plus shared
17
+ * evidence refs), and then a cheap yes/no per cluster decides whether the
18
+ * members really are one issue. The clustering is local because it is
19
+ * mechanical, and a model call per pair would cost more than the whole
20
+ * report.
21
+ * - **Pass 2, ranking.** Entirely deterministic: the skill fixes the
22
+ * formula, and same inputs must produce the same order. There is nothing
23
+ * here for a model to decide, so nothing is asked of one.
24
+ *
25
+ * ## The aggregate run
26
+ *
27
+ * `aggregate.*` steps are only spendable from a run whose module is
28
+ * `aggregate_report`: the proxy maps a run's module onto a step prefix and
29
+ * answers 400 `step_not_allowed` otherwise. A job that asks for the
30
+ * `aggregate_report` module gets that run, so both passes are reachable and
31
+ * both are used.
32
+ *
33
+ * Pass 2's judgement is the part a developer actually reads, and it is the
34
+ * part only a model can do: which success criteria were met, which issues the
35
+ * brief already knew about, and which ones the game's stated design intent
36
+ * explains. All three are applied to the locally ranked issues rather than
37
+ * letting the model rewrite them, so the ranking stays deterministic and the
38
+ * model's job stays "decide", not "recompute".
39
+ *
40
+ * `aggregateRunId` is still optional, and without one the aggregate is the
41
+ * local clustering and ranking alone with `provenance.generated_from` set to
42
+ * `local_dedup`, so nobody mistakes it for a judged merge. That is the
43
+ * fallback for a job registered without the module, not the normal path.
44
+ */
45
+
46
+ /** From the skill's ranking formula. */
47
+ export const SEVERITY_WEIGHT = Object.freeze({ blocker: 100, major: 40, minor: 10, cosmetic: 2, praise: 0 });
48
+
49
+ /** Severities that need evidence to survive the quality gate. */
50
+ export const NEEDS_EVIDENCE = Object.freeze(['blocker', 'major', 'minor']);
51
+
52
+ const STOPWORDS = new Set([
53
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'on', 'at', 'for', 'and', 'or',
54
+ 'it', 'its', 'this', 'that', 'with', 'when', 'after', 'before', 'from', 'by', 'you', 'your',
55
+ 'but', 'not', 'no', 'can', 'cannot', 'does', 'do', 'did', 'be', 'been', 'has', 'have', 'had'
56
+ ]);
57
+
58
+ /**
59
+ * `category | sorted key nouns`. Lowercased, stopwords stripped, sorted so
60
+ * two titles with the same words in a different order collide on purpose.
61
+ *
62
+ * @param {Object} finding
63
+ * @returns {string}
64
+ */
65
+ export function signatureFor(finding) {
66
+ const tokens = String((finding && finding.title) || '')
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9\s]/g, ' ')
69
+ .split(/\s+/)
70
+ .filter(token => token.length > 2 && !STOPWORDS.has(token))
71
+ .sort();
72
+ return `${(finding && finding.category) || 'unknown'}|${[...new Set(tokens)].join(' ')}`;
73
+ }
74
+
75
+ /**
76
+ * A stable id, derived from the cluster's signature rather than random.
77
+ *
78
+ * The skill asks for `iss_<ulid>` and for stable ids across regeneration.
79
+ * Those pull in opposite directions, and stability is the one that matters
80
+ * to a reader: an issue the developer marked last week has to be the same
81
+ * issue after a regenerate. So the id is a hash of the signature, which is
82
+ * stable by construction.
83
+ *
84
+ * @param {string} signature
85
+ * @returns {string}
86
+ */
87
+ export function issueId(signature) {
88
+ return `iss_${createHash('sha256').update(signature, 'utf8').digest('hex').slice(0, 26)}`;
89
+ }
90
+
91
+ /**
92
+ * Candidate clusters: same signature, or a shared evidence ref.
93
+ *
94
+ * @param {Array<{runId: string, persona: string, findings: Array}>} runs
95
+ * @returns {Array<{signature: string, members: Array}>}
96
+ */
97
+ export function clusterFindings(runs) {
98
+ /** @type {Map<string, Array>} */
99
+ const bySignature = new Map();
100
+ /** @type {Map<string, string>} */
101
+ const byEvidence = new Map();
102
+
103
+ for (const run of runs) {
104
+ for (const finding of run.findings || []) {
105
+ const member = {
106
+ ref: `${run.runId}:${finding.id}`,
107
+ runId: run.runId,
108
+ persona: run.persona,
109
+ finding
110
+ };
111
+ let signature = signatureFor(finding);
112
+ // A shared evidence ref is strong enough to pull two differently
113
+ // worded findings into one candidate cluster.
114
+ for (const item of finding.evidence || []) {
115
+ if (!item || (item.type !== 'screenshot' && item.type !== 'file_line')) continue;
116
+ const key = `${item.type}:${item.ref}`;
117
+ const existing = byEvidence.get(key);
118
+ if (existing) signature = existing;
119
+ else byEvidence.set(key, signature);
120
+ }
121
+ const bucket = bySignature.get(signature) || [];
122
+ bucket.push(member);
123
+ bySignature.set(signature, bucket);
124
+ }
125
+ }
126
+
127
+ return [...bySignature.entries()]
128
+ .map(([signature, members]) => ({ signature, members }))
129
+ .sort((a, b) => a.signature.localeCompare(b.signature));
130
+ }
131
+
132
+ /**
133
+ * Pass 1's model turn, batched: one call for all the multi-member clusters,
134
+ * asking only whether each really is one issue and what to call it.
135
+ *
136
+ * `aggregate.dedup` allows no client tools and 512 output tokens, so the
137
+ * answer has to be small: a JSON array, one entry per cluster.
138
+ *
139
+ * @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
140
+ */
141
+ export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
142
+ const verdicts = new Map();
143
+ const ambiguous = clusters.filter(cluster => cluster.members.length > 1);
144
+ if (ambiguous.length === 0 || !runId) return verdicts;
145
+
146
+ const prompt = [
147
+ 'For each cluster below, answer whether the findings describe the same underlying issue.',
148
+ 'Answer with JSON only: [{"cluster": <index>, "same": true|false, "title": "<canonical title>"}].',
149
+ 'Two findings in the same area are NOT the same issue unless their actual content says so.',
150
+ '',
151
+ ...ambiguous.map((cluster, index) => [
152
+ `cluster ${index}:`,
153
+ ...cluster.members.map(member => ` - [${member.finding.severity}/${member.finding.category}] ${member.finding.title}: ${String(member.finding.description || '').slice(0, 300)}`)
154
+ ].join('\n'))
155
+ ].join('\n');
156
+
157
+ try {
158
+ const answer = await callStepWithRetry({
159
+ client,
160
+ jobId,
161
+ runId,
162
+ step: STEPS.aggregateDedup,
163
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
164
+ maxTokens: 512
165
+ });
166
+ for (const entry of parseJsonArray(answer.text)) {
167
+ const cluster = ambiguous[Number(entry.cluster)];
168
+ if (!cluster) continue;
169
+ verdicts.set(cluster.signature, { same: entry.same !== false, title: entry.title || null });
170
+ }
171
+ } catch (error) {
172
+ // A dedup that could not be judged is not worth failing a job over: the
173
+ // local clustering is the fallback and it is deterministic. A budget
174
+ // refusal in particular has to reach the caller though, because it
175
+ // decides the exit code.
176
+ if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
177
+ log(`dedup pass skipped: ${String(error && error.message)}`);
178
+ }
179
+ return verdicts;
180
+ }
181
+
182
+ /**
183
+ * The quality gate: a finding of `minor` or worse with no evidence is not an
184
+ * issue, it is an observation. Applied to MEMBER findings before clustering,
185
+ * as the skill specifies.
186
+ *
187
+ * @param {Array} runs
188
+ * @returns {{kept: Array, observations: Array}}
189
+ */
190
+ export function applyQualityGate(runs) {
191
+ const kept = [];
192
+ const observations = [];
193
+ for (const run of runs) {
194
+ const findings = [];
195
+ for (const finding of run.findings || []) {
196
+ const hasEvidence = Array.isArray(finding.evidence) && finding.evidence.length > 0;
197
+ if (!hasEvidence && NEEDS_EVIDENCE.includes(finding.severity)) {
198
+ observations.push({ runId: run.runId, persona: run.persona, finding });
199
+ continue;
200
+ }
201
+ findings.push(finding);
202
+ }
203
+ kept.push({ ...run, findings });
204
+ }
205
+ return { kept, observations };
206
+ }
207
+
208
+ /**
209
+ * One cluster becomes one issue.
210
+ *
211
+ * @param {{signature: string, members: Array}} cluster
212
+ * @param {{same: boolean, title: string|null}|undefined} verdict
213
+ * @returns {Array<Object>} one issue, or one issue per member when the judge
214
+ * said they are different
215
+ */
216
+ export function toIssues(cluster, verdict) {
217
+ const split = cluster.members.length > 1 && verdict && verdict.same === false;
218
+ if (split) {
219
+ return cluster.members.map(member => buildIssue(`${cluster.signature}#${member.ref}`, [member], null));
220
+ }
221
+ return [buildIssue(cluster.signature, cluster.members, verdict ? verdict.title : null)];
222
+ }
223
+
224
+ function buildIssue(signature, members, canonicalTitle) {
225
+ const severities = members.map(member => member.finding.severity);
226
+ const worst = ['blocker', 'major', 'minor', 'cosmetic', 'praise'].find(level => severities.includes(level)) || 'minor';
227
+ const personas = [...new Set(members.map(member => member.persona).filter(Boolean))];
228
+ const evidence = [];
229
+ const seen = new Set();
230
+ for (const member of members) {
231
+ for (const item of member.finding.evidence || []) {
232
+ const key = `${item.type}:${item.ref}`;
233
+ if (seen.has(key)) continue;
234
+ seen.add(key);
235
+ evidence.push(item);
236
+ }
237
+ }
238
+ const quotes = [];
239
+ const quoted = new Set();
240
+ for (const member of members) {
241
+ const quote = member.finding.persona_quote;
242
+ if (!quote || quoted.has(member.persona) || quotes.length >= 3) continue;
243
+ quoted.add(member.persona);
244
+ quotes.push({ persona: member.persona, text: quote });
245
+ }
246
+ // The shortest complete repro, not a concatenation: a developer has to be
247
+ // able to follow one of them standalone.
248
+ const repro = members
249
+ .map(member => member.finding.repro_steps || [])
250
+ .filter(steps => steps.length > 0)
251
+ .sort((a, b) => a.join(' ').length - b.join(' ').length)[0] || [];
252
+
253
+ return {
254
+ issue_id: issueId(signature),
255
+ label: null,
256
+ title: canonicalTitle || members[0].finding.title,
257
+ category: majority(members.map(member => member.finding.category)),
258
+ severity: worst,
259
+ personas_affected: personas,
260
+ occurrences: members.length,
261
+ repro_steps: repro,
262
+ evidence,
263
+ quotes,
264
+ member_findings: members.map(member => member.ref),
265
+ description: members[0].finding.description
266
+ };
267
+ }
268
+
269
+ function majority(values) {
270
+ const counts = new Map();
271
+ for (const value of values) counts.set(value, (counts.get(value) || 0) + 1);
272
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0];
273
+ }
274
+
275
+ /**
276
+ * Pass 2, exactly the skill's formula. Deterministic, and there is a test
277
+ * that proves the same inputs give the same order.
278
+ *
279
+ * `confidence` is 1 for every member: the run report schema has no
280
+ * `confidence` field (`additionalProperties: false`, and the finding
281
+ * properties are fixed), so there is nothing to average. Kept in the formula
282
+ * rather than dropped, so adding the field later changes one line.
283
+ *
284
+ * `telemetry_bonus` is 1: it needs real-player analytics, which this CLI has
285
+ * no scope to read.
286
+ *
287
+ * @param {Array<Object>} issues
288
+ * @param {number} personasRun
289
+ * @returns {{issues: Array<Object>, praise: Array<Object>}}
290
+ */
291
+ export function rankIssues(issues, personasRun) {
292
+ const scored = issues.map(issue => {
293
+ const breadth = personasRun > 0 ? issue.personas_affected.length / personasRun : 0;
294
+ const confidence = 1;
295
+ const kinds = new Set((issue.evidence || []).map(item => item && item.type));
296
+ const evidenceBonus = (kinds.has('screenshot') || kinds.has('video_ts') ? 1.2 : 1) * (kinds.has('file_line') ? 1.1 : 1);
297
+ const score = SEVERITY_WEIGHT[issue.severity] * (0.5 + 0.5 * breadth) * confidence * evidenceBonus * 1;
298
+ return { ...issue, score: Number(score.toFixed(6)), breadth: Number(breadth.toFixed(6)) };
299
+ });
300
+
301
+ const praise = scored
302
+ .filter(issue => issue.severity === 'praise')
303
+ .sort((a, b) => b.breadth - a.breadth || a.issue_id.localeCompare(b.issue_id));
304
+ const ranked = scored
305
+ .filter(issue => issue.severity !== 'praise')
306
+ .sort((a, b) => b.score - a.score || a.issue_id.localeCompare(b.issue_id))
307
+ .map((issue, index) => ({ ...issue, label: `I${index + 1}` }));
308
+ return { issues: ranked, praise: praise.map((issue, index) => ({ ...issue, label: `P${index + 1}` })) };
309
+ }
310
+
311
+ /**
312
+ * @param {Object} args
313
+ * @returns {Promise<{json: Object, markdown: string}>}
314
+ */
315
+ export async function buildAggregate({
316
+ client,
317
+ jobId,
318
+ gameId,
319
+ aggregateRunId = null,
320
+ runs,
321
+ brief = null,
322
+ capabilityNotes = [],
323
+ intentNotes = [],
324
+ promptVersion = 'aggregate-report@0.1.0',
325
+ ledger = null,
326
+ log = () => {}
327
+ }) {
328
+ const terminal = runs.filter(run => run && run.report);
329
+ const inputs = terminal.map(run => ({
330
+ runId: run.runId,
331
+ persona: run.persona,
332
+ state: run.state,
333
+ report: run.report,
334
+ findings: run.report.findings || []
335
+ }));
336
+ const personasRun = new Set(inputs.map(run => run.persona).filter(Boolean)).size;
337
+
338
+ const { kept, observations } = applyQualityGate(inputs);
339
+ const clusters = clusterFindings(kept);
340
+ const verdicts = aggregateRunId
341
+ ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
342
+ : new Map();
343
+
344
+ const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
345
+ const ranked = rankIssues(issues, personasRun);
346
+
347
+ const partial = runs.some(run => !run.report || ['failed', 'canceled', 'budget_exceeded'].includes(run.state));
348
+ const judgement = aggregateRunId
349
+ ? await judgeAggregate({ client, jobId, runId: aggregateRunId, ranked, inputs, brief, intentNotes, ledger, log })
350
+ : null;
351
+ const applied = applyJudgement({ ranked, brief, intentNotes, judgement });
352
+ const json = {
353
+ schema_version: '1.0',
354
+ report_id: `agg_${createHash('sha256').update(jobId, 'utf8').digest('hex').slice(0, 26)}`,
355
+ report_version: 1,
356
+ job_id: jobId,
357
+ game_id: gameId,
358
+ provenance: {
359
+ brief_id: brief ? brief.brief_id || brief.briefId || null : null,
360
+ brief_version: brief ? brief.version ?? null : null,
361
+ prompt_version: promptVersion,
362
+ design_intent_version: intentNotes.length,
363
+ generated_from: aggregateRunId ? 'live_run' : 'local_dedup'
364
+ },
365
+ success_criteria: applied.successCriteria,
366
+ excluded_by_intent: applied.excludedByIntent,
367
+ known_issues: applied.known,
368
+ tldr: applied.tldr,
369
+ generated_at: new Date().toISOString(),
370
+ partial,
371
+ runs: inputs.map(run => ({
372
+ run_id: run.runId,
373
+ persona: run.persona,
374
+ state: run.state,
375
+ actions_taken: run.report.actions_taken,
376
+ quit_reason: run.report.quit_reason,
377
+ sentiment: run.report.sentiment,
378
+ would_continue: run.report.would_continue
379
+ })),
380
+ issues: applied.issues,
381
+ praise: ranked.praise,
382
+ observations: observations.map(entry => ({
383
+ run_id: entry.runId,
384
+ persona: entry.persona,
385
+ text: `${entry.finding.title}: ${entry.finding.description}`,
386
+ dropped_for: 'no evidence'
387
+ })),
388
+ capability_notes: capabilityNotes.length > 0 ? capabilityNotes : ['No capability limits noted for this job.'],
389
+ usage: ledger ? aggregateUsage(ledger) : { usd: 0, by_model: {} }
390
+ };
391
+
392
+ return { json, markdown: renderAggregateMarkdown(json) };
393
+ }
394
+
395
+ /**
396
+ * The aggregate's own usage, in the shape spec 11 documents for this report
397
+ * (`{ usd, by_model }`, a map rather than the run report's array).
398
+ *
399
+ * @param {Object} ledger
400
+ * @returns {{usd: number, by_model: Object}}
401
+ */
402
+ export function aggregateUsage(ledger) {
403
+ const usage = ledger.toReportUsage();
404
+ return {
405
+ usd: usage.total_usd,
406
+ by_model: Object.fromEntries(usage.by_model.map(entry => [entry.model, {
407
+ input_tokens: entry.input_tokens,
408
+ cache_read_tokens: entry.cache_read_tokens,
409
+ output_tokens: entry.output_tokens
410
+ }]))
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Pass 2's judgement: the three questions only a model can answer about a
416
+ * ranked list of issues.
417
+ *
418
+ * It is deliberately NOT allowed to reorder or rewrite the issues. The ranking
419
+ * is a formula the skill fixes and a test pins, so letting a model restate it
420
+ * would make the same inputs produce different orders. What comes back is a set
421
+ * of verdicts keyed by id, applied locally by `applyJudgement`.
422
+ *
423
+ * `aggregate.synthesis` allows `read_file`; nothing is sent, because every
424
+ * input is already in the prompt and a tool the step does not need is a tool
425
+ * that can go wrong.
426
+ *
427
+ * @returns {Promise<Object|null>} null when the pass could not be made
428
+ */
429
+ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, brief, intentNotes, ledger, log = () => {} }) {
430
+ const fields = (brief && brief.fields) || {};
431
+ const criteria = criteriaFrom(brief);
432
+ const known = Array.isArray(fields.known_issues) ? fields.known_issues : [];
433
+ const intent = [
434
+ ...(Array.isArray(fields.design_intent_notes) ? fields.design_intent_notes : []).map(note => (typeof note === 'string' ? note : note.text || '')),
435
+ ...(intentNotes || []).map(note => note.text)
436
+ ].filter(Boolean);
437
+
438
+ const prompt = [
439
+ 'You are finishing a playtest aggregate. The issues below are already merged and ranked; do not reorder or rewrite them.',
440
+ 'Answer with JSON only, in this shape:',
441
+ '{"success_criteria":[{"criterion_id":"sc_1","result":"met|partly|not met|not testable","evidence":["iss_..."]}],',
442
+ ' "known":["iss_..."], "excluded_by_intent":[{"issue_id":"iss_...","intent":"<the stated intent it matches>"}],',
443
+ ' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
444
+ '',
445
+ 'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
446
+ '',
447
+ `success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
448
+ `known issues from the brief:\n${known.length > 0 ? known.map(entry => ` - ${typeof entry === 'string' ? entry : JSON.stringify(entry)}`).join('\n') : ' (none)'}`,
449
+ `stated design intent:\n${intent.length > 0 ? intent.map(entry => ` - ${entry}`).join('\n') : ' (none)'}`,
450
+ '',
451
+ `issues:\n${ranked.issues.map(issue => ` ${issue.issue_id} [${issue.severity}/${issue.category}] ${issue.title}: ${String(issue.description || '').slice(0, 300)} (personas: ${issue.personas_affected.join(', ') || 'none'})`).join('\n') || ' (none)'}`,
452
+ `praise:\n${ranked.praise.map(issue => ` ${issue.issue_id} ${issue.title}`).join('\n') || ' (none)'}`,
453
+ `runs:\n${inputs.map(run => ` ${run.persona}: ${run.report.actions_taken} actions, ${run.report.quit_reason}, would_continue=${run.report.would_continue}`).join('\n') || ' (none)'}`
454
+ ].join('\n');
455
+
456
+ try {
457
+ const answer = await callStepWithRetry({
458
+ client,
459
+ jobId,
460
+ runId,
461
+ step: STEPS.aggregateSynthesis,
462
+ messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
463
+ maxTokens: 8192
464
+ });
465
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
466
+ return parseJsonObject(answer.text);
467
+ } catch (error) {
468
+ // A budget or cancel refusal decides the caller's exit code and has to
469
+ // reach it. Anything else leaves the locally ranked aggregate standing,
470
+ // which is still the report a developer can read.
471
+ if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
472
+ log(`aggregate synthesis skipped: ${String(error && error.message)}`);
473
+ return null;
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Apply the judgement to the ranked issues. Every decision here is a lookup by
479
+ * id, so an answer naming an issue that does not exist is ignored rather than
480
+ * able to invent one.
481
+ *
482
+ * @returns {{issues: Array, known: Array, excludedByIntent: Array, successCriteria: Array, tldr: Array}}
483
+ */
484
+ export function applyJudgement({ ranked, brief, judgement }) {
485
+ const criteria = criteriaFrom(brief);
486
+ if (!judgement) {
487
+ return { issues: ranked.issues, known: [], excludedByIntent: [], successCriteria: criteria, tldr: [] };
488
+ }
489
+
490
+ const byId = new Map(ranked.issues.map(issue => [issue.issue_id, issue]));
491
+ const knownIds = new Set((Array.isArray(judgement.known) ? judgement.known : []).filter(id => byId.has(id)));
492
+ const excluded = (Array.isArray(judgement.excluded_by_intent) ? judgement.excluded_by_intent : [])
493
+ .filter(entry => entry && byId.has(entry.issue_id));
494
+ const excludedIds = new Set(excluded.map(entry => entry.issue_id));
495
+
496
+ const verdicts = new Map(
497
+ (Array.isArray(judgement.success_criteria) ? judgement.success_criteria : [])
498
+ .map(row => [row && row.criterion_id, row])
499
+ );
500
+ const allowed = new Set(['met', 'partly', 'not met', 'not testable']);
501
+
502
+ return {
503
+ // A known or intent-explained issue leaves the ranked list, which is what
504
+ // "excluded from Top issues" means; it is still reported, in its own
505
+ // section, because a confirmed known issue is information too.
506
+ issues: ranked.issues
507
+ .filter(issue => !knownIds.has(issue.issue_id) && !excludedIds.has(issue.issue_id))
508
+ .map((issue, index) => ({ ...issue, label: `I${index + 1}` })),
509
+ known: ranked.issues.filter(issue => knownIds.has(issue.issue_id)).map(issue => ({ ...issue, known: true })),
510
+ excludedByIntent: excluded.map(entry => ({
511
+ issue_id: entry.issue_id,
512
+ title: byId.get(entry.issue_id).title,
513
+ matched_intent_entry: String(entry.intent || '')
514
+ })),
515
+ successCriteria: criteria.map(row => {
516
+ const verdict = verdicts.get(row.criterion_id);
517
+ const result = verdict && allowed.has(verdict.result) ? verdict.result : 'not testable';
518
+ const evidence = verdict && Array.isArray(verdict.evidence) ? verdict.evidence.filter(id => byId.has(id)) : [];
519
+ return { ...row, result, evidence };
520
+ }),
521
+ tldr: (Array.isArray(judgement.tldr) ? judgement.tldr : []).map(line => String(line)).slice(0, 5)
522
+ };
523
+ }
524
+
525
+ /**
526
+ * The criteria the brief actually states, one row each, every one starting at
527
+ * `not testable`. `success_criteria` is a single string in the brief schema, so
528
+ * one line is one criterion.
529
+ *
530
+ * Starting at `not testable` rather than at `met` is the point: a verdict has
531
+ * to be earned by the judgement pass, and a job that never made that pass
532
+ * should say "not testable" rather than imply anything.
533
+ *
534
+ * @param {Object|null} brief
535
+ * @returns {Array<{criterion_id: string, text: string, result: string, evidence: Array}>}
536
+ */
537
+ export function criteriaFrom(brief) {
538
+ const fields = (brief && brief.fields) || brief || {};
539
+ const text = typeof fields.success_criteria === 'string' ? fields.success_criteria.trim() : '';
540
+ if (!text) return [];
541
+ return text
542
+ .split('\n')
543
+ .map(line => line.replace(/^[-*\d.\s]+/, '').trim())
544
+ .filter(Boolean)
545
+ .map((criterion, index) => ({ criterion_id: `sc_${index + 1}`, text: criterion, result: 'not testable', evidence: [] }));
546
+ }
547
+
548
+ /**
549
+ * The fixed headings, in the skill's order, so the dashboard and any future
550
+ * parser can find a section by name.
551
+ *
552
+ * @param {Object} json
553
+ * @returns {string}
554
+ */
555
+ export function renderAggregateMarkdown(json) {
556
+ const lines = [];
557
+ lines.push(`# Playtest Report: ${json.game_id}`);
558
+ lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${json.runs.length}`);
559
+ if (json.partial) {
560
+ lines.push('');
561
+ lines.push('> Partial: at least one run did not finish, so this report does not cover the whole pack.');
562
+ }
563
+ lines.push('');
564
+ lines.push('## TL;DR');
565
+ const top = json.issues.slice(0, 5);
566
+ if (Array.isArray(json.tldr) && json.tldr.length > 0) {
567
+ lines.push(json.tldr.map(line => `- ${line}`).join('\n'));
568
+ } else {
569
+ lines.push(top.length > 0
570
+ ? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${issue.occurrences} of ${json.runs.length} personas)`).join('\n')
571
+ : '- No ranked issues: no run produced an evidenced finding.');
572
+ }
573
+ lines.push('');
574
+ lines.push('## Against your success criteria');
575
+ lines.push('| Criterion | Result | Evidence |');
576
+ lines.push('|---|---|---|');
577
+ for (const row of json.success_criteria) lines.push(`| ${row.text} | ${row.result} | ${row.evidence.join(', ') || 'none'} |`);
578
+ if (json.success_criteria.length === 0) lines.push('| (the brief states no success criteria) | not testable | none |');
579
+ lines.push('');
580
+ lines.push('## Scorecard');
581
+ lines.push('| Persona | Actions | Quit reason | Fun | Clarity | Frustration | Would continue |');
582
+ lines.push('|---|---|---|---|---|---|---|');
583
+ for (const run of json.runs) {
584
+ const sentiment = run.sentiment || {};
585
+ lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue ? 'yes' : 'no'} |`);
586
+ }
587
+ lines.push('');
588
+ lines.push('## Top issues (ranked)');
589
+ if (json.issues.length === 0) lines.push('None.');
590
+ for (const issue of json.issues) {
591
+ lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${issue.occurrences}/${json.runs.length} personas`);
592
+ lines.push(`**Why it matters:** ${issue.description || ''}`);
593
+ if (issue.repro_steps.length > 0) lines.push(`**Repro:** ${issue.repro_steps.map((step, i) => `${i + 1}. ${step}`).join(' ')}`);
594
+ lines.push(`**Evidence:** ${issue.evidence.map(item => `${item.type} ${item.ref}`).join(', ') || 'none'}`);
595
+ for (const quote of issue.quotes) lines.push(`**Voices:** > "${quote.text}" (${quote.persona})`);
596
+ lines.push('');
597
+ }
598
+ lines.push("## What's working");
599
+ lines.push(json.praise.length > 0
600
+ ? json.praise.map(issue => `- ${issue.title} (${issue.personas_affected.join(', ')})`).join('\n')
601
+ : '- Nothing was logged as praise.');
602
+ lines.push('');
603
+ lines.push('## Confirmed known issues');
604
+ lines.push((json.known_issues || []).length > 0
605
+ ? json.known_issues.map(issue => `- ${issue.title} (${issue.severity}, ${issue.occurrences} occurrence(s))`).join('\n')
606
+ : 'None: nothing this pack found matches an issue the brief already describes.');
607
+ if ((json.excluded_by_intent || []).length > 0) {
608
+ lines.push('');
609
+ lines.push('### Suppressed as intended');
610
+ lines.push(json.excluded_by_intent.map(entry => `- ${entry.title} (matches: ${entry.matched_intent_entry})`).join('\n'));
611
+ }
612
+ lines.push('');
613
+ lines.push('## Capability notes');
614
+ lines.push(json.capability_notes.map(note => `- ${note}`).join('\n'));
615
+ lines.push('');
616
+ lines.push('## Appendix');
617
+ lines.push(json.observations.length > 0
618
+ ? `Observations dropped for lack of evidence:\n${json.observations.map(o => `- ${o.persona}: ${o.text}`).join('\n')}`
619
+ : 'No dropped observations.');
620
+ lines.push('');
621
+ return lines.join('\n');
622
+ }
623
+
624
+ /**
625
+ * Write both aggregate files into the job directory.
626
+ *
627
+ * @param {string} jobDirectory
628
+ * @param {{json: Object, markdown: string}} aggregate
629
+ * @returns {Promise<{jsonPath: string, markdownPath: string}>}
630
+ */
631
+ export async function writeAggregate(jobDirectory, aggregate) {
632
+ const body = `${JSON.stringify(aggregate.json, null, 2)}\n`;
633
+ assertNoSecrets('aggregate-report.json', body);
634
+ assertNoSecrets('aggregate-report.md', aggregate.markdown);
635
+ const jsonPath = path.join(jobDirectory, JOB_FILES.aggregateJson);
636
+ const markdownPath = path.join(jobDirectory, JOB_FILES.aggregateMd);
637
+ await writeFile(jsonPath, body, 'utf8');
638
+ await writeFile(markdownPath, aggregate.markdown, 'utf8');
639
+ return { jsonPath, markdownPath };
640
+ }
641
+
642
+ export { parseJsonArray };
643
+
644
+ export default {
645
+ buildAggregate,
646
+ judgeAggregate,
647
+ applyJudgement,
648
+ criteriaFrom,
649
+ aggregateUsage,
650
+ clusterFindings,
651
+ rankIssues,
652
+ applyQualityGate,
653
+ renderAggregateMarkdown,
654
+ writeAggregate,
655
+ signatureFor,
656
+ issueId,
657
+ parseJsonArray
658
+ };