@a-t-h-i/bot-lobby 0.6.23 → 0.6.25
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 +8 -2
- package/package.json +1 -1
- package/prompts/designer.md +14 -0
- package/prompts/master.md +18 -0
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +17 -1
- 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/delivery/review.ts +1 -1
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/lobby/ask.ts +4 -2
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +19 -2
- package/src/lobby/service.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +3 -1
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/configuration.ts +51 -1
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/changes.ts +8 -2
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +1 -0
- package/src/webui/dev/fixtures.ts +2 -1
- package/src/webui/protocol.ts +10 -1
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +70 -13
- package/webui/dist/assets/index-BRVbs5In.js +105 -0
- package/webui/dist/assets/index-BeRXVi_r.css +2 -0
- package/webui/dist/build.json +51 -35
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BWwnNfkg.css +0 -2
- package/webui/dist/assets/index-Bh8la3EM.js +0 -105
package/src/webui/api/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { plansDiscard, plansGet, plansStart } from "./plans.ts";
|
|
|
23
23
|
import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
|
|
24
24
|
import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
|
|
25
25
|
import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
|
|
26
|
-
import { settingsGet, settingsSet } from "./settings.ts";
|
|
26
|
+
import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
|
|
27
27
|
import { noticeText, pushNotice } from "../notices.ts";
|
|
28
28
|
|
|
29
29
|
/** What a handler reads besides the request body. */
|
|
@@ -367,6 +367,7 @@ function buildRoutes(): Record<string, Route> {
|
|
|
367
367
|
schema: Type.Object({ patch: Type.Object({}, { additionalProperties: true }) }, { additionalProperties: false }),
|
|
368
368
|
run: (body, ctx) => settingsSet(body as { patch: Record<string, unknown> }, ctx),
|
|
369
369
|
};
|
|
370
|
+
routes["settings.linters"] = { schema: Empty, run: (_body, ctx) => settingsLinters(ctx) };
|
|
370
371
|
return routes;
|
|
371
372
|
}
|
|
372
373
|
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { KnowledgeAgent } from "../../knowledge/paths.ts";
|
|
9
9
|
import type { EntryRef } from "../../lobby/knowledge.ts";
|
|
10
|
-
import type { KnowledgeFileInfo, KnowledgeViewData } from "../protocol.ts";
|
|
10
|
+
import type { KnowledgeAgentName, KnowledgeFileInfo, KnowledgeViewData } from "../protocol.ts";
|
|
11
|
+
import { INHERIT_MODEL, type BotLobbyConfig } from "../../schemas/configuration.ts";
|
|
12
|
+
import { loadConfig } from "../../state/project.ts";
|
|
11
13
|
import type { ApiContext } from "./index.ts";
|
|
12
14
|
import { fail } from "./index.ts";
|
|
13
15
|
|
|
@@ -31,8 +33,14 @@ interface RefBody extends FileBody {
|
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** Every agent's files with their size and note counts, in tab order. */
|
|
34
|
-
export function knowledgeFiles(ctx: ApiContext): { files: KnowledgeFileInfo[] } {
|
|
35
|
-
return { files: ctx.service.knowledge.files() };
|
|
36
|
+
export function knowledgeFiles(ctx: ApiContext): { files: KnowledgeFileInfo[]; models: Record<KnowledgeAgentName, string> } {
|
|
37
|
+
return { files: ctx.service.knowledge.files(), models: agentModels(ctx.service.config?.() ?? loadConfig()) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The model each knowledge-keeping agent runs on, as the tree names it ("session model" when none is pinned). */
|
|
41
|
+
export function agentModels(config: BotLobbyConfig): Record<KnowledgeAgentName, string> {
|
|
42
|
+
const named = (model: string) => (model === INHERIT_MODEL || !model.trim() ? "session model" : model);
|
|
43
|
+
return { master: named(config.master.model), designer: named(config.agents.designer.model), backend: named(config.agents.backend.model), qa: named(config.agents.qa.model) };
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
/** One file as entries with their notes. */
|
|
@@ -10,7 +10,7 @@ import { lobbyTopics } from "../../lobby/topics.ts";
|
|
|
10
10
|
import { prefillModels } from "../../pi/model-settings.ts";
|
|
11
11
|
import { kindLabel } from "../../pi/model-support.ts";
|
|
12
12
|
import { pushNotice } from "../notices.ts";
|
|
13
|
-
import type { SettingsInfo } from "../protocol.ts";
|
|
13
|
+
import type { SettingsInfo, SettingsLinter } from "../protocol.ts";
|
|
14
14
|
import type { ApiContext } from "./index.ts";
|
|
15
15
|
import { fail } from "./index.ts";
|
|
16
16
|
|
|
@@ -93,6 +93,11 @@ export function settingsGet(ctx: ApiContext): SettingsInfo {
|
|
|
93
93
|
return { config: stripSecrets(config), models: ctx.service.models?.() ?? [] };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** The linters the project configures (its top and a few folders below), and whether each is installed. */
|
|
97
|
+
export function settingsLinters(ctx: ApiContext): { linters: SettingsLinter[] } {
|
|
98
|
+
return { linters: ctx.service.linters?.() ?? [] };
|
|
99
|
+
}
|
|
100
|
+
|
|
96
101
|
/** Merge, normalise and save the patch; the lobby rereads the config. */
|
|
97
102
|
export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
|
|
98
103
|
checkPort(body.patch);
|
package/src/webui/command.ts
CHANGED
|
@@ -119,6 +119,16 @@ function resetCommand(ctx: ExtensionCommandContext): void {
|
|
|
119
119
|
ctx.ui.notify(`bot-lobby web: the link changed: ${link}`, "info");
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** Close the page's server when bot-lobby is turned off; quiet, and nothing when it is not running. */
|
|
123
|
+
export async function stopWebServer(ctx: ExtensionContext): Promise<void> {
|
|
124
|
+
const running = currentWebServer();
|
|
125
|
+
if (!running) return;
|
|
126
|
+
await running.close();
|
|
127
|
+
latestLink = undefined;
|
|
128
|
+
heartbeat();
|
|
129
|
+
showAddress(ctx, undefined);
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
/** Run one `web` subcommand; a failure is a notice, never a throw. */
|
|
123
133
|
export async function webCommand(ctx: ExtensionCommandContext, word: string | undefined, deps?: WebCommandDeps): Promise<void> {
|
|
124
134
|
const action = webAction(word);
|
|
@@ -137,9 +147,10 @@ export async function webCommand(ctx: ExtensionCommandContext, word: string | un
|
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
|
-
* On `session_start
|
|
141
|
-
* (the first time) with the browser
|
|
142
|
-
*
|
|
150
|
+
* On `session_start` (and when bot-lobby is turned on): follow the fresh
|
|
151
|
+
* lobby service, and start the server (the first time) with the browser
|
|
152
|
+
* opening on it. The page runs while bot-lobby is on in an interactive
|
|
153
|
+
* session; off, a subagent or a one-shot run serves nothing.
|
|
143
154
|
*/
|
|
144
155
|
export function webSessionStarted(ctx: ExtensionContext, deps?: WebCommandDeps): void {
|
|
145
156
|
if (isSubagentProcess() || !servesPage(ctx)) return;
|
|
@@ -447,6 +447,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
|
|
|
447
447
|
sessionName: () => fixture.status.sessionName,
|
|
448
448
|
masterBusy: () => fixture.status.busy,
|
|
449
449
|
config: () => config,
|
|
450
|
+
linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
|
|
450
451
|
saveConfig: (next: BotLobbyConfig) => {
|
|
451
452
|
config = next;
|
|
452
453
|
},
|
|
@@ -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,7 +59,7 @@ 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). */
|
package/src/webui/protocol.ts
CHANGED
|
@@ -159,6 +159,14 @@ export interface SettingsModelInfo {
|
|
|
159
159
|
thinkingLevels: string[];
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** A linter the project configures (`settings.linters`): which, in which folder, and whether it is installed to run. */
|
|
163
|
+
export interface SettingsLinter {
|
|
164
|
+
tool: string;
|
|
165
|
+
/** Repository-relative; "" is the top. */
|
|
166
|
+
folder: string;
|
|
167
|
+
installed: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
162
170
|
/** `settings.get`: the effective config (never a secret) and the models Pi offers. */
|
|
163
171
|
export interface SettingsInfo {
|
|
164
172
|
config: BotLobbyConfig;
|
|
@@ -555,7 +563,7 @@ export interface Api {
|
|
|
555
563
|
"quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
|
|
556
564
|
"quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
|
|
557
565
|
"metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string }; result: MetricsData };
|
|
558
|
-
"knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[] } };
|
|
566
|
+
"knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };
|
|
559
567
|
"knowledge.open": { request: { agent: KnowledgeAgentName; file: string }; result: { view: KnowledgeViewData } };
|
|
560
568
|
"knowledge.edit": { request: { agent: KnowledgeAgentName; file: string; ref: EntryRefInfo; text: string }; result: { notice: string } };
|
|
561
569
|
"knowledge.add": { request: { agent: KnowledgeAgentName; file: string; ref?: EntryRefInfo; text: string }; result: { notice: string } };
|
|
@@ -583,6 +591,7 @@ export interface Api {
|
|
|
583
591
|
"issues.create": { request: { text: string }; result: { notice: string } };
|
|
584
592
|
"settings.get": { request: Record<string, never>; result: SettingsInfo };
|
|
585
593
|
"settings.set": { request: { patch: Record<string, unknown> }; result: { config: BotLobbyConfig } };
|
|
594
|
+
"settings.linters": { request: Record<string, never>; result: { linters: SettingsLinter[] } };
|
|
586
595
|
}
|
|
587
596
|
|
|
588
597
|
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);
|
package/src/workflow/workflow.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, realpathSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, realpathSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, relative } from "node:path";
|
|
3
3
|
import type { BotLobbyConfig, ProfileResolver } from "../schemas/configuration.ts";
|
|
4
4
|
import type { AgentRun, Pushback, ResearchResult, ReviewResult, WorkerResult } from "../schemas/findings.ts";
|
|
@@ -67,6 +67,9 @@ import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart,
|
|
|
67
67
|
import { missingWorktree, taskCwd } from "../execution/workspace.ts";
|
|
68
68
|
import { completionReview } from "../delivery/review.ts";
|
|
69
69
|
import { execCommand, type Exec } from "../lobby/issues.ts";
|
|
70
|
+
import type { LintExec } from "../execution/lint.ts";
|
|
71
|
+
import type { LintReport } from "../schemas/lint.ts";
|
|
72
|
+
import { lintBrief, lintContext, lintHolds, lintNote, lintRefusal, lintSummary, runLintGate } from "./lint.ts";
|
|
70
73
|
|
|
71
74
|
export const ORCHESTRATE_ACTIONS = [
|
|
72
75
|
"clarify",
|
|
@@ -159,6 +162,10 @@ export interface WorkflowDeps {
|
|
|
159
162
|
triage?: (request: string, signal?: AbortSignal) => Promise<Task["triage"]>;
|
|
160
163
|
/** Lowers thinking or the model for scouts and workers on steps the classifier judges simple or trivial. */
|
|
161
164
|
effort?: EffortRouter;
|
|
165
|
+
/** Runs a linter for the lint gate (tests); the real one otherwise. */
|
|
166
|
+
lintExec?: LintExec;
|
|
167
|
+
/** Told each new lint report on a task's touched files (the lobby's activity log). */
|
|
168
|
+
onLint?: (taskId: string, report: LintReport) => void;
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
export interface WorkflowResult {
|
|
@@ -830,12 +837,43 @@ async function treeChanges(deps: WorkflowDeps, base?: string): Promise<{ top: st
|
|
|
830
837
|
return { top: tree.top, files: tree.files.filter((file) => !file.startsWith(prefix)) };
|
|
831
838
|
}
|
|
832
839
|
|
|
833
|
-
/** Every changed file of the working tree, explained for this task; undefined without a baseline or git. */
|
|
834
|
-
async function
|
|
840
|
+
/** Every changed file of the working tree, explained for this task, with the repository's top; undefined without a baseline or git. */
|
|
841
|
+
async function changeState(deps: WorkflowDeps, task: Task, base?: string): Promise<{ top: string; files: FileProvenance[] } | undefined> {
|
|
835
842
|
if (!task.baseline) return undefined;
|
|
836
843
|
const tree = await treeChanges(deps, base);
|
|
837
844
|
if (!tree) return undefined;
|
|
838
|
-
return explainChanges(task, tree.files, tree.top, readChanges(deps.root, deps.configDir, task.createdAt));
|
|
845
|
+
return { top: tree.top, files: explainChanges(task, tree.files, tree.top, readChanges(deps.root, deps.configDir, task.createdAt)) };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Who touched each file, as the oracle names them (`DEV`, `DESIGN/DEV`). */
|
|
849
|
+
function ownersOf(files: readonly FileProvenance[] | undefined): Map<string, string> {
|
|
850
|
+
return new Map((files ?? []).filter((file) => file.domains?.length).map((file) => [file.path, file.domains!.map((domain) => AGENT_LABELS[domain]).join("/")]));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The lint gate (see lint.ts): lint the files this task's agents touched, as
|
|
855
|
+
* they stand, record the report on the task and tell the lobby when it is
|
|
856
|
+
* new. `tree` is the change state measured from the task's baseline, when the
|
|
857
|
+
* caller already has it. Nothing runs with lint off or before any worker.
|
|
858
|
+
*/
|
|
859
|
+
async function lintTask(task: Task, deps: WorkflowDeps, tree?: { top: string; files: FileProvenance[] }): Promise<LintReport | undefined> {
|
|
860
|
+
if (deps.config.lint.mode === "off" || !task.baseline) return undefined;
|
|
861
|
+
const state = tree ?? (await changeState(deps, task, task.baseline.head));
|
|
862
|
+
if (!state) return undefined;
|
|
863
|
+
const files = state.files.filter((file) => file.kinds.includes("planned") && existsSync(join(state.top, file.path))).map((file) => file.path);
|
|
864
|
+
const before = task.lint;
|
|
865
|
+
const report = await runLintGate({
|
|
866
|
+
top: state.top,
|
|
867
|
+
files,
|
|
868
|
+
...(task.baseline.head ? { base: task.baseline.head } : {}),
|
|
869
|
+
config: deps.config.lint,
|
|
870
|
+
...(before ? { previous: before } : {}),
|
|
871
|
+
...(deps.lintExec ? { exec: deps.lintExec } : {}),
|
|
872
|
+
...(deps.signal ? { signal: deps.signal } : {}),
|
|
873
|
+
});
|
|
874
|
+
task.lint = report;
|
|
875
|
+
if (report !== before && report.state !== "skipped") deps.onLint?.(task.id, report);
|
|
876
|
+
return report;
|
|
839
877
|
}
|
|
840
878
|
|
|
841
879
|
/** Who changed the tree, for the Master: counts, and what the changes that are not this task's mean for it. */
|
|
@@ -925,8 +963,9 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
925
963
|
// A step that never reported (the call failed) does not stay "running" in the budget.
|
|
926
964
|
for (const time of times.values()) settleAllotment(task, deps, time.id, "stopped");
|
|
927
965
|
}
|
|
928
|
-
const
|
|
929
|
-
|
|
966
|
+
const tree = await changeState(deps, task, task.baseline?.head);
|
|
967
|
+
const lint = lintNote(await lintTask(task, deps, tree), deps.config.lint.mode, ownersOf(tree?.files));
|
|
968
|
+
return [report, provenanceNote(tree?.files), lint].filter(Boolean).join("\n\n");
|
|
930
969
|
}
|
|
931
970
|
|
|
932
971
|
/**
|
|
@@ -1102,7 +1141,7 @@ const QA_INSTRUCTION = [
|
|
|
1102
1141
|
].join(" ");
|
|
1103
1142
|
|
|
1104
1143
|
/** The QA gate looks at every domain's work, not just one worker's diff, knowing who changed each file. */
|
|
1105
|
-
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string, provenance?: readonly FileProvenance[]): ReviewerRequest {
|
|
1144
|
+
function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: string, provenance?: readonly FileProvenance[], lint?: LintReport): ReviewerRequest {
|
|
1106
1145
|
const rounds = previousRound(task);
|
|
1107
1146
|
return {
|
|
1108
1147
|
taskId: task.id,
|
|
@@ -1113,6 +1152,7 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
1113
1152
|
scoutOutcomes: loadScoutResults(taskReadDirs(deps.root, deps.configDir, task.id), [...new Set<Domain>(["qa", ...task.domains])]),
|
|
1114
1153
|
diff,
|
|
1115
1154
|
...(provenance && provenance.length > 0 ? { provenance: provenanceLines(provenance) } : {}),
|
|
1155
|
+
...(lint ? { lint: lintContext(lint, deps.config.lint.mode) } : {}),
|
|
1116
1156
|
instruction: instruction?.trim() || QA_INSTRUCTION,
|
|
1117
1157
|
cwd: deps.cwd,
|
|
1118
1158
|
dataRoots: readDataRoots(deps.root, deps.configDir),
|
|
@@ -1127,7 +1167,7 @@ function qaRequest(deps: WorkflowDeps, task: Task, diff: string, instruction?: s
|
|
|
1127
1167
|
/** What the loop does after a QA round: finish, fix and review again, stop, or (the user's call) accept the work as it is. */
|
|
1128
1168
|
type LoopDecision = "accept" | "iterate" | "blocked" | "waived";
|
|
1129
1169
|
|
|
1130
|
-
function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?: readonly FileProvenance[]): string {
|
|
1170
|
+
function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?: readonly FileProvenance[], lint = ""): string {
|
|
1131
1171
|
const { result, run, issues } = outcome;
|
|
1132
1172
|
const passed = result.verdict === "pass";
|
|
1133
1173
|
return [
|
|
@@ -1148,6 +1188,7 @@ function qaReport(outcome: ReviewerOutcome, decision: LoopDecision, provenance?:
|
|
|
1148
1188
|
result.pushback ? pushbackLine(result.pushback, "qa") : "",
|
|
1149
1189
|
issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",
|
|
1150
1190
|
provenanceNote(provenance),
|
|
1191
|
+
lint,
|
|
1151
1192
|
]
|
|
1152
1193
|
.filter((line) => line.length > 0)
|
|
1153
1194
|
.join("\n");
|
|
@@ -1160,11 +1201,14 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
1160
1201
|
task.reviewIterations = { qa: iterations };
|
|
1161
1202
|
const time = qaTime(task, deps);
|
|
1162
1203
|
const base = await reviewBase(task, deps);
|
|
1163
|
-
const [diff,
|
|
1204
|
+
const [diff, tree] = await Promise.all([
|
|
1164
1205
|
readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
|
|
1165
|
-
|
|
1206
|
+
changeState(deps, task, base),
|
|
1166
1207
|
]);
|
|
1167
|
-
const
|
|
1208
|
+
const provenance = tree?.files;
|
|
1209
|
+
// The gate's base is the task's own; a legacy task measured from an older commit lints from a fresh look.
|
|
1210
|
+
const lint = await lintTask(task, deps, base === task.baseline?.head ? tree : undefined);
|
|
1211
|
+
const outcome = await runReviewer({ ...qaRequest(deps, task, diff, params.task, provenance, lint), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
|
|
1168
1212
|
if (time) settleAllotment(task, deps, time.id, "finished");
|
|
1169
1213
|
task.qaVerdict = outcome.result.verdict;
|
|
1170
1214
|
recordReview(task, "qa", outcome.result);
|
|
@@ -1173,7 +1217,7 @@ async function handleQa(task: Task, params: OrchestrateParams, deps: WorkflowDep
|
|
|
1173
1217
|
let decision: LoopDecision = decideReviewLoop(outcome.result.verdict, iterations, reviewLimit(task, deps));
|
|
1174
1218
|
if (decision === "blocked") decision = await askAtReviewLimit(task, outcome.result, iterations, deps);
|
|
1175
1219
|
if (decision === "accept") task.blockers = task.blockers.filter((blocker) => blocker.domain !== "qa");
|
|
1176
|
-
return qaReport(outcome, decision, provenance);
|
|
1220
|
+
return qaReport(outcome, decision, provenance, lintBrief(lint, deps.config.lint.mode, Boolean(task.lintWaiver)));
|
|
1177
1221
|
}
|
|
1178
1222
|
|
|
1179
1223
|
/** Review rounds allowed: the configured limit plus any the user granted. */
|
|
@@ -1228,6 +1272,8 @@ async function askAtReviewLimit(task: Task, result: ReviewResult, iterations: nu
|
|
|
1228
1272
|
*/
|
|
1229
1273
|
export function waiveQa(task: Task, open: readonly string[], why: string): void {
|
|
1230
1274
|
task.qaWaiver = { at: new Date().toISOString(), open: open.map((ask) => truncate(ask, 300)) };
|
|
1275
|
+
// Accepting the work as it stands accepts its lint errors too.
|
|
1276
|
+
if (task.lint?.state === "failing") waiveLint(task, why);
|
|
1231
1277
|
const cleared = task.blockers.map((blocker) => blocker.reason);
|
|
1232
1278
|
task.blockers = [];
|
|
1233
1279
|
if (task.state === "blocked") transition(task, "implementing");
|
|
@@ -1235,6 +1281,13 @@ export function waiveQa(task: Task, open: readonly string[], why: string): void
|
|
|
1235
1281
|
recordDecision(task, `The user accepted the work without a QA pass (${why}).${open.length > 0 ? ` QA still asked for: ${open.map((ask) => truncate(ask, 160)).join("; ")}.` : ""}${cleared.length > 0 ? ` Cleared blockers: ${cleared.join("; ")}.` : ""}`);
|
|
1236
1282
|
}
|
|
1237
1283
|
|
|
1284
|
+
/** The user accepts the work with the lint errors it has: the lint gate no longer holds completion. */
|
|
1285
|
+
export function waiveLint(task: Task, why: string): void {
|
|
1286
|
+
if (task.lintWaiver) return;
|
|
1287
|
+
task.lintWaiver = { at: new Date().toISOString() };
|
|
1288
|
+
recordDecision(task, `The user accepted the work with its lint errors (${why})${task.lint ? `: ${lintSummary(task.lint)}` : "."}`);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1238
1291
|
/** The last QA round's open asks, for a waiver made outside the loop. */
|
|
1239
1292
|
export function lastQaAsks(task: Task): string[] {
|
|
1240
1293
|
const last = task.reviewRecords.filter((record) => record.domain === "qa").at(-1);
|
|
@@ -1301,6 +1354,9 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1301
1354
|
if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
|
|
1302
1355
|
const blockers = completionBlockers(task, pendingApprovals(task).length);
|
|
1303
1356
|
if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
|
|
1357
|
+
// New lint errors in what the task touched hold it in block mode, unless the user accepted the work as it is.
|
|
1358
|
+
const lint = await lintTask(task, deps);
|
|
1359
|
+
if (lint && lintHolds(lint, deps.config.lint.mode, Boolean(task.lintWaiver))) throw new Error(`cannot complete: ${lintRefusal(lint)}`);
|
|
1304
1360
|
// The gate refuses before any side effect, so a refusal leaves the task untouched.
|
|
1305
1361
|
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
1306
1362
|
if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
|
|
@@ -1313,7 +1369,8 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1313
1369
|
task.delivery = await completionReview(task, deps.root, { cwd: deps.root, exec: deps.exec ?? execCommand });
|
|
1314
1370
|
deps.notify(`Task ${task.id} is completed. Delivery review is available; publishing requires your approval.`, "info");
|
|
1315
1371
|
flushDecisions(deps, task);
|
|
1316
|
-
|
|
1372
|
+
const left = lint?.state === "failing" ? ` ${task.lintWaiver ? "Accepted by the user with lint errors" : "Lint (advisory)"}: ${lintSummary(lint)} Tell the user.` : "";
|
|
1373
|
+
return `Task ${task.id} completed. History recorded and temporary scratchpads removed. Delivery review is available; nothing was published.${left}`;
|
|
1317
1374
|
}
|
|
1318
1375
|
|
|
1319
1376
|
/** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */
|