@a-t-h-i/bot-lobby 0.6.24 → 0.6.26
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 +11 -3
- package/package.json +1 -5
- package/prompts/designer.md +14 -0
- package/prompts/master.md +30 -7
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +22 -3
- package/prompts/qa.md +23 -3
- package/prompts/quickfix.md +4 -0
- package/prompts/reviewer.md +17 -0
- package/prompts/worker.md +21 -6
- package/src/ask/tool.ts +3 -3
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/delivery/review.ts +1 -1
- package/src/execution/agent-runner.ts +0 -18
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +4 -17
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +4 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +20 -3
- package/src/lobby/service.ts +5 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/master/research.ts +0 -16
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +12 -4
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +51 -3
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/budget.ts +49 -22
- package/src/state/changes.ts +8 -2
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +2 -6
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/api/tasks.ts +27 -6
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +7 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/protocol.ts +30 -4
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +105 -21
- package/webui/dist/assets/index-C1IwecNS.css +2 -0
- package/webui/dist/assets/index-C4Ww36TW.js +105 -0
- package/webui/dist/build.json +58 -62
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BSewK1QO.css +0 -2
- package/webui/dist/assets/index-CHQYSZXA.js +0 -105
|
@@ -20,6 +20,7 @@ export const SCENARIOS = [
|
|
|
20
20
|
"question",
|
|
21
21
|
"questions3",
|
|
22
22
|
"issues",
|
|
23
|
+
"mockups",
|
|
23
24
|
] as const;
|
|
24
25
|
|
|
25
26
|
export type ScenarioName = (typeof SCENARIOS)[number];
|
|
@@ -58,11 +59,13 @@ export interface ScenarioFixture {
|
|
|
58
59
|
/** Streamed back (in deltas) after `lobby.send`. */
|
|
59
60
|
oracleReply: string;
|
|
60
61
|
/** The Settings page's mock: a partial config merged over the defaults, and the models Pi offers. */
|
|
61
|
-
settings?: { config?: Record<string, unknown>; models?: Array<{ id: string; label: string; thinkingLevels: string[] }> };
|
|
62
|
+
settings?: { config?: Record<string, unknown>; models?: Array<{ id: string; label: string; thinkingLevels: string[] }>; linters?: Array<{ tool: string; folder: string; installed: boolean }> };
|
|
62
63
|
/** Opened through `promptHub` shortly after a send, if any. */
|
|
63
64
|
questionAfterSend: FixturePrompt | null;
|
|
64
65
|
/** Tasks backing `tasks.list` (absent means none). */
|
|
65
66
|
mockTasks?: Array<Record<string, unknown>>;
|
|
67
|
+
/** Work clocks by task id (`work.json` as the owner keeps it); a run's start is given as how long ago, so it reads live. */
|
|
68
|
+
mockWork?: Record<string, { workedMs: number; running: boolean; active?: Array<{ runId: string; instruction: string; startedAgoMs: number }> }>;
|
|
66
69
|
/** Saved plans backing `plans.start`/`plans.discard` and the pending rows. */
|
|
67
70
|
mockPlans?: Array<{ id: string; title: string; brief: string }>;
|
|
68
71
|
/** Archived tasks backing `tasks.archived`. */
|
package/src/webui/protocol.ts
CHANGED
|
@@ -95,6 +95,12 @@ export interface StatusInfo {
|
|
|
95
95
|
windows: Array<{ name: string; url: string }>;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Agent work time as of the reply: the page adds the time that passes while `running`. */
|
|
99
|
+
export interface WorkClock {
|
|
100
|
+
workedMs: number;
|
|
101
|
+
running: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
98
104
|
/** One task header as the lobby snapshot carries it. */
|
|
99
105
|
export interface SnapshotTask {
|
|
100
106
|
id: string;
|
|
@@ -105,6 +111,10 @@ export interface SnapshotTask {
|
|
|
105
111
|
git?: { branch: string; from?: string };
|
|
106
112
|
progress?: { done: number; total: number };
|
|
107
113
|
currentStep?: string;
|
|
114
|
+
/** The steps a worker is on right now, with the worker time on each (ms, as of the snapshot). */
|
|
115
|
+
activeSteps?: Array<{ text: string; workedMs: number }>;
|
|
116
|
+
/** How long the task's agents have worked on it, idle time left out. */
|
|
117
|
+
work?: WorkClock;
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
/** A file attached in the composer, as `files.upload` answers (the message names it by its path). */
|
|
@@ -148,7 +158,7 @@ export interface BackgroundSessionInfo {
|
|
|
148
158
|
dialogs: SessionDialog[];
|
|
149
159
|
}
|
|
150
160
|
|
|
151
|
-
/** How loud a lobby notice is
|
|
161
|
+
/** How loud a lobby notice is. */
|
|
152
162
|
export type NoticeLevel = "info" | "success" | "warning" | "error";
|
|
153
163
|
|
|
154
164
|
/** One model Pi offers, for the settings page's picker. */
|
|
@@ -159,6 +169,14 @@ export interface SettingsModelInfo {
|
|
|
159
169
|
thinkingLevels: string[];
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
/** A linter the project configures (`settings.linters`): which, in which folder, and whether it is installed to run. */
|
|
173
|
+
export interface SettingsLinter {
|
|
174
|
+
tool: string;
|
|
175
|
+
/** Repository-relative; "" is the top. */
|
|
176
|
+
folder: string;
|
|
177
|
+
installed: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
/** `settings.get`: the effective config (never a secret) and the models Pi offers. */
|
|
163
181
|
export interface SettingsInfo {
|
|
164
182
|
config: BotLobbyConfig;
|
|
@@ -348,8 +366,15 @@ export interface TaskDetail {
|
|
|
348
366
|
proposal?: string;
|
|
349
367
|
/** The approved plan in Markdown. */
|
|
350
368
|
plan?: string;
|
|
351
|
-
/** The plan as
|
|
352
|
-
|
|
369
|
+
/** The plan after the steps list it opens with (the steps show as the checklist); absent when the plan is read whole. */
|
|
370
|
+
planDetails?: string;
|
|
371
|
+
/**
|
|
372
|
+
* The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
|
|
373
|
+
* each with its worker time (ms, as of the reply).
|
|
374
|
+
*/
|
|
375
|
+
steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number }>;
|
|
376
|
+
/** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
|
|
377
|
+
work?: WorkClock;
|
|
353
378
|
amendments: string[];
|
|
354
379
|
/** Approvals waiting on you: `kind` plus `for <domain>: <detail>`. */
|
|
355
380
|
waiting: Array<{ kind: string; detail: string }>;
|
|
@@ -555,7 +580,7 @@ export interface Api {
|
|
|
555
580
|
"quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
|
|
556
581
|
"quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
|
|
557
582
|
"metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string }; result: MetricsData };
|
|
558
|
-
"knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[] } };
|
|
583
|
+
"knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };
|
|
559
584
|
"knowledge.open": { request: { agent: KnowledgeAgentName; file: string }; result: { view: KnowledgeViewData } };
|
|
560
585
|
"knowledge.edit": { request: { agent: KnowledgeAgentName; file: string; ref: EntryRefInfo; text: string }; result: { notice: string } };
|
|
561
586
|
"knowledge.add": { request: { agent: KnowledgeAgentName; file: string; ref?: EntryRefInfo; text: string }; result: { notice: string } };
|
|
@@ -583,6 +608,7 @@ export interface Api {
|
|
|
583
608
|
"issues.create": { request: { text: string }; result: { notice: string } };
|
|
584
609
|
"settings.get": { request: Record<string, never>; result: SettingsInfo };
|
|
585
610
|
"settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
|
|
611
|
+
"settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
|
|
586
612
|
}
|
|
587
613
|
|
|
588
614
|
export type ApiName = keyof Api;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lint gate. The engine lints the files a task's agents touched (and only
|
|
3
|
+
* those) with the project's own linter, after every worker step, before QA and
|
|
4
|
+
* at completion, so what the oracle and QA are told is a check the engine ran,
|
|
5
|
+
* not an agent's word. Every file is linted whole, but only problems on the
|
|
6
|
+
* lines the task changed (or in files it created) are the task's: what was
|
|
7
|
+
* already there is reported and never holds it. In block mode new errors hold
|
|
8
|
+
* completion; in advise mode they are reported only. Lint suppressions and
|
|
9
|
+
* lint-config changes the task added are listed for QA, which cannot edit, to
|
|
10
|
+
* judge: a worker cannot quietly get past the gate.
|
|
11
|
+
*/
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
import type { LintConfig, LintMode } from "../schemas/configuration.ts";
|
|
14
|
+
import type { LintFinding, LintReport, LintRun } from "../schemas/lint.ts";
|
|
15
|
+
import { addedLines, type AddedLines } from "../execution/git.ts";
|
|
16
|
+
import { execLint, lintFingerprint, MAX_FINDINGS, planLint, runPlan, type LintExec } from "../execution/lint.ts";
|
|
17
|
+
import { truncate } from "../text.ts";
|
|
18
|
+
|
|
19
|
+
/** Comments and attributes that switch a linter or type checker off. */
|
|
20
|
+
const SUPPRESSION = /\b(?:eslint-disable(?:-next-line|-line)?|eslint-enable|oxlint-disable(?:-next-line|-line)?|biome-ignore(?:-all|-start)?|deno-lint-ignore(?:-file)?|stylelint-disable(?:-next-line|-line)?|jshint\s+ignore)\b|@ts-(?:ignore|nocheck|expect-error)\b|#\s*(?:noqa|type:\s*ignore|pylint:\s*disable|ruff:\s*noqa|rubocop:\s*disable)\b|\/\/\s*nolint\b|NOLINT(?:NEXTLINE)?\b|@SuppressWarnings\b|#!?\[allow\(/;
|
|
21
|
+
|
|
22
|
+
/** Files that decide what a linter or the type checker checks. */
|
|
23
|
+
const LINT_CONFIG = /^(?:eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.(?:js|cjs|json|ya?ml))?|\.eslintignore|biome\.jsonc?|\.oxlintrc\.json|oxlint\.config\.ts|\.oxlintignore|ruff\.toml|\.ruff\.toml|\.flake8|\.pylintrc|\.golangci\.(?:ya?ml|toml|json)|\.rubocop\.yml|\.stylelintrc(?:\.\w+)?|tsconfig(?:\.[\w-]+)?\.json)$/;
|
|
24
|
+
/** Shared config files that count only when the added lines touch a linter. */
|
|
25
|
+
const SHARED_CONFIG = /^(?:package\.json|pyproject\.toml|setup\.cfg|tox\.ini)$/;
|
|
26
|
+
const LINT_WORDS = /lint|eslint|biome|oxlint|ruff|flake8|pylint|noqa|strict/i;
|
|
27
|
+
|
|
28
|
+
/** Suppressions shown to QA. */
|
|
29
|
+
const MAX_SUPPRESSIONS = 40;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every lint suppression and lint-config change among the lines the task
|
|
33
|
+
* added: `src/a.ts:3 — // eslint-disable-next-line no-explicit-any`, or
|
|
34
|
+
* `eslint.config.js — lint config changed`.
|
|
35
|
+
*/
|
|
36
|
+
export function lintSuppressions(added: AddedLines | undefined, files: readonly string[]): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
const configs = new Set<string>();
|
|
39
|
+
for (const file of files) if (LINT_CONFIG.test(basename(file))) configs.add(file);
|
|
40
|
+
for (const entry of added?.text ?? []) {
|
|
41
|
+
if (SHARED_CONFIG.test(basename(entry.file)) && LINT_WORDS.test(entry.text)) configs.add(entry.file);
|
|
42
|
+
else if (SUPPRESSION.test(entry.text) && !LINT_CONFIG.test(basename(entry.file))) out.push(`${entry.file}:${entry.line} — ${truncate(entry.text.trim(), 160)}`);
|
|
43
|
+
}
|
|
44
|
+
return [...[...configs].sort().map((file) => `${file} — lint or type-check config changed`), ...out].slice(0, MAX_SUPPRESSIONS);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether a finding sits on a line the task changed: always in a file it created, and when git cannot say. */
|
|
48
|
+
function fresh(finding: LintFinding, added: AddedLines | undefined): boolean {
|
|
49
|
+
if (!added || added.whole.has(finding.file)) return true;
|
|
50
|
+
if (!finding.line) return true;
|
|
51
|
+
return added.lines.get(finding.file)?.has(finding.line) ?? false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** New ones first, errors before warnings, then by place. */
|
|
55
|
+
function order(a: LintFinding, b: LintFinding): number {
|
|
56
|
+
return Number(Boolean(b.fresh)) - Number(Boolean(a.fresh)) || Number(b.severity === "error") - Number(a.severity === "error") || a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LintGateInput {
|
|
60
|
+
/** The repository's top folder; `files` are relative to it. */
|
|
61
|
+
top: string;
|
|
62
|
+
/** The files the task's agents touched. */
|
|
63
|
+
files: readonly string[];
|
|
64
|
+
/** The commit the task's work is measured from (HEAD when absent). */
|
|
65
|
+
base?: string;
|
|
66
|
+
config: LintConfig;
|
|
67
|
+
/** The last report: reused when the files and settings are unchanged. */
|
|
68
|
+
previous?: LintReport;
|
|
69
|
+
exec?: LintExec;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
now?: () => Date;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Lint the touched files, or reuse the last report when nothing it covers has changed since. */
|
|
75
|
+
export async function runLintGate(input: LintGateInput): Promise<LintReport> {
|
|
76
|
+
const at = (input.now?.() ?? new Date()).toISOString();
|
|
77
|
+
const files = [...new Set(input.files)].sort();
|
|
78
|
+
const fingerprint = lintFingerprint(input.top, files, input.config);
|
|
79
|
+
const previous = input.previous;
|
|
80
|
+
if (previous && previous.fingerprint === fingerprint && (previous.state === "passing" || previous.state === "failing")) return previous;
|
|
81
|
+
const empty = (note: string): LintReport => ({ at, state: "skipped", files, runs: [], errors: 0, warnings: 0, freshErrors: 0, freshWarnings: 0, suppressions: [], note, fingerprint });
|
|
82
|
+
if (files.length === 0) return empty("no files touched yet");
|
|
83
|
+
const added = await addedLines(input.top, input.base, files);
|
|
84
|
+
const suppressions = lintSuppressions(added, files);
|
|
85
|
+
const plans = planLint(input.top, files, input.config);
|
|
86
|
+
if (plans.length === 0) return { ...empty(input.config.command.trim() ? "none of the touched files is of a type the lint command takes" : "no linter is configured for the touched files"), suppressions };
|
|
87
|
+
const runs: LintRun[] = [];
|
|
88
|
+
let freshErrors = 0;
|
|
89
|
+
let freshWarnings = 0;
|
|
90
|
+
for (const plan of plans) {
|
|
91
|
+
const run = await runPlan(plan, input.top, input.config, input.exec ?? execLint, input.signal);
|
|
92
|
+
const findings = run.findings.map((finding) => ({ ...finding, fresh: fresh(finding, added) })).sort(order);
|
|
93
|
+
const counts = countFresh(findings);
|
|
94
|
+
freshErrors += counts.freshErrors;
|
|
95
|
+
freshWarnings += counts.freshWarnings;
|
|
96
|
+
runs.push({ ...run, findings: findings.slice(0, MAX_FINDINGS) });
|
|
97
|
+
}
|
|
98
|
+
const ran = runs.filter((run) => run.state !== "unavailable");
|
|
99
|
+
return {
|
|
100
|
+
at,
|
|
101
|
+
state: freshErrors > 0 ? "failing" : ran.length === 0 ? "unavailable" : "passing",
|
|
102
|
+
files,
|
|
103
|
+
runs,
|
|
104
|
+
errors: runs.reduce((total, run) => total + run.errors, 0),
|
|
105
|
+
warnings: runs.reduce((total, run) => total + run.warnings, 0),
|
|
106
|
+
freshErrors,
|
|
107
|
+
freshWarnings,
|
|
108
|
+
suppressions,
|
|
109
|
+
fingerprint,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface FreshCounts {
|
|
114
|
+
freshErrors: number;
|
|
115
|
+
freshWarnings: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function countFresh(findings: readonly LintFinding[]): FreshCounts {
|
|
119
|
+
const mine = findings.filter((finding) => finding.fresh);
|
|
120
|
+
const errors = mine.filter((finding) => finding.severity === "error").length;
|
|
121
|
+
return { freshErrors: errors, freshWarnings: mine.length - errors };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True when the report holds completion: block mode, new errors, and the user has not accepted the work as it is. */
|
|
125
|
+
export function lintHolds(report: LintReport | undefined, mode: LintMode, waived: boolean): boolean {
|
|
126
|
+
return mode === "block" && report?.state === "failing" && !waived;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
130
|
+
|
|
131
|
+
/** `ESLint, Ruff` */
|
|
132
|
+
function tools(report: LintReport): string {
|
|
133
|
+
return [...new Set(report.runs.map((run) => run.tool))].join(", ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One line: `ESLint on 3 touched files: 2 new errors, 1 new warning (4 more already there)`. */
|
|
137
|
+
export function lintSummary(report: LintReport): string {
|
|
138
|
+
if (report.state === "skipped") return `Lint: ${report.note ?? "nothing to lint"}.`;
|
|
139
|
+
const head = `${tools(report)} on ${plural(report.files.length, "touched file")}`;
|
|
140
|
+
if (report.state === "unavailable") return `${head}: could not run.`;
|
|
141
|
+
const old = report.errors + report.warnings - report.freshErrors - report.freshWarnings;
|
|
142
|
+
const news = [report.freshErrors ? plural(report.freshErrors, "new error") : "", report.freshWarnings ? plural(report.freshWarnings, "new warning") : ""].filter(Boolean).join(", ");
|
|
143
|
+
return `${head}: ${news || "no new problems"}${old > 0 ? ` (${plural(old, "problem")} already there before this task)` : ""}.`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function place(finding: LintFinding): string {
|
|
147
|
+
return `${finding.file}${finding.line ? `:${finding.line}${finding.column ? `:${finding.column}` : ""}` : ""}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findingLine(finding: LintFinding, owners?: ReadonlyMap<string, string>): string {
|
|
151
|
+
const owner = owners?.get(finding.file);
|
|
152
|
+
return `- ${place(finding)}${finding.rule ? ` [${finding.rule}]` : ""} ${truncate(finding.message.replace(/\s+/g, " "), 200)} — ${finding.severity}${owner ? ` (${owner})` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function newFindings(report: LintReport, max: number): LintFinding[] {
|
|
156
|
+
return report.runs.flatMap((run) => run.findings.filter((finding) => finding.fresh)).slice(0, max);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cannotRun(report: LintReport): string[] {
|
|
160
|
+
return report.runs.filter((run) => run.state === "unavailable").map((run) => `${run.tool}${run.folder ? ` (${run.folder}/)` : ""}: ${truncate((run.output ?? "could not run").split("\n")[0]!, 200)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What the oracle is told after a step, before QA's verdict and at completion:
|
|
165
|
+
* the new problems with the domain that touched each file, what to do about
|
|
166
|
+
* them for the mode, the suppressions the task added, and what could not run.
|
|
167
|
+
* Empty when there is nothing to say (no linter, nothing touched).
|
|
168
|
+
*/
|
|
169
|
+
export function lintNote(report: LintReport | undefined, mode: LintMode, owners?: ReadonlyMap<string, string>): string {
|
|
170
|
+
if (!report || mode === "off" || report.state === "skipped") return "";
|
|
171
|
+
const lines = [`Lint — ${lintSummary(report)}`];
|
|
172
|
+
if (report.state === "failing" || report.freshWarnings > 0) {
|
|
173
|
+
lines.push(...newFindings(report, 20).map((finding) => findingLine(finding, owners)));
|
|
174
|
+
lines.push(
|
|
175
|
+
mode === "block" && report.state === "failing"
|
|
176
|
+
? "Completion is held until the new errors are fixed: delegate each fix to the domain that touched the file, and have it fix the cause, never silence the rule (QA reads every new suppression as a finding)."
|
|
177
|
+
: "Advisory: have the domain that touched each file fix the new ones, or tell the user why they stay. Never have a rule silenced to pass.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const blocked = cannotRun(report);
|
|
181
|
+
if (blocked.length > 0) lines.push(`Could not run, so nothing is held for it: ${blocked.join("; ")}.`);
|
|
182
|
+
if (report.suppressions.length > 0) lines.push(`Suppressions and lint-config changes this task added (QA judges each):\n${report.suppressions.slice(0, 10).map((entry) => `- ${entry}`).join("\n")}`);
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The lint line under QA's verdict for the oracle: the summary, and whether it still holds completion. */
|
|
187
|
+
export function lintBrief(report: LintReport | undefined, mode: LintMode, waived: boolean): string {
|
|
188
|
+
if (!report || mode === "off" || report.state === "skipped") return "";
|
|
189
|
+
const held = lintHolds(report, mode, waived) ? " Completion stays held until the new errors are fixed." : "";
|
|
190
|
+
return `Lint — ${lintSummary(report)}${held}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What QA reads: the engine's lint result, and every suppression it must judge. */
|
|
194
|
+
export function lintContext(report: LintReport | undefined, mode: LintMode): string {
|
|
195
|
+
if (!report || mode === "off") return "";
|
|
196
|
+
const lines = [`Lint, run by the engine on the files this task touched (${mode} mode): ${lintSummary(report)}`];
|
|
197
|
+
const fresh = newFindings(report, 30);
|
|
198
|
+
if (fresh.length > 0) lines.push(`On lines this task changed:\n${fresh.map((finding) => findingLine(finding)).join("\n")}`);
|
|
199
|
+
const blocked = cannotRun(report);
|
|
200
|
+
if (blocked.length > 0) lines.push(`Could not run: ${blocked.join("; ")}. Read the touched files with the project's rules in mind instead.`);
|
|
201
|
+
lines.push(
|
|
202
|
+
report.suppressions.length > 0
|
|
203
|
+
? `Suppressions and lint-config changes this task added. Judge each: one without a reason that holds (in a comment beside it, or the worker's report) is a finding, and so is a rule loosened or a file ignored to pass:\n${report.suppressions.map((entry) => `- ${entry}`).join("\n")}`
|
|
204
|
+
: "This task added no lint suppressions and changed no lint config. Still look for code removed or bent only to satisfy a rule.",
|
|
205
|
+
);
|
|
206
|
+
return lines.join("\n\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The activity log's line, and how it reads. */
|
|
210
|
+
export function lintFeed(taskId: string, report: LintReport): { text: string; kind: "success" | "warning" | "error" | "info" } {
|
|
211
|
+
const first = newFindings(report, 1)[0];
|
|
212
|
+
const text = `${taskId} · ${lintSummary(report).replace(/\.$/, "")}${first ? ` — ${place(first)}${first.rule ? ` ${first.rule}` : ""}` : ""}`;
|
|
213
|
+
const kind = report.state === "failing" ? "error" : report.state === "unavailable" || report.freshWarnings > 0 || report.suppressions.length > 0 ? "warning" : report.state === "passing" ? "success" : "info";
|
|
214
|
+
return { text, kind };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Why completion is refused, for the oracle. */
|
|
218
|
+
export function lintRefusal(report: LintReport): string {
|
|
219
|
+
const fresh = newFindings(report, 5).filter((finding) => finding.severity === "error");
|
|
220
|
+
return `lint found ${plural(report.freshErrors, "error")} on lines this task changed (${fresh.map((finding) => `${place(finding)}${finding.rule ? ` ${finding.rule}` : ""}`).join(", ")}${report.freshErrors > fresh.length ? ", …" : ""}). Delegate the fixes to the domain that touched each file and call complete again; the user can accept the work as it is with /bot-lobby accept`;
|
|
221
|
+
}
|
package/src/workflow/track.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* how much process it gets. A small, clear, low-risk request takes the fast
|
|
4
4
|
* track: the oracle delegates straight to the agents it needs (no scouts, no
|
|
5
5
|
* proposal round, no plan document), QA takes part only when the change
|
|
6
|
-
* needs tests,
|
|
6
|
+
* needs tests (a fix to pin, a risky change, more than a small one; a small
|
|
7
|
+
* non-destructive change is left to the worker's checks and the lint gate), and the researcher only when it needs facts from outside the
|
|
7
8
|
* repository. Anything bigger, riskier or unclear takes the full workflow.
|
|
8
9
|
*
|
|
9
10
|
* The engine reads the request once as the task starts, with the rules below
|
|
@@ -108,6 +109,9 @@ const RESEARCH = terms([
|
|
|
108
109
|
"compatibility", "compatible with", "research", "verify online",
|
|
109
110
|
]);
|
|
110
111
|
|
|
112
|
+
/** Wording that replaces or takes away existing behavior, rather than adding to it. */
|
|
113
|
+
const DESTRUCTIVE = /\b(?:instead of|rather than|no longer|stop (?:returning|sending|accepting|allowing)|replac(?:e|es|ed|ing)|remov(?:e|es|ed|ing)|delet(?:e|es|ed|ing)|drop(?:s|ped|ping)?|renam(?:e|es|ed|ing)|deprecat(?:e|es|ed|ing)|break(?:s|ing)? (?:the )?(?:api|contract|format))\b/i;
|
|
114
|
+
|
|
111
115
|
/** Serious whatever its size: security, money, data, production. Such a change gets a plan and the QA gate. */
|
|
112
116
|
const RISK = terms([
|
|
113
117
|
"security", "secure", "vulnerability", "vulnerabilities", "cve", "exploit", "xss", "csrf", "injection", "auth",
|
|
@@ -217,12 +221,17 @@ export function readRequest(request: string): RequestReading {
|
|
|
217
221
|
if (backend) roster.add("backend");
|
|
218
222
|
const asked = matched(TESTS, text) ?? (TEST_FILE.test(text) ? "a test file" : undefined);
|
|
219
223
|
const bugfix = size !== "trivial" && !docsOnly ? matched(BUGFIX, text) : undefined;
|
|
224
|
+
const risk = matched(RISK, text);
|
|
225
|
+
const changes = backend && size !== "trivial" ? risk ?? matched(DESTRUCTIVE, text) : undefined;
|
|
226
|
+
// A small, additive change is covered by the worker's own checks and the lint gate; QA joins for what
|
|
227
|
+
// they cannot settle: a fix to pin, a backend change that is risky or replaces behavior, more than a
|
|
228
|
+
// small change, or tests asked for.
|
|
220
229
|
const tests = asked
|
|
221
230
|
? `tests: asked for (${asked})`
|
|
222
231
|
: bugfix
|
|
223
232
|
? `tests: a bug fix needs a regression test (${bugfix})`
|
|
224
|
-
:
|
|
225
|
-
? `tests: backend
|
|
233
|
+
: changes
|
|
234
|
+
? `tests: a backend change that ${risk ? "touches" : "changes existing behavior:"} ${changes}`
|
|
226
235
|
: size === "medium" || size === "large"
|
|
227
236
|
? "tests: the QA gate checks a change this size"
|
|
228
237
|
: undefined;
|
|
@@ -236,7 +245,6 @@ export function readRequest(request: string): RequestReading {
|
|
|
236
245
|
reasons.push(`research: ${research}`);
|
|
237
246
|
}
|
|
238
247
|
|
|
239
|
-
const risk = matched(RISK, text);
|
|
240
248
|
if (risk) reasons.push(`serious: touches ${risk}`);
|
|
241
249
|
const vague = words < 8 && !concrete ? matched(VAGUE, text) : undefined;
|
|
242
250
|
const undecided = matched(UNDECIDED, text);
|