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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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
|
+
};
|