@a-t-h-i/bot-lobby 0.6.26 → 0.6.27
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/README.md +7 -1
- package/package.json +1 -1
- package/prompts/master.md +24 -3
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/qa-risk.ts +537 -0
- package/src/lobby/planner.ts +14 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/events.ts +4 -3
- package/src/pi/start-task.ts +31 -4
- package/src/pi/tools.ts +3 -0
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/webui/api/tasks.ts +1 -0
- package/src/webui/protocol.ts +3 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BUzOUrih.css +2 -0
- package/webui/dist/assets/{index-C4Ww36TW.js → index-myC4UiEg.js} +18 -15
- package/webui/dist/build.json +18 -10
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA risk: once a task's implementation is done, how much verification the
|
|
3
|
+
* change it built warrants, read from the actual diff rather than the
|
|
4
|
+
* request. A five-line authorization change can be HIGH and a 500-line
|
|
5
|
+
* documentation change NONE: what counts is how likely a meaningful
|
|
6
|
+
* regression is, how far a break would spread and how hard it would be to
|
|
7
|
+
* notice.
|
|
8
|
+
*
|
|
9
|
+
* The engine's rules read the change first (instant, no model): what kind of
|
|
10
|
+
* files changed, how much and how widely, and whether the changed lines touch
|
|
11
|
+
* an area where a defect spreads — security, persistent data, destructive
|
|
12
|
+
* operations, concurrency, orchestration and recovery, isolation. Jev, when
|
|
13
|
+
* it is on, reads the diff with those signals and has the last word on the
|
|
14
|
+
* level within the rules' floors: an area the changed lines touch stays HIGH
|
|
15
|
+
* unless Jev reads it as clearly untouched, and only a change that is plainly
|
|
16
|
+
* non-functional skips the LLM QA pass. The result is the QA gate's budget:
|
|
17
|
+
* its depth and a ceiling on new tests, never a quota.
|
|
18
|
+
*/
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
import { QA_RISKS, type QaRisk, type QaRiskAssessment } from "../schemas/task.ts";
|
|
21
|
+
import type { Classifier } from "./classifier.ts";
|
|
22
|
+
import { noul, score, scoreOf, yesOf, type SystemOneRequest } from "./client.ts";
|
|
23
|
+
import { clip } from "./limits.ts";
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------ reading the change */
|
|
26
|
+
|
|
27
|
+
export type FileKind = "docs" | "style" | "asset" | "generated" | "test" | "config" | "dependency" | "code";
|
|
28
|
+
|
|
29
|
+
const GENERATED = /(?:^|\/)(?:dist|build|out|coverage|vendor|__generated__|generated)\/|\.min\.(?:js|css)$|\.map$|\.snap$|\.generated\.\w+$/i;
|
|
30
|
+
const LOCKFILE = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|poetry\.lock|Pipfile\.lock|Cargo\.lock|go\.sum|Gemfile\.lock|composer\.lock)$/;
|
|
31
|
+
const MANIFEST = /(?:^|\/)(?:package\.json|requirements[\w.-]*\.txt|Pipfile|pyproject\.toml|go\.mod|Cargo\.toml|Gemfile|composer\.json)$/;
|
|
32
|
+
const TEST = /(?:^|\/)(?:tests?|__tests__|spec|e2e)\/|\.(?:test|spec)\.\w+$|_test\.(?:go|py)$|(?:^|\/)test_[\w-]+\.py$/i;
|
|
33
|
+
const DOC = /\.(?:md|mdx|markdown|rst|txt|adoc)$|(?:^|\/)(?:LICENSE|LICENCE|NOTICE|AUTHORS|CODEOWNERS)(?:\.\w+)?$/i;
|
|
34
|
+
const STYLE = /\.(?:css|scss|sass|less|styl|pcss)$/i;
|
|
35
|
+
const ASSET = /\.(?:png|jpe?g|gif|svg|ico|webp|avif|bmp|woff2?|ttf|otf|eot|mp[34]|webm|wav|ogg|pdf)$/i;
|
|
36
|
+
const CONFIG = /\.(?:json|jsonc|ya?ml|toml|ini|cfg|conf|properties|env)$|(?:^|\/)\.[\w.-]+$|(?:^|\/)(?:Dockerfile|Makefile|Procfile)$/i;
|
|
37
|
+
/** A version in a manifest's changed line: a dependency moved, not just a setting. */
|
|
38
|
+
const VERSION = /\d+\.\d+|"\s*[~^*]|\blatest\b|\bworkspace:|\bgit\+|==|>=|~=/;
|
|
39
|
+
|
|
40
|
+
/** What a changed file is, by its path; a manifest counts as dependencies only when a version in it changed. */
|
|
41
|
+
export function fileKind(path: string, changed: readonly string[] = []): FileKind {
|
|
42
|
+
if (LOCKFILE.test(path)) return "dependency";
|
|
43
|
+
if (GENERATED.test(path)) return "generated";
|
|
44
|
+
if (MANIFEST.test(path)) return changed.some((line) => VERSION.test(line)) ? "dependency" : "config";
|
|
45
|
+
if (TEST.test(path)) return "test";
|
|
46
|
+
if (DOC.test(path)) return "docs";
|
|
47
|
+
if (STYLE.test(path)) return "style";
|
|
48
|
+
if (ASSET.test(path)) return "asset";
|
|
49
|
+
if (CONFIG.test(path)) return "config";
|
|
50
|
+
return "code";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One file's changed lines in a diff: added and removed, the text after the marker. */
|
|
54
|
+
export interface FileChange {
|
|
55
|
+
added: string[];
|
|
56
|
+
removed: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The changed lines of each file in the diff the QA gate reads (`git diff`,
|
|
61
|
+
* then the untracked new files whole). A file the diff was cut before is
|
|
62
|
+
* simply missing; `total` is the stat's insertions and deletions when it
|
|
63
|
+
* names them.
|
|
64
|
+
*/
|
|
65
|
+
export function parseDiff(diff: string): { files: Map<string, FileChange>; total?: number } {
|
|
66
|
+
const files = new Map<string, FileChange>();
|
|
67
|
+
let current: FileChange | undefined;
|
|
68
|
+
let inHunk = false;
|
|
69
|
+
let untracked = false;
|
|
70
|
+
for (const line of diff.split("\n")) {
|
|
71
|
+
const header = /^diff --git a\/.+? b\/(.+)$/.exec(line);
|
|
72
|
+
if (header) {
|
|
73
|
+
current = { added: [], removed: [] };
|
|
74
|
+
files.set(header[1]!, current);
|
|
75
|
+
inHunk = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (/^New files \(untracked\):$/.test(line)) {
|
|
79
|
+
untracked = true;
|
|
80
|
+
current = undefined;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (untracked) {
|
|
84
|
+
const named = /^--- (.+?)(?: \((?:large; read it directly|binary|unreadable)\))?$/.exec(line);
|
|
85
|
+
if (named) {
|
|
86
|
+
current = { added: [], removed: [] };
|
|
87
|
+
files.set(named[1]!, current);
|
|
88
|
+
} else current?.added.push(line);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (line.startsWith("@@")) {
|
|
92
|
+
inHunk = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!current || !inHunk) continue;
|
|
96
|
+
if (line.startsWith("+")) current.added.push(line.slice(1));
|
|
97
|
+
else if (line.startsWith("-")) current.removed.push(line.slice(1));
|
|
98
|
+
}
|
|
99
|
+
const stat = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(diff);
|
|
100
|
+
return { files, ...(stat ? { total: Number(stat[2] ?? 0) + Number(stat[3] ?? 0) } : {}) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SLASH_COMMENT = /^(?:\/\/|\/\*|\*|\*\/|\{\s*\/\*.*\*\/\s*\}$)/;
|
|
104
|
+
const COMMENTS: Array<[RegExp, RegExp]> = [
|
|
105
|
+
[/\.(?:[cm]?[jt]sx?|java|kt|kts|go|rs|c|h|cc|cpp|hpp|cs|swift|scala|dart|css|scss|less)$/i, SLASH_COMMENT],
|
|
106
|
+
[/\.(?:py|pyi|rb|sh|bash|zsh|pl|r|ya?ml|toml|ps1|ex|exs)$|(?:^|\/)(?:Dockerfile|Makefile)$/i, /^#/],
|
|
107
|
+
[/\.(?:sql|lua|hs)$/i, /^--/],
|
|
108
|
+
[/\.(?:html?|vue|svelte|xml)$/i, /^(?:<!--|-->)|-->$|^\/\/|^\/\*|^\*/],
|
|
109
|
+
[/\.php$/i, /^(?:#|\/\/|\/\*|\*)/],
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** Every changed line is blank or a comment: the code does what it did. */
|
|
113
|
+
export function commentOnly(path: string, change: FileChange | undefined): boolean {
|
|
114
|
+
if (!change) return false;
|
|
115
|
+
const pattern = COMMENTS.find(([files]) => files.test(path))?.[1];
|
|
116
|
+
if (!pattern) return false;
|
|
117
|
+
return [...change.added, ...change.removed].every((line) => !line.trim() || pattern.test(line.trim()));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------ the areas where a defect spreads */
|
|
121
|
+
|
|
122
|
+
/** Areas that bias a change toward HIGH, and the two that only raise it to MEDIUM (contract, dependency). */
|
|
123
|
+
export const RISK_AREAS = ["security", "data", "destructive", "concurrency", "workflow", "isolation", "contract", "dependency"] as const;
|
|
124
|
+
export type RiskArea = (typeof RISK_AREAS)[number];
|
|
125
|
+
const SERIOUS: ReadonlySet<RiskArea> = new Set(["security", "data", "destructive", "concurrency", "workflow", "isolation"]);
|
|
126
|
+
|
|
127
|
+
interface AreaRule {
|
|
128
|
+
/** The file's path names the area: weak evidence alone. */
|
|
129
|
+
path?: RegExp;
|
|
130
|
+
/** A changed line touches it: strong evidence. */
|
|
131
|
+
text?: RegExp;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const AREA_RULES: Record<Exclude<RiskArea, "dependency">, AreaRule> = {
|
|
135
|
+
security: {
|
|
136
|
+
path: /auth|login|logout|password|permission|rbac|\bacl\b|secret|credential|oauth|jwt|security|crypto|csrf|sanitiz/i,
|
|
137
|
+
text: /password|passwd|secret|credential|api[_-]?key|authori[sz]|authenticat|permission|\brbac\b|\bacl\b|oauth|\bjwt\b|csrf|\bxss\b|sanitiz|encrypt|decrypt|bcrypt|argon2|\bhmac\b|private[_-]?key/i,
|
|
138
|
+
},
|
|
139
|
+
data: {
|
|
140
|
+
path: /migrations?\b|schema\.(?:prisma|sql|rb)$|\.sql$|persistence|database|(?:^|\/)db\//i,
|
|
141
|
+
text: /\b(?:ALTER|CREATE|DROP)\s+(?:TABLE|INDEX|COLUMN|SCHEMA)\b|\bDELETE\s+FROM\b|\bINSERT\s+INTO\b|\bUPDATE\s+\w+\s+SET\b|\bmigrat(?:e|ion)/i,
|
|
142
|
+
},
|
|
143
|
+
destructive: {
|
|
144
|
+
text: /\brm(?:Sync|dirSync)?\s*\(|\bunlink(?:Sync)?\s*\(|\brm\s+-[a-z]*r[a-z]*f|shutil\.rmtree|os\.remove|\bTRUNCATE\b|\bDROP\s+TABLE\b|git\s+(?:reset\s+--hard|clean\s+-\w*f|push\s+(?:-f\b|--force)|branch\s+-D)|\bdeleteMany\s*\(/i,
|
|
145
|
+
},
|
|
146
|
+
concurrency: {
|
|
147
|
+
path: /lock|mutex|semaphore|queue|scheduler|concurren|parallel/i,
|
|
148
|
+
text: /\bmutex\b|\bsemaphore\b|\block(?:file)?s?\b|\bAtomics\.|worker_threads|\brace\b|concurren|Promise\.race|\btransaction\b/i,
|
|
149
|
+
},
|
|
150
|
+
workflow: {
|
|
151
|
+
path: /workflow|state-?machine|transitions?\b|orchestrat|task-state|\bfsm\b|recover|rollback|retry|restore|resume/i,
|
|
152
|
+
text: /\btransition\s*\(|state\s*machine|\brollback\b|\brecover(?:y)?\b/i,
|
|
153
|
+
},
|
|
154
|
+
isolation: {
|
|
155
|
+
path: /worktree|isolation|sandbox/i,
|
|
156
|
+
text: /\bworktree\b|\bsandbox\b|allowed_?tools/i,
|
|
157
|
+
},
|
|
158
|
+
contract: {
|
|
159
|
+
path: /protocol|openapi|swagger|\.proto$|\.graphql$|(?:^|\/)(?:api|routes?|endpoints?)\//i,
|
|
160
|
+
text: /\b(?:app|router|server)\.(?:get|post|put|patch|delete)\s*\(|@(?:Get|Post|Put|Patch|Delete)Mapping|@app\.route|@(?:router|app)\.(?:get|post|put|patch|delete)/i,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const AREA_WORDS: Record<RiskArea, { factor: string; focus: string }> = {
|
|
165
|
+
security: { factor: "security", focus: "security boundaries: the wrong user, no user and an expired session are refused, and no secret leaks" },
|
|
166
|
+
data: { factor: "persistent data", focus: "data integrity: what is saved reads back the same, a migration runs on real data, and a failure halfway loses or corrupts nothing" },
|
|
167
|
+
destructive: { factor: "destructive operations", focus: "destructive operations: they touch only what they should, and a failure halfway leaves things recoverable" },
|
|
168
|
+
concurrency: { factor: "concurrency", focus: "concurrency: races, ordering, double runs, and a lock or claim that is never released" },
|
|
169
|
+
workflow: { factor: "orchestration and state", focus: "state transitions and recovery: no illegal or stuck state, and a crash or failed step recovers" },
|
|
170
|
+
isolation: { factor: "isolation", focus: "isolation: work stays inside its branch, worktree and permission boundaries" },
|
|
171
|
+
contract: { factor: "a public contract", focus: "public contracts: existing callers and saved data keep working (backwards compatibility)" },
|
|
172
|
+
dependency: { factor: "dependencies", focus: "dependencies: the new or changed package works as it is used, and nothing relied on the old one" },
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/* ------------------------------------------------------------ the rules */
|
|
176
|
+
|
|
177
|
+
export interface QaRiskInput {
|
|
178
|
+
/** The task's changed files, repository-relative. */
|
|
179
|
+
files: readonly string[];
|
|
180
|
+
/** The diff as the QA gate reads it. */
|
|
181
|
+
diff: string;
|
|
182
|
+
/** The request, for Jev's context. */
|
|
183
|
+
request?: string;
|
|
184
|
+
/** The task fixes a bug: it must no longer reproduce. */
|
|
185
|
+
bugfix?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Evidence the change touches an area: a changed line (strong), or only a path (weak). */
|
|
189
|
+
interface AreaHit {
|
|
190
|
+
strong: boolean;
|
|
191
|
+
/** `src/auth/reset.ts ("password")` or just the path. */
|
|
192
|
+
where: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** What the rules make of a change. */
|
|
196
|
+
export interface ChangeReading {
|
|
197
|
+
risk: QaRisk;
|
|
198
|
+
areas: Partial<Record<RiskArea, AreaHit>>;
|
|
199
|
+
factors: string[];
|
|
200
|
+
testsChanged: boolean;
|
|
201
|
+
/** Files whose content can run: what Jev is asked to read. Nothing to read when there is none. */
|
|
202
|
+
functional: number;
|
|
203
|
+
/** Changed lines in those files. */
|
|
204
|
+
lines: number;
|
|
205
|
+
/** Frontend files changed (screens, components). */
|
|
206
|
+
frontend: boolean;
|
|
207
|
+
/** Only documentation, assets, generated files or comments: nothing that runs changed. */
|
|
208
|
+
inert: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const UI_FILE = /\.(?:tsx|jsx|vue|svelte|html?)$|(?:^|\/)(?:components?|pages|screens|views)\//i;
|
|
212
|
+
|
|
213
|
+
function rank(risk: QaRisk): number {
|
|
214
|
+
return QA_RISKS.indexOf(risk);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function higher(a: QaRisk, b: QaRisk): QaRisk {
|
|
218
|
+
return rank(a) >= rank(b) ? a : b;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `src/workflow`, `webui/src/tabs`: where a file lives, at most three folders deep. */
|
|
222
|
+
function moduleOf(path: string): string {
|
|
223
|
+
const dir = dirname(path);
|
|
224
|
+
return dir === "." ? "(top)" : dir.split("/").slice(0, 3).join("/");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function listed(items: readonly string[], max = 3): string {
|
|
228
|
+
return items.length > max ? `${items.slice(0, max).join(", ")} and ${items.length - max} more` : items.join(", ");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Read a change with plain rules: what changed, how much and how widely, and
|
|
233
|
+
* which areas it touches. Deliberately cautious: whatever runs is at least
|
|
234
|
+
* LOW, a touched area is HIGH (MEDIUM on a path alone), and only a change
|
|
235
|
+
* with nothing that runs is NONE.
|
|
236
|
+
*/
|
|
237
|
+
export function readChange(input: QaRiskInput): ChangeReading {
|
|
238
|
+
const { files: parsed, total } = parseDiff(input.diff);
|
|
239
|
+
const kinds = input.files.map((path) => ({ path, kind: fileKind(path, [...(parsed.get(path)?.added ?? []), ...(parsed.get(path)?.removed ?? [])]) }));
|
|
240
|
+
const functional = kinds.filter(({ path, kind }) => kind === "code" && !commentOnly(path, parsed.get(path)));
|
|
241
|
+
const tests = kinds.filter(({ kind }) => kind === "test");
|
|
242
|
+
const dependencies = kinds.filter(({ kind }) => kind === "dependency");
|
|
243
|
+
const config = kinds.filter(({ kind }) => kind === "config");
|
|
244
|
+
const styles = kinds.filter(({ kind }) => kind === "style");
|
|
245
|
+
const count = (paths: ReadonlyArray<{ path: string }>) => paths.reduce((sum, { path }) => sum + (parsed.get(path)?.added.length ?? 0) + (parsed.get(path)?.removed.length ?? 0), 0);
|
|
246
|
+
// A file the diff was cut before still counts: the stat's total is the safe side.
|
|
247
|
+
const unseen = functional.some(({ path }) => !parsed.has(path));
|
|
248
|
+
const lines = unseen ? Math.max(count(functional), total ?? 0) : count(functional);
|
|
249
|
+
const factors: string[] = [];
|
|
250
|
+
const areas: Partial<Record<RiskArea, AreaHit>> = {};
|
|
251
|
+
|
|
252
|
+
// Areas: the changed lines of anything that runs or configures; a path alone counts for code only (a CI workflow is not orchestration).
|
|
253
|
+
const scanned = kinds.filter(({ path, kind }) => (kind === "code" || kind === "config") && !commentOnly(path, parsed.get(path)));
|
|
254
|
+
for (const [area, rule] of Object.entries(AREA_RULES) as Array<[Exclude<RiskArea, "dependency">, AreaRule]>) {
|
|
255
|
+
for (const { path, kind } of scanned) {
|
|
256
|
+
const change = parsed.get(path);
|
|
257
|
+
const line = rule.text ? [...(change?.added ?? []), ...(change?.removed ?? [])].find((text) => !/^\s*(?:\/\/|\/\*|\*|#)/.test(text) && rule.text!.test(text)) : undefined;
|
|
258
|
+
if (line !== undefined) {
|
|
259
|
+
const word = rule.text!.exec(line)?.[0] ?? "";
|
|
260
|
+
areas[area] = { strong: true, where: `${path} ("${word.trim()}")` };
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
if (!areas[area] && kind === "code" && rule.path?.test(path)) areas[area] = { strong: false, where: path };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (dependencies.length > 0) areas.dependency = { strong: true, where: listed(dependencies.map(({ path }) => path)) };
|
|
267
|
+
for (const area of RISK_AREAS) {
|
|
268
|
+
const hit = areas[area];
|
|
269
|
+
if (hit) factors.push(`${AREA_WORDS[area].factor}: ${hit.where}${hit.strong ? "" : " (by its path)"}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const modules = [...new Set(functional.map(({ path }) => moduleOf(path)))];
|
|
273
|
+
const inert = kinds.length > 0 && kinds.every(({ path, kind }) => kind === "docs" || kind === "asset" || kind === "generated" || (kind === "code" && commentOnly(path, parsed.get(path))));
|
|
274
|
+
let risk: QaRisk;
|
|
275
|
+
if (functional.length === 0) {
|
|
276
|
+
// Nothing that runs changed: tests, a large style or config change still deserve a light look.
|
|
277
|
+
const configLines = count(config);
|
|
278
|
+
const styleLines = count(styles);
|
|
279
|
+
if (tests.length > 0) {
|
|
280
|
+
risk = "low";
|
|
281
|
+
factors.push(`tests only: ${listed(tests.map(({ path }) => path))}`);
|
|
282
|
+
} else if (configLines > 20 || styleLines > 150) {
|
|
283
|
+
risk = "low";
|
|
284
|
+
factors.push(configLines > 20 ? `${configLines} changed configuration lines` : `${styleLines} changed style lines`);
|
|
285
|
+
} else {
|
|
286
|
+
risk = "none";
|
|
287
|
+
factors.push(`nothing that runs changed: ${listed(input.files)}`);
|
|
288
|
+
}
|
|
289
|
+
} else if (modules.length >= 4 || functional.length > 12 || lines > 800) {
|
|
290
|
+
risk = "high";
|
|
291
|
+
factors.push(`a wide change: ${functional.length} files, ${lines} changed lines across ${modules.length} modules (${listed(modules)})`);
|
|
292
|
+
} else if (lines <= 40 && functional.length <= 2 && modules.length <= 1) {
|
|
293
|
+
risk = "low";
|
|
294
|
+
factors.push(`small and local: ${lines} changed lines in ${listed(functional.map(({ path }) => path))}`);
|
|
295
|
+
} else {
|
|
296
|
+
risk = "medium";
|
|
297
|
+
factors.push(`${functional.length} files, ${lines} changed lines in ${listed(modules)}`);
|
|
298
|
+
}
|
|
299
|
+
for (const area of RISK_AREAS) {
|
|
300
|
+
const hit = areas[area];
|
|
301
|
+
if (!hit) continue;
|
|
302
|
+
risk = higher(risk, SERIOUS.has(area) && hit.strong ? "high" : "medium");
|
|
303
|
+
}
|
|
304
|
+
if (input.bugfix) {
|
|
305
|
+
risk = higher(risk, "low");
|
|
306
|
+
factors.push("a bug fix: the bug must no longer reproduce");
|
|
307
|
+
}
|
|
308
|
+
if (tests.length > 0 && functional.length > 0) factors.push(`tests changed with it: ${listed(tests.map(({ path }) => path))}`);
|
|
309
|
+
return {
|
|
310
|
+
risk,
|
|
311
|
+
areas,
|
|
312
|
+
factors,
|
|
313
|
+
testsChanged: tests.length > 0,
|
|
314
|
+
functional: functional.length,
|
|
315
|
+
lines,
|
|
316
|
+
frontend: functional.some(({ path }) => UI_FILE.test(path)),
|
|
317
|
+
inert,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ------------------------------------------------------------ Jev */
|
|
322
|
+
|
|
323
|
+
const RISK_LEVELS = [
|
|
324
|
+
"none: no behavioural risk — comments, documentation, wording, formatting, a trivial style tweak, metadata, generated files, or an obvious configuration value",
|
|
325
|
+
"low: small and local, its behaviour plain and a failure contained — a simple UI change, straightforward wiring, a small mapping, basic validation, an isolated bug fix with obvious behaviour",
|
|
326
|
+
"medium: meaningful behaviour changes and realistic regressions are possible — a new feature, API behaviour, business logic, state transitions, database access, several interacting components, a non-trivial bug fix",
|
|
327
|
+
"high: a defect could spread widely or break an invariant — authentication, authorization, permissions, secrets, destructive operations, persistence and data integrity, migrations, concurrency, orchestration or state machines, recovery, isolation, public contracts, or a cross-cutting refactor",
|
|
328
|
+
];
|
|
329
|
+
|
|
330
|
+
const AREA_QUESTIONS: Record<Exclude<RiskArea, "dependency">, string> = {
|
|
331
|
+
security: "Does `diff` change authentication, authorization, permissions, secrets, input sanitising or another security boundary?",
|
|
332
|
+
data: "Does `diff` change how data is persisted, migrated or transformed, so a defect could lose or corrupt saved data?",
|
|
333
|
+
destructive: "Does `diff` add or change an operation that deletes or overwrites files, data or history?",
|
|
334
|
+
concurrency: "Does `diff` change concurrent or parallel behaviour: locks, queues, ordering, shared state, or work that can run twice at once?",
|
|
335
|
+
workflow: "Does `diff` change orchestration or a state machine: which states or steps follow which, completion rules, retries, or recovery after a failure?",
|
|
336
|
+
isolation: "Does `diff` change isolation: which branch, worktree, sandbox or permissions an agent or process works within?",
|
|
337
|
+
contract: "Does `diff` change a public contract — an API route, a protocol, a file or wire format, a command-line interface — that existing callers or saved data rely on?",
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** What Jev made of the change. */
|
|
341
|
+
export interface JevRiskRead {
|
|
342
|
+
risk: QaRisk;
|
|
343
|
+
confidence: number;
|
|
344
|
+
/** Probability of yes per area. */
|
|
345
|
+
areas: Partial<Record<RiskArea, number>>;
|
|
346
|
+
behaviour: number;
|
|
347
|
+
testsCover: number;
|
|
348
|
+
model: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function qaRiskRequest(input: QaRiskInput, reading: ChangeReading): SystemOneRequest {
|
|
352
|
+
const questions: SystemOneRequest["questions"] = {
|
|
353
|
+
risk: score("How much verification does the change in `diff` warrant, judged by how likely it is to break something that works, how far a break would spread and how hard it would be to notice?", RISK_LEVELS),
|
|
354
|
+
behaviour: noul(
|
|
355
|
+
"Does `diff` change behaviour a user, a caller or another part of the system can observe?",
|
|
356
|
+
"Yes: something works differently after it.",
|
|
357
|
+
"No: it changes wording, comments, formatting, styling or metadata only.",
|
|
358
|
+
),
|
|
359
|
+
tests_cover: noul(
|
|
360
|
+
"Is the behaviour `diff` changes covered by tests — ones it adds or updates, or ones that plainly exist for this code — so few or no new tests are needed?",
|
|
361
|
+
"Yes: a regression in what it changes would already fail a test.",
|
|
362
|
+
"No: a regression in what it changes could pass every existing test.",
|
|
363
|
+
),
|
|
364
|
+
};
|
|
365
|
+
for (const [area, text] of Object.entries(AREA_QUESTIONS)) questions[`area_${area}`] = noul(text);
|
|
366
|
+
return {
|
|
367
|
+
state: {
|
|
368
|
+
...(input.request ? { request: clip(input.request, 2000) } : {}),
|
|
369
|
+
changed_files: clip(input.files.join("\n"), 4000),
|
|
370
|
+
engine_signals: clip(reading.factors.join("\n"), 2000),
|
|
371
|
+
diff: clip(input.diff, 60_000),
|
|
372
|
+
},
|
|
373
|
+
questions,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Jev's read of a change, or undefined when the classifier is off or fails. */
|
|
378
|
+
export async function readRiskWithJev(classifier: Classifier, input: QaRiskInput, reading: ChangeReading, signal?: AbortSignal): Promise<JevRiskRead | undefined> {
|
|
379
|
+
if (!classifier.enabled("qa")) return undefined;
|
|
380
|
+
const result = await classifier.ask("qa", qaRiskRequest(input, reading), signal ? { signal } : {});
|
|
381
|
+
const level = scoreOf(result?.answers, "risk");
|
|
382
|
+
if (!result || !level) return undefined;
|
|
383
|
+
const areas: Partial<Record<RiskArea, number>> = {};
|
|
384
|
+
for (const area of Object.keys(AREA_QUESTIONS) as RiskArea[]) {
|
|
385
|
+
const yes = yesOf(result.answers, `area_${area}`);
|
|
386
|
+
if (yes !== undefined) areas[area] = yes;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
risk: QA_RISKS[Math.max(0, Math.min(QA_RISKS.length - 1, level.level))]!,
|
|
390
|
+
confidence: level.confidence,
|
|
391
|
+
areas,
|
|
392
|
+
behaviour: yesOf(result.answers, "behaviour") ?? 0.5,
|
|
393
|
+
testsCover: yesOf(result.answers, "tests_cover") ?? 0,
|
|
394
|
+
model: result.model,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* ------------------------------------------------------------ the assessment */
|
|
399
|
+
|
|
400
|
+
/** New behavioural tests that may be worth writing at each level: ceilings, never quotas. */
|
|
401
|
+
export const TEST_BUDGETS: Record<QaRisk, { min: number; max: number }> = {
|
|
402
|
+
none: { min: 0, max: 0 },
|
|
403
|
+
low: { min: 0, max: 1 },
|
|
404
|
+
medium: { min: 1, max: 3 },
|
|
405
|
+
high: { min: 2, max: 6 },
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/** How deep QA goes at each level. */
|
|
409
|
+
export const DEPTHS: Record<QaRisk, string> = {
|
|
410
|
+
none: "the engine's checks only",
|
|
411
|
+
low: "light",
|
|
412
|
+
medium: "standard",
|
|
413
|
+
high: "deep and adversarial",
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
/** Jev's floor for an area the rules found: confirmed keeps it serious, doubted lowers it, denied drops a path-only hit. */
|
|
417
|
+
function areaFloor(area: RiskArea, hit: AreaHit, jev: JevRiskRead | undefined): QaRisk {
|
|
418
|
+
const serious = SERIOUS.has(area);
|
|
419
|
+
if (area === "dependency") return "medium";
|
|
420
|
+
const yes = jev?.areas[area];
|
|
421
|
+
if (yes === undefined) return serious && hit.strong ? "high" : "medium";
|
|
422
|
+
if (yes >= 0.5) return serious ? "high" : "medium";
|
|
423
|
+
if (yes >= 0.2) return "medium";
|
|
424
|
+
return hit.strong ? "medium" : "none";
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Combine the rules' reading with Jev's: Jev's level when it is confident,
|
|
429
|
+
* never below the floors the touched areas set, never NONE for a change that
|
|
430
|
+
* runs unless Jev reads it as plainly inert, and at least LOW for one Jev
|
|
431
|
+
* reads as changing behaviour.
|
|
432
|
+
*/
|
|
433
|
+
export function combineRisk(reading: ChangeReading, jev: JevRiskRead | undefined, at = new Date().toISOString()): QaRiskAssessment {
|
|
434
|
+
let risk: QaRisk = jev ? (jev.confidence >= 0.5 ? jev.risk : higher(jev.risk, reading.risk)) : reading.risk;
|
|
435
|
+
const factors = [...reading.factors];
|
|
436
|
+
const focus: string[] = [];
|
|
437
|
+
const touched = new Set<RiskArea>();
|
|
438
|
+
for (const area of RISK_AREAS) {
|
|
439
|
+
const hit = reading.areas[area];
|
|
440
|
+
if (!hit) continue;
|
|
441
|
+
const floor = areaFloor(area, hit, jev);
|
|
442
|
+
risk = higher(risk, floor);
|
|
443
|
+
if (floor !== "none") touched.add(area);
|
|
444
|
+
}
|
|
445
|
+
for (const [area, yes] of Object.entries(jev?.areas ?? {}) as Array<[RiskArea, number]>) {
|
|
446
|
+
if (reading.areas[area] || yes < 0.6) continue;
|
|
447
|
+
risk = higher(risk, SERIOUS.has(area) ? "high" : "medium");
|
|
448
|
+
touched.add(area);
|
|
449
|
+
factors.push(`${AREA_WORDS[area].factor}: Jev reads it in the diff (${yes.toFixed(2)})`);
|
|
450
|
+
}
|
|
451
|
+
// Whatever runs is at least LOW, unless Jev reads a small change as plainly inert.
|
|
452
|
+
const plainlyInert = jev !== undefined && jev.risk === "none" && jev.confidence >= 0.6 && jev.behaviour < 0.3 && reading.lines <= 20 && touched.size === 0;
|
|
453
|
+
if (reading.functional > 0 && !plainlyInert) risk = higher(risk, "low");
|
|
454
|
+
if (reading.risk === "none" && jev && jev.behaviour >= 0.6) risk = higher(risk, "low");
|
|
455
|
+
for (const area of touched) focus.push(AREA_WORDS[area].focus);
|
|
456
|
+
if (factors.some((factor) => factor.startsWith("a bug fix"))) focus.push("the reported bug no longer reproduces");
|
|
457
|
+
if (reading.frontend && focus.length < 4) focus.push("the changed screens: what the user sees and can do");
|
|
458
|
+
if (focus.length === 0 && risk !== "none") focus.push("the changed behaviour, as its callers and users see it");
|
|
459
|
+
const existing = risk === "none" || reading.testsChanged || (jev ? jev.testsCover >= 0.6 : risk === "low" && !factors.some((factor) => factor.startsWith("a bug fix")));
|
|
460
|
+
const budget = TEST_BUDGETS[risk];
|
|
461
|
+
const why = jev
|
|
462
|
+
? `Jev read the diff as ${jev.risk} (${jev.confidence.toFixed(2)})${jev.risk !== risk ? `; the touched areas and the rules set ${risk}` : ""}`
|
|
463
|
+
: "the engine's rules (Jev is off or had nothing to read)";
|
|
464
|
+
return {
|
|
465
|
+
risk,
|
|
466
|
+
qaRequired: risk !== "none",
|
|
467
|
+
testBudget: { min: existing ? 0 : budget.min, max: budget.max },
|
|
468
|
+
focusAreas: focus.slice(0, 5),
|
|
469
|
+
riskFactors: factors.slice(0, 8),
|
|
470
|
+
existingTestsLikelySufficient: existing,
|
|
471
|
+
reasoning: `${risk.toUpperCase()}: ${factors.slice(0, 3).join("; ")}. ${why}.`,
|
|
472
|
+
source: jev ? "classifier" : "rules",
|
|
473
|
+
at,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The QA risk of a change: the rules, refined by Jev when it is on and there
|
|
479
|
+
* is code or configuration to read. Never throws: Jev failing leaves the
|
|
480
|
+
* rules' reading.
|
|
481
|
+
*/
|
|
482
|
+
export async function assessQaRisk(input: QaRiskInput, classifier?: Classifier, signal?: AbortSignal): Promise<QaRiskAssessment> {
|
|
483
|
+
const reading = readChange(input);
|
|
484
|
+
let jev: JevRiskRead | undefined;
|
|
485
|
+
if (classifier && !reading.inert) {
|
|
486
|
+
try {
|
|
487
|
+
jev = await readRiskWithJev(classifier, input, reading, signal);
|
|
488
|
+
} catch {
|
|
489
|
+
jev = undefined;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return combineRisk(reading, jev);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** The assessment when the change could not be read (no git, or nothing changed): QA reviews it in full. */
|
|
496
|
+
export function unreadRisk(why: string, at = new Date().toISOString()): QaRiskAssessment {
|
|
497
|
+
return {
|
|
498
|
+
risk: "medium",
|
|
499
|
+
qaRequired: true,
|
|
500
|
+
testBudget: { ...TEST_BUDGETS.medium },
|
|
501
|
+
focusAreas: ["the acceptance criteria, checked against the repository as it stands"],
|
|
502
|
+
riskFactors: [why],
|
|
503
|
+
existingTestsLikelySufficient: false,
|
|
504
|
+
reasoning: `MEDIUM: ${why}, so QA reviews the work in full.`,
|
|
505
|
+
source: "rules",
|
|
506
|
+
at,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ------------------------------------------------------------ words */
|
|
511
|
+
|
|
512
|
+
/** `0`, `0-1`, `1-3`. */
|
|
513
|
+
export function budgetWords(budget: { min: number; max: number }): string {
|
|
514
|
+
return budget.min === budget.max ? `${budget.max}` : `${budget.min}-${budget.max}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** One line: `QA risk: high (Jev) · 0-6 new tests at most · security, orchestration and state`. */
|
|
518
|
+
export function qaRiskLine(assessment: QaRiskAssessment): string {
|
|
519
|
+
const by = assessment.source === "classifier" ? "Jev" : "rules";
|
|
520
|
+
const tests = assessment.qaRequired ? `${budgetWords(assessment.testBudget)} new tests at most` : "no QA agent, the engine's checks decide";
|
|
521
|
+
const about = assessment.riskFactors.map((factor) => factor.split(":")[0]).slice(0, 3).join(", ");
|
|
522
|
+
return `QA risk: ${assessment.risk} (${by}) · ${tests}${about ? ` · ${about}` : ""}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* The assessment as the QA agent reads it: its level, depth and test budget,
|
|
527
|
+
* where to look, and why. The levels themselves are explained in qa.md.
|
|
528
|
+
*/
|
|
529
|
+
export function qaRiskBrief(assessment: QaRiskAssessment): string {
|
|
530
|
+
return [
|
|
531
|
+
`## QA risk: ${assessment.risk.toUpperCase()} (${assessment.source === "classifier" ? "Jev's read of the diff" : "the engine's rules"})`,
|
|
532
|
+
`Depth: ${DEPTHS[assessment.risk]}. Test budget: ${budgetWords(assessment.testBudget)} new behavioural tests at most — a ceiling, never a quota; write none that adds no confidence.`,
|
|
533
|
+
`Existing tests likely enough: ${assessment.existingTestsLikelySufficient ? "yes — find and run them before writing any" : "no — find what exists first, then cover only the gaps that matter"}.`,
|
|
534
|
+
assessment.focusAreas.length > 0 ? `Focus:\n${assessment.focusAreas.map((area) => `- ${area}`).join("\n")}` : "",
|
|
535
|
+
assessment.riskFactors.length > 0 ? `Why:\n${assessment.riskFactors.map((factor) => `- ${factor}`).join("\n")}` : "",
|
|
536
|
+
].filter(Boolean).join("\n");
|
|
537
|
+
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -563,6 +563,8 @@ export class PlanningSession {
|
|
|
563
563
|
satOut = new Map<PanelMember, number>();
|
|
564
564
|
/** How each seat left the last round it sat. */
|
|
565
565
|
private readonly lastStatus = new Map<PanelMember, "ready" | "open">();
|
|
566
|
+
/** Seats that answered in at least one round: who took part in the plan. */
|
|
567
|
+
private readonly sat = new Set<PanelMember>();
|
|
566
568
|
/** The last round was started by the classifier settling every question; the next one waits for the user. */
|
|
567
569
|
private autoContinued = false;
|
|
568
570
|
private controller?: AbortController;
|
|
@@ -707,6 +709,15 @@ export class PlanningSession {
|
|
|
707
709
|
this.controller?.abort();
|
|
708
710
|
}
|
|
709
711
|
|
|
712
|
+
/**
|
|
713
|
+
* The seats that took part in the plan: each answered in a round and is
|
|
714
|
+
* still on the panel. One the classifier always left out, or the user
|
|
715
|
+
* unseated, sat the plan out, and the task it becomes runs without it.
|
|
716
|
+
*/
|
|
717
|
+
get panel(): PanelMember[] {
|
|
718
|
+
return PANEL_MEMBERS.filter((member) => this.sat.has(member) && this.seats.has(member));
|
|
719
|
+
}
|
|
720
|
+
|
|
710
721
|
/** Save the latest draft as a pending task. */
|
|
711
722
|
save(now = new Date()): PlannedTask {
|
|
712
723
|
const plan = this.reply?.plan;
|
|
@@ -715,6 +726,7 @@ export class PlanningSession {
|
|
|
715
726
|
title: this.title ?? this.messages[0]?.text.split("\n")[0] ?? "planned task",
|
|
716
727
|
brief: plan,
|
|
717
728
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
729
|
+
panel: this.panel,
|
|
718
730
|
}, now);
|
|
719
731
|
this.deps.feed?.log(ORACLE_LABEL, `saved ${this.saved.id} to pending tasks`, "success");
|
|
720
732
|
this.deps.onChange?.();
|
|
@@ -733,6 +745,7 @@ export class PlanningSession {
|
|
|
733
745
|
brief: partBrief({ plan, steps, proposal, index }),
|
|
734
746
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
735
747
|
split: partInfo(proposal, index, group),
|
|
748
|
+
panel: this.panel,
|
|
736
749
|
}, new Date(now.getTime() + (proposal.tasks.length - index))));
|
|
737
750
|
this.savedParts = parts;
|
|
738
751
|
this.saved = parts[0];
|
|
@@ -966,6 +979,7 @@ export class PlanningSession {
|
|
|
966
979
|
const transcript = [plannerTranscript(this.messages, this.seed, this.reply?.plan, ""), likely].filter(Boolean).join("\n\n");
|
|
967
980
|
const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal, effort)));
|
|
968
981
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
982
|
+
for (const outcome of outcomes) if (outcome.reply) this.sat.add(outcome.member);
|
|
969
983
|
this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
|
|
970
984
|
this.step = "writing the plan";
|
|
971
985
|
this.deps.onChange?.();
|
package/src/master/decisions.ts
CHANGED
|
@@ -7,14 +7,17 @@ import { builtSomething, onFastTrack, qaRequired, qaTookPart } from "../workflow
|
|
|
7
7
|
/**
|
|
8
8
|
* §19: everything the engine requires before completion may be declared. The
|
|
9
9
|
* fast track asks for a finished worker step instead of a QA pass, and for
|
|
10
|
-
* QA's part only when the change needs tests
|
|
10
|
+
* QA's part only when the change needs tests; so does a planned task whose
|
|
11
|
+
* QA sat out the planning panel.
|
|
11
12
|
*/
|
|
12
13
|
export function completionBlockers(task: Task, pendingCount: number): string[] {
|
|
13
14
|
const blockers: string[] = [];
|
|
14
15
|
if (!task.plan) blockers.push("no approved plan is recorded");
|
|
15
16
|
if (onFastTrack(task)) {
|
|
16
17
|
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
17
|
-
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (
|
|
18
|
+
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (run the QA gate: action=qa)");
|
|
19
|
+
} else if (!qaRequired(task)) {
|
|
20
|
+
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
18
21
|
} else if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
|
|
19
22
|
if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
|
|
20
23
|
if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
|
package/src/master/master.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface WorkerRequest {
|
|
|
238
238
|
time?: AgentTime;
|
|
239
239
|
/** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
|
|
240
240
|
ask?: { onAsk: RelayAsk; previews: string };
|
|
241
|
+
/** A QA step's verification budget: Jev's QA risk read of the change so far. */
|
|
242
|
+
risk?: string;
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
/** What a worker that may ask the user is told about it. */
|
|
@@ -264,6 +266,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
|
|
|
264
266
|
? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
|
|
265
267
|
: "No scout findings were collected for your domain; verify the repository yourself.",
|
|
266
268
|
likely,
|
|
269
|
+
request.risk ?? "",
|
|
267
270
|
request.time?.note ?? "",
|
|
268
271
|
request.ask ? askGuidance(request.ask.previews) : "",
|
|
269
272
|
].filter(Boolean).join("\n\n");
|
|
@@ -344,6 +347,8 @@ export interface ReviewerRequest {
|
|
|
344
347
|
previousRound?: string;
|
|
345
348
|
/** The engine's lint result on the touched files and the suppressions the task added. */
|
|
346
349
|
lint?: string;
|
|
350
|
+
/** Jev's QA risk read of the change: the gate's depth, test budget and focus. */
|
|
351
|
+
risk?: string;
|
|
347
352
|
/** Under a task time budget: the gate's time. */
|
|
348
353
|
time?: AgentTime;
|
|
349
354
|
instruction?: string;
|
|
@@ -365,6 +370,7 @@ function reviewerContext(request: ReviewerRequest): string {
|
|
|
365
370
|
return [
|
|
366
371
|
`Task state: reviewing. Domain: ${request.domain}.`,
|
|
367
372
|
"You may not modify implementation. Report required changes instead.",
|
|
373
|
+
request.risk ?? "",
|
|
368
374
|
request.previousRound ?? "",
|
|
369
375
|
request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
|
|
370
376
|
owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
|