@a-t-h-i/bot-lobby 0.6.46 → 0.6.47

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.
@@ -60,3 +60,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60
60
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61
61
  SOFTWARE.
62
62
  ```
63
+
64
+ ## Typefaces
65
+
66
+ The web UI bundles the Latin subset of these variable fonts (from the
67
+ fontsource packages, `@fontsource-variable/*`) as files in `webui/dist/assets`,
68
+ so the Typeface setting works offline. Each is under the SIL Open Font
69
+ License 1.1 (https://openfontlicense.org): Inter (The Inter Project Authors),
70
+ Geist and Geist Mono (Vercel), IBM Plex Sans (IBM Corp.), Manrope (The
71
+ Manrope Project Authors) and JetBrains Mono (The JetBrains Mono Project
72
+ Authors).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.46",
3
+ "version": "0.6.47",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -57,6 +57,12 @@
57
57
  "@earendil-works/pi-ai": "1.0.1",
58
58
  "@earendil-works/pi-coding-agent": "1.0.1",
59
59
  "@earendil-works/pi-tui": "1.0.1",
60
+ "@fontsource-variable/geist": "^5.3.0",
61
+ "@fontsource-variable/geist-mono": "^5.3.0",
62
+ "@fontsource-variable/ibm-plex-sans": "^5.3.0",
63
+ "@fontsource-variable/inter": "^5.3.0",
64
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
65
+ "@fontsource-variable/manrope": "^5.3.0",
60
66
  "@playwright/test": "1.62.0",
61
67
  "@tailwindcss/vite": "4.3.3",
62
68
  "@types/node": "^22.10.0",
package/prompts/qa.md CHANGED
@@ -55,7 +55,7 @@ you state in the report.
55
55
  6. Run the smallest useful set of them.
56
56
  7. Decide what confidence is still missing.
57
57
  8. Only for a meaningful uncovered risk, add a behavioural test.
58
- 9. PASS or FAIL.
58
+ 9. Give the verdict: PASS, CHANGES_REQUIRED or BLOCKED.
59
59
 
60
60
  Never duplicate coverage that exists, and never rewrite existing tests because
61
61
  you prefer another style.
@@ -101,6 +101,12 @@ re-running it. Use the project's own type checker, build and existing tests
101
101
  before judging by reading, and run the narrowest command that answers the
102
102
  question (one test file, one test name, the touched package's typecheck).
103
103
  Run a whole suite only at HIGH risk, or when the change touches shared logic.
104
+ A failure is the task's only when the change causes it. A test that fails in
105
+ code the diff does not touch, for a reason the diff cannot explain, was
106
+ already failing: list it as an `info` finding and judge the change on the rest.
107
+ When you cannot tell, run that one test at the task's base commit in a
108
+ throwaway worktree (`git worktree add <tmp dir> <base>`; remove it after) —
109
+ never stash, reset or check out over the working tree.
104
110
  Do not re-run a check the worker already ran on the same files with the same
105
111
  result; cite it. Always pass a bash `timeout`, and never start watch mode or
106
112
  long-running servers.
@@ -147,9 +153,11 @@ A PASS is a claim backed by evidence, not an absence of complaints.
147
153
  A PASS without executed checks is downgraded by the engine to
148
154
  CHANGES_REQUIRED.
149
155
  - Check every acceptance criterion; an unmet or unverifiable one is a finding.
150
- - If you could not verify something important (no test runner, a failing
151
- environment, missing access), say so and return CHANGES_REQUIRED or BLOCKED —
152
- never PASS on assumption.
156
+ - If you could not verify something important, say so and never PASS on
157
+ assumption. Verify it another way first (the typecheck, a narrower test,
158
+ reading the code path); if it stays unverifiable, return CHANGES_REQUIRED
159
+ with what is missing. BLOCKED is only for a review that cannot happen at
160
+ all, and it stops the task until the user steps in.
153
161
 
154
162
  ## Testing conventions
155
163
 
@@ -108,7 +108,13 @@ If implementation changes are required, report them to the Master.
108
108
  behaviour, a failing check, an unmet acceptance criterion, a security
109
109
  problem. Tag every finding with its severity.
110
110
  - **BLOCKED** only when you cannot review at all (it does not build, the
111
- checks cannot run).
111
+ checks cannot run). It stops the task until the user steps in, so never use
112
+ it for something the workers can fix.
113
+ - **Pre-existing failures are not the task's.** A check that fails in code
114
+ the diff does not touch, for a reason the diff cannot explain, was already
115
+ failing: note it as an `info` finding and judge the change on the rest. When
116
+ unsure, run that check at the task's base commit in a throwaway worktree
117
+ (`git worktree add`), never by stashing or resetting the working tree.
112
118
  - **A re-review verifies; it does not start over.** When your context lists
113
119
  what the previous round asked for, check each item first and say which are
114
120
  addressed. Do not raise the bar between rounds: a new blocking finding must
@@ -12,7 +12,7 @@ import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.
12
12
  import { scriptsFor } from "../scripts/pick.ts";
13
13
  import { summarizeOutcomes } from "./synthesis.ts";
14
14
  import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
15
- import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
15
+ import { parseReviewResult, readVerdict, validateReviewResult } from "../roles/reviewer.ts";
16
16
  import { truncate } from "../text.ts";
17
17
  import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
18
18
  import { treeState } from "../execution/git.ts";
@@ -433,9 +433,9 @@ function reviewerContext(request: ReviewerRequest): string {
433
433
  .join("\n\n");
434
434
  }
435
435
 
436
- /** True when the output carries the reviewer contract's required heading. */
436
+ /** True when a verdict can be read from the output (the contract's heading, or a line naming it). */
437
437
  export function hasReviewVerdict(text: string): boolean {
438
- return /##\s*verdict/i.test(text);
438
+ return readVerdict(text) !== undefined;
439
439
  }
