@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.
Files changed (48) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/prompts/designer.md +14 -0
  4. package/prompts/master.md +18 -0
  5. package/prompts/panel.md +21 -1
  6. package/prompts/planner.md +17 -1
  7. package/prompts/qa.md +23 -3
  8. package/prompts/quickfix.md +4 -0
  9. package/prompts/reviewer.md +17 -0
  10. package/prompts/worker.md +21 -6
  11. package/src/ask/tool.ts +3 -3
  12. package/src/delivery/review.ts +1 -1
  13. package/src/execution/git.ts +77 -0
  14. package/src/execution/lint.ts +435 -0
  15. package/src/lobby/ask.ts +4 -2
  16. package/src/lobby/feed.ts +39 -4
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/planner.ts +54 -5
  19. package/src/lobby/pr-review.ts +2 -1
  20. package/src/lobby/quickfix.ts +34 -1
  21. package/src/lobby/runtime.ts +19 -2
  22. package/src/lobby/service.ts +2 -0
  23. package/src/master/master.ts +3 -0
  24. package/src/pi/commands.ts +25 -10
  25. package/src/pi/events.ts +3 -1
  26. package/src/pi/switch.ts +69 -0
  27. package/src/pi/tools.ts +7 -0
  28. package/src/pi/ui.ts +2 -14
  29. package/src/schemas/configuration.ts +51 -1
  30. package/src/schemas/lint.ts +55 -0
  31. package/src/schemas/task.ts +5 -0
  32. package/src/state/changes.ts +8 -2
  33. package/src/webui/api/index.ts +2 -1
  34. package/src/webui/api/knowledge.ts +11 -3
  35. package/src/webui/api/settings.ts +6 -1
  36. package/src/webui/command.ts +14 -3
  37. package/src/webui/dev/fake-service.ts +1 -0
  38. package/src/webui/dev/fixtures.ts +2 -1
  39. package/src/webui/protocol.ts +10 -1
  40. package/src/workflow/lint.ts +221 -0
  41. package/src/workflow/track.ts +12 -4
  42. package/src/workflow/workflow.ts +70 -13
  43. package/webui/dist/assets/index-BRVbs5In.js +105 -0
  44. package/webui/dist/assets/index-BeRXVi_r.css +2 -0
  45. package/webui/dist/build.json +51 -35
  46. package/webui/dist/index.html +2 -2
  47. package/webui/dist/assets/index-BWwnNfkg.css +0 -2
  48. package/webui/dist/assets/index-Bh8la3EM.js +0 -105
@@ -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);
@@ -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`: follow the fresh lobby service, and start the server
141
- * (the first time) with the browser opening on it. The page is always on in
142
- * an interactive session; a subagent or a one-shot run serves nothing.
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). */
@@ -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
+ }
@@ -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, and the researcher only when it needs facts from outside the
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
- : backend && size !== "trivial"
225
- ? `tests: backend logic (${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);
@@ -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 changeProvenance(deps: WorkflowDeps, task: Task, base?: string): Promise<FileProvenance[] | undefined> {
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 note = provenanceNote(await changeProvenance(deps, task, task.baseline?.head));
929
- return note ? `${report}\n\n${note}` : report;
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, provenance] = await Promise.all([
1204
+ const [diff, tree] = await Promise.all([
1164
1205
  readRepositoryDiff(deps.cwd, { ...(base ? { base } : {}), exclude: ownRecords(deps) }),
1165
- changeProvenance(deps, task, base),
1206
+ changeState(deps, task, base),
1166
1207
  ]);
1167
- const outcome = await runReviewer({ ...qaRequest(deps, task, diff, params.task, provenance), ...(time ? { time } : {}) }, deps.runProcess ?? spawnPiProcess);
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
- return `Task ${task.id} completed. History recorded and temporary scratchpads removed. Delivery review is available; nothing was published.`;
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). */