@a-t-h-i/bot-lobby 0.6.26 → 0.6.28
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 +36 -11
- 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/host.ts +19 -0
- package/src/lobby/planner.ts +145 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +10 -6
- package/src/pi/start-task.ts +31 -4
- package/src/pi/switch.ts +7 -2
- package/src/pi/tools.ts +3 -0
- package/src/pi/ui.ts +9 -2
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +36 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +59 -0
- package/src/webui/server.ts +10 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +51 -35
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
- package/webui/dist/assets/index-C4Ww36TW.js +0 -105
|
@@ -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/host.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ChatEntry, LobbyFeed } from "./feed.ts";
|
|
|
13
13
|
import type { BackgroundSession } from "./sessions.ts";
|
|
14
14
|
import type { QuickFixQueue } from "./quickfix.ts";
|
|
15
15
|
import type { PlannerSeed, PlanningSession } from "./planner.ts";
|
|
16
|
+
import type { PlanRecord, PlanSummary } from "../state/plan-history.ts";
|
|
16
17
|
import type { IssuesState } from "./issues.ts";
|
|
17
18
|
import type { PullsState } from "./pulls.ts";
|
|
18
19
|
import type { PullReviews } from "./pr-review.ts";
|
|
@@ -115,6 +116,16 @@ export interface LobbyService {
|
|
|
115
116
|
abortMaster(): void;
|
|
116
117
|
quickfix: QuickFixQueue;
|
|
117
118
|
planner(): PlanningSession | undefined;
|
|
119
|
+
/** The project's previous plans (never saved as a task), newest first; the archived ones when asked. */
|
|
120
|
+
previousPlans(archived: boolean): PlanSummary[];
|
|
121
|
+
/** One previous plan, whole; throws when there is none (or it was saved as a task). */
|
|
122
|
+
previousPlan(id: string): PlanRecord;
|
|
123
|
+
/** Archive a previous plan, or bring it back; returns a notice (throws when it cannot). */
|
|
124
|
+
archivePlan(id: string, archived: boolean): string;
|
|
125
|
+
/** Delete a previous plan for good; returns a notice (throws when it cannot). */
|
|
126
|
+
deletePlan(id: string): string;
|
|
127
|
+
/** Carry a previous plan on as the plan on screen; returns a notice. */
|
|
128
|
+
reopenPlan(id: string): string;
|
|
118
129
|
/** Start a planning session (replacing any other) with these seats on the panel. */
|
|
119
130
|
newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
|
|
120
131
|
/** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
|
|
@@ -150,6 +161,8 @@ export interface LobbyService {
|
|
|
150
161
|
sessionModel?(): string | undefined;
|
|
151
162
|
/** The Issues tab is switched on (`lobby.issues`). */
|
|
152
163
|
issuesEnabled(): boolean;
|
|
164
|
+
/** What this window carried on from one that stopped unexpectedly, when it did. */
|
|
165
|
+
recovered?(): { at: number; text: string } | undefined;
|
|
153
166
|
/** This window's pi session name, when it has one. */
|
|
154
167
|
sessionName(): string | undefined;
|
|
155
168
|
/** Background sessions this window started, oldest first. */
|
|
@@ -161,6 +174,12 @@ export interface LobbyService {
|
|
|
161
174
|
setAuto(taskId: string, on: boolean): void;
|
|
162
175
|
/** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
|
|
163
176
|
sendToTask(taskId: string, text: string): string;
|
|
177
|
+
/**
|
|
178
|
+
* Resume a task from the Tasks screen, leaving this window where it is: a
|
|
179
|
+
* paused task is unpaused where it runs, one nothing runs carries on in a
|
|
180
|
+
* background session. A notice, and the background session's key.
|
|
181
|
+
*/
|
|
182
|
+
resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
|
|
164
183
|
/** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
|
|
165
184
|
sendToSession(sessionId: string, text: string): string;
|
|
166
185
|
/** Sessions running in this project, other than this window's, from their heartbeats. */
|