440
440
 
441
441
  /** A success without the Verdict section is off-contract and worth resampling. */
@@ -23,11 +23,34 @@ export const reviewerSpec: RoleSpec = {
23
23
 
24
24
  const SEVERITIES: readonly Severity[] = ["critical", "major", "minor", "info"];
25
25
 
26
- function parseVerdict(text: string | undefined): Verdict {
27
- const value = (text ?? "").toUpperCase();
28
- if (value.includes("CHANGES REQUIRED") || value.includes("CHANGES_REQUIRED")) return "changes_required";
29
- if (value.includes("PASS")) return "pass";
30
- return "blocked";
26
+ /** Severity words reviewers use outside the contract's four, mapped onto them. */
27
+ const SEVERITY_ALIASES: ReadonlyArray<[RegExp, Severity]> = [
28
+ [/\b(?:blocker|blocking|severe)\b/, "critical"],
29
+ [/\bhigh\b/, "major"],
30
+ [/\b(?:low|nit|nitpick|trivial|cosmetic|optional|suggestion)\b/, "minor"],
31
+ [/\b(?:note|fyi)\b/, "info"],
32
+ ];
33
+
34
+ /**
35
+ * Verdict words in the order a reviewer writes them. FAIL is the word qa.md
36
+ * used to ask for: it means changes are required, not that QA could not run.
37
+ */
38
+ const VERDICT_TOKENS: ReadonlyArray<[RegExp, Verdict]> = [
39
+ [/\bCHANGES?[\s_-]*(?:REQUIRED|REQUESTED|NEEDED)\b|\b(?:NEEDS|REQUEST(?:ED)?)[\s_-]+CHANGES\b/, "changes_required"],
40
+ [/\bFAIL(?:ED|S|URE)?\b/, "changes_required"],
41
+ [/\bBLOCKED\b/, "blocked"],
42
+ [/\bPASS(?:ED|ES)?\b/, "pass"],
43
+ ];
44
+
45
+ /** The first verdict word in the text, so `PASS — no changes required` reads as the PASS it leads with. */
46
+ function verdictToken(text: string): Verdict | undefined {
47
+ const value = text.toUpperCase();
48
+ let best: { at: number; verdict: Verdict } | undefined;
49
+ for (const [pattern, verdict] of VERDICT_TOKENS) {
50
+ const match = pattern.exec(value);
51
+ if (match && (!best || match.index < best.at)) best = { at: match.index, verdict };
52
+ }
53
+ return best?.verdict;
31
54
  }
32
55
 
33
56
  /** The verdict token may sit in the heading itself (`## Verdict PASS`), leaving an empty body. */
@@ -38,14 +61,35 @@ function verdictHeading(sections: Map<string, string>): string | undefined {
38
61
  return undefined;
39
62
  }
40
63
 
64
+ /**
65
+ * The reviewer's verdict, or undefined when none can be read. The contract's
66
+ * `## Verdict` section comes first; failing that, a line that names the
67
+ * verdict (`**Verdict:** PASS`, `# Verdict: PASS`) and the line after it.
68
+ */
69
+ export function readVerdict(raw: string, sections = parseSections(raw)): Verdict | undefined {
70
+ const heading = verdictHeading(sections);
71
+ if (heading !== undefined) {
72
+ const body = (findSection(sections, "verdict") ?? "").split("\n").filter((line) => line.trim()).slice(0, 2).join("\n");
73
+ const found = verdictToken(`${heading}\n${body}`);
74
+ if (found) return found;
75
+ }
76
+ const lines = raw.split("\n");
77
+ const at = lines.findIndex((line) => /\bverdict\b/i.test(line));
78
+ if (at < 0) return undefined;
79
+ const next = lines.slice(at + 1).find((line) => line.trim()) ?? "";
80
+ return verdictToken(`${lines[at]}\n${next}`);
81
+ }
82
+
41
83
  /** The severity a finding was tagged with, or undefined when it carries none. */
42
84
  function taggedSeverity(text: string): Severity | undefined {
43
- const match = /^\[([^\]]+)\]/.exec(text);
44
- return SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate));
85
+ const tag = /^\*{0,2}\[([^\]]+)\]/.exec(text)?.[1]?.toLowerCase();
86
+ if (!tag) return undefined;
87
+ return SEVERITIES.find((candidate) => tag.includes(candidate))
88
+ ?? SEVERITY_ALIASES.find(([pattern]) => pattern.test(tag))?.[1];
45
89
  }
46
90
 
47
91
  function parseFinding(text: string): ReviewFinding {
48
- const match = /^\[([^\]]+)\]\s*(.*)$/.exec(text);
92
+ const match = /^\*{0,2}\[([^\]]+)\]\*{0,2}\s*(.*)$/.exec(text);
49
93
  return { severity: taggedSeverity(text) ?? "info", text: (match?.[2] ?? text).trim() };
50
94
  }
51
95
 
@@ -55,9 +99,38 @@ export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks un
55
99
  /** Minor and info findings never hold the gate; what the reviewer asked for is kept as follow-ups. */
56
100
  export const MINOR_ONLY = "CHANGES_REQUIRED for minor or info findings only: passed, its asks kept as follow-ups";
57
101
 
58
- /** A verification bullet names a command or check and its result (`- cmd — result`). */
102
+ /** No verdict could be read: the review did not finish, so it is run again rather than stopping the task. */
103
+ export const NO_VERDICT = "no readable verdict: the review did not finish, so nothing is known yet about the work";
104
+
105
+ /** A check names a command and its result: `cmd — result`, `cmd: result`, `✅ cmd`, `cmd (40/40)` or `cmd passed`. */
106
+ const CHECK_RESULT = /\S\s*(?:—|–|-{1,2}|:|=>|→|->)\s*\S|[✅✓✔❌✗✘]|\(\s*\d+\s*\/\s*\d+\s*\)|\b(?:pass(?:ed|es|ing)?|fail(?:ed|s|ing)?|clean|exit(?:ed)?\s*(?:code\s*)?\d+|\d+\s+(?:tests?|passing|passed|failing|failed))\b/i;
107
+
108
+ /** A table row's cells, or undefined for a line that is not a row. */
109
+ function tableCells(line: string): string[] | undefined {
110
+ if (!/^\|.*\|$/.test(line)) return undefined;
111
+ return line.slice(1, -1).split("|").map((cell) => cell.trim());
112
+ }
113
+
114
+ /**
115
+ * The checks under `## Verification`: bullets, numbered items and table rows
116
+ * that name a command and its result. A reviewer's list style never decides
117
+ * whether the gate believes it ran something.
118
+ */
59
119
  function executedChecks(verification: string): string[] {
60
- return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
120
+ const lines = verification.split("\n").map((line) => line.trim());
121
+ const checks: string[] = [];
122
+ lines.forEach((line, index) => {
123
+ const cells = tableCells(line);
124
+ if (cells) {
125
+ const separator = cells.every((cell) => /^:?-{2,}:?$/.test(cell));
126
+ const header = tableCells(lines[index + 1] ?? "")?.every((cell) => /^:?-{2,}:?$/.test(cell));
127
+ if (!separator && !header && cells.filter(Boolean).length >= 2) checks.push(cells.join(" — "));
128
+ return;
129
+ }
130
+ const item = /^(?:[-*+]|\d+[.)])\s+(\S.*)$/.exec(line)?.[1];
131
+ if (item && CHECK_RESULT.test(item)) checks.push(item);
132
+ });
133
+ return checks;
61
134
  }
62
135
 
63
136
  /** Parse a reviewer's markdown into a structured verdict (never throws). */
@@ -66,11 +139,15 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
66
139
  const verification = findSection(sections, "verification") ?? "";
67
140
  const requiredChanges = bullets(findSection(sections, "required changes"));
68
141
  const entries = bullets(findSection(sections, "findings"));
69
- let verdict = parseVerdict(`${verdictHeading(sections) ?? ""}\n${findSection(sections, "verdict") ?? ""}`);
142
+ const read = readVerdict(raw, sections);
143
+ let verdict: Verdict = read ?? "changes_required";
70
144
  let downgraded: string | undefined;
71
145
  let relaxed: string | undefined;
72
146
  const checked = executedChecks(verification).length > 0;
73
- if (verdict === "pass" && !checked) {
147
+ if (!read) {
148
+ // Never a pass, and never a stop either: an unreadable review is run again.
149
+ requiredChanges.push("Re-run the QA gate: the last review returned no readable verdict.");
150
+ } else if (verdict === "pass" && !checked) {
74
151
  verdict = "changes_required";
75
152
  downgraded = UNVERIFIED_PASS;
76
153
  requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
@@ -93,6 +170,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
93
170
  pushback: parsePushback(sections),
94
171
  ...(downgraded ? { downgraded } : {}),
95
172
  ...(relaxed ? { relaxed } : {}),
173
+ ...(read ? {} : { unread: NO_VERDICT }),
96
174
  raw,
97
175
  };
98
176
  }
@@ -100,7 +178,7 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
100
178
  /** An unknown verdict must never be treated as acceptance (§42). */
101
179
  export function validateReviewResult(result: ReviewResult): string[] {
102
180
  const issues: string[] = [];
103
- if (!/##\s*verdict/i.test(result.raw)) issues.push("missing Verdict section");
181
+ if (result.unread) issues.push("missing Verdict section");
104
182
  if (result.downgraded) issues.push(result.downgraded);
105
183
  if (result.verdict !== "pass" && result.findings.length === 0 && result.requiredChanges.length === 0) {
106
184
  issues.push("non-pass verdict without findings or required changes");
@@ -89,6 +89,8 @@ export interface ReviewResult {
89
89
  downgraded?: string;
90
90
  /** Set when the engine passed a CHANGES_REQUIRED that named only minor or info findings. */
91
91
  relaxed?: string;
92
+ /** Set when no verdict could be read from the review (a failed or off-contract run). */
93
+ unread?: string;
92
94
  raw: string;
93
95
  }
94
96
 
@@ -1289,7 +1289,9 @@ function qaReport(header: string, result: ReviewResult, decision: LoopDecision,
1289
1289
  : decision === "waived"
1290
1290
  ? "The user accepted the work as it is, without a QA pass. Call action=complete now with a short summary that names what QA still asked for."
1291
1291
  : decision === "iterate"
1292
- ? "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa."
1292
+ ? result.unread
1293
+ ? "QA returned no readable verdict, so nothing is known about the work yet: re-run action=qa; there is nothing to fix first."
1294
+ : "The QA gate did not pass: delegate the required changes to the owning domain, then re-run action=qa."
1293
1295
  : "The review limit is reached and the user did not accept the work: mark the task blocked and tell the user what QA still asks for. They can accept it with /bot-lobby accept.",
1294
1296
  result.pushback ? pushbackLine(result.pushback, "qa") : "",
1295
1297
  issues.length > 0 ? `Issues: ${issues.join("; ")}` : "",