paseo-bm-plugin 0.0.0-placeholder.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict template check of the `BM-*` blocks agents send each other
|
|
3
|
+
* (delta 20260918g §4.6, REQ-061 e, owner decisions Q2 a and Q7 a).
|
|
4
|
+
*
|
|
5
|
+
* The readers in `bm-report.ts` and `bm-questions.ts` are tolerant on purpose:
|
|
6
|
+
* they recover what they can from a sloppy block, because a card that shows
|
|
7
|
+
* something beats one that shows nothing. This module is the other half: it
|
|
8
|
+
* says exactly where a block departs from the templates in `plugin/roles/*.md`
|
|
9
|
+
* (BM-REPORT and BM-QUESTIONS in worker.md, BM-ANSWERS in manager.md,
|
|
10
|
+
* BM-REVIEW in reviewer.md), so the sender can be told and the user can see it.
|
|
11
|
+
* Every rule below comes from those templates and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* Issue messages are English: they are sent back to agents.
|
|
14
|
+
*
|
|
15
|
+
* Pure and environment-neutral: no Node API, no React; the client (card chip)
|
|
16
|
+
* and the server (BM-FORMAT notice) import it alike.
|
|
17
|
+
*/
|
|
18
|
+
import { BEAD_ID } from "./bm-report";
|
|
19
|
+
|
|
20
|
+
export type BlockKind = "BM-REPORT" | "BM-QUESTIONS" | "BM-ANSWERS" | "BM-REVIEW";
|
|
21
|
+
|
|
22
|
+
export interface FormatIssue {
|
|
23
|
+
kind: BlockKind;
|
|
24
|
+
/** The field or question the issue is about; null for the block as a whole. */
|
|
25
|
+
field: string | null;
|
|
26
|
+
message: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CheckedBlock {
|
|
30
|
+
kind: BlockKind;
|
|
31
|
+
requestId: string | null;
|
|
32
|
+
/** The block's own lines, as sent. */
|
|
33
|
+
text: string;
|
|
34
|
+
issues: FormatIssue[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Longest message checked; the rest is reported as not checked. */
|
|
38
|
+
export const MAX_CHECKED_CHARS = 20_000;
|
|
39
|
+
|
|
40
|
+
const MARKER = /^(?:[-*]\s+)?(?:\*\*)?(BM-REPORT|BM-QUESTIONS|BM-ANSWERS|BM-REVIEW)(?:\*\*)?(\s+STOPPED)?\s*$/;
|
|
41
|
+
const FENCE = /^\s*(?:```|~~~)/;
|
|
42
|
+
const QUOTE = /^\s*>\s?/;
|
|
43
|
+
const FIELD = /^([A-Za-z][A-Za-z0-9]*):(?:\s(.*))?$/;
|
|
44
|
+
const REQUEST_ID = /^req-\d{8}T\d{6}Z$/;
|
|
45
|
+
const PLACEHOLDER = /^<[^>]*>$/;
|
|
46
|
+
const DASH = "(?:—|–|-)";
|
|
47
|
+
|
|
48
|
+
export const REPORT_FIELDS = [
|
|
49
|
+
"requestId",
|
|
50
|
+
"phase",
|
|
51
|
+
"tier",
|
|
52
|
+
"filesChanged",
|
|
53
|
+
"beadsCreated",
|
|
54
|
+
"beadsUpdated",
|
|
55
|
+
"beadsClosed",
|
|
56
|
+
"beadsReady",
|
|
57
|
+
"reviewFindingsOpen",
|
|
58
|
+
"buildAndTests",
|
|
59
|
+
"skillsUsed",
|
|
60
|
+
"blockers",
|
|
61
|
+
] as const;
|
|
62
|
+
const PHASES = ["received", "beads-done", "blocked", "finished"];
|
|
63
|
+
const TIER = /^(Small|Medium|Large) \(changed: (no|from (Small|Medium|Large), .+)\)$/;
|
|
64
|
+
const BEAD_FIELDS = new Set(["beadsCreated", "beadsUpdated", "beadsClosed", "beadsReady"]);
|
|
65
|
+
const SKILL = /^[a-z0-9][a-z0-9-]*$/i;
|
|
66
|
+
const FINDING_OPEN = /^b\d+: \S.*$/;
|
|
67
|
+
const BLOCKERS_QUESTIONS = new RegExp(`^(\\d+) questions?: (Q\\d+(?:, Q\\d+)*) ${DASH} see BM-QUESTIONS\\b`);
|
|
68
|
+
|
|
69
|
+
const REVIEW_FIELDS = ["requestId", "batchId", "reviewKind", "verdict", "checked", "findings", "notChecked"] as const;
|
|
70
|
+
const FINDING_FIELDS = ["severity", "location", "reason", "suggestedFix"] as const;
|
|
71
|
+
|
|
72
|
+
const QUESTION = /^Q(\d+): \S.*$/;
|
|
73
|
+
const OPTION = /^- ([a-z]): \S.*$/;
|
|
74
|
+
const RECOMMENDED = /\(recommended\)\s*$/;
|
|
75
|
+
const ANSWER = new RegExp(`^Q(\\d+): (?:[a-z]|other) ${DASH} \\S.*$`);
|
|
76
|
+
|
|
77
|
+
const isNone = (value: string) => value.trim().toLowerCase() === "none";
|
|
78
|
+
|
|
79
|
+
/** The block marker of a line (quote stripped), or null. */
|
|
80
|
+
function markerOf(line: string): { kind: BlockKind; bare: boolean; stopped: boolean } | null {
|
|
81
|
+
const match = MARKER.exec(line.trim());
|
|
82
|
+
if (match === null) return null;
|
|
83
|
+
const kind = match[1] as BlockKind;
|
|
84
|
+
const stopped = match[2] !== undefined;
|
|
85
|
+
const bare = line.trim() === (stopped ? "BM-REVIEW STOPPED" : kind);
|
|
86
|
+
return { kind, bare, stopped };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Does `line` belong to a block of this kind (used to tell a stray blank line from the block's end)? */
|
|
90
|
+
function continues(kind: BlockKind, line: string): boolean {
|
|
91
|
+
switch (kind) {
|
|
92
|
+
case "BM-REPORT":
|
|
93
|
+
return FIELD.test(line) && (REPORT_FIELDS as readonly string[]).includes(FIELD.exec(line)![1]!);
|
|
94
|
+
case "BM-QUESTIONS":
|
|
95
|
+
return /^Q\d+:/.test(line) || /^- [a-z]:/.test(line);
|
|
96
|
+
case "BM-ANSWERS":
|
|
97
|
+
return /^Q\d+:/.test(line);
|
|
98
|
+
case "BM-REVIEW":
|
|
99
|
+
return /^\s+\S/.test(line) || /^- severity:/.test(line) || (FIELD.test(line) && (REVIEW_FIELDS as readonly string[]).includes(FIELD.exec(line)![1]!));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface RawBlock {
|
|
104
|
+
kind: BlockKind;
|
|
105
|
+
stopped: boolean;
|
|
106
|
+
bare: boolean;
|
|
107
|
+
lines: string[];
|
|
108
|
+
blankInside: boolean;
|
|
109
|
+
template: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Fields whose template value lists the allowed choices (`received | beads-done | …`). */
|
|
113
|
+
const CHOICE_FIELDS = new Set(["phase", "tier", "reviewKind", "verdict", "severity"]);
|
|
114
|
+
|
|
115
|
+
/** A line that shows the format rather than fills it: a `<placeholder>`, or a choice field listing `a | b`. */
|
|
116
|
+
function isTemplateLine(line: string): boolean {
|
|
117
|
+
const field = FIELD.exec(line.replace(/^\s*-?\s*/, ""));
|
|
118
|
+
if (field === null) return false;
|
|
119
|
+
const value = (field[2] ?? "").trim();
|
|
120
|
+
return PLACEHOLDER.test(value) || (CHOICE_FIELDS.has(field[1]!) && /\S\s\|\s\S/.test(value));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Splits a message into its BM-* blocks. */
|
|
124
|
+
function blocksOf(message: string): RawBlock[] {
|
|
125
|
+
const lines = message.split(/\r?\n/).map((line) => line.replace(QUOTE, ""));
|
|
126
|
+
const blocks: RawBlock[] = [];
|
|
127
|
+
let index = 0;
|
|
128
|
+
while (index < lines.length) {
|
|
129
|
+
const marker = markerOf(lines[index]!);
|
|
130
|
+
if (marker === null) {
|
|
131
|
+
index += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const block: RawBlock = { kind: marker.kind, stopped: marker.stopped, bare: marker.bare, lines: [], blankInside: false, template: false };
|
|
135
|
+
index += 1;
|
|
136
|
+
if (!marker.stopped) {
|
|
137
|
+
while (index < lines.length) {
|
|
138
|
+
const line = lines[index]!;
|
|
139
|
+
if (FENCE.test(line) || markerOf(line) !== null) break;
|
|
140
|
+
if (line.trim() === "") {
|
|
141
|
+
let next = index + 1;
|
|
142
|
+
while (next < lines.length && lines[next]!.trim() === "") next += 1;
|
|
143
|
+
if (next < lines.length && !FENCE.test(lines[next]!) && markerOf(lines[next]!) === null && continues(block.kind, lines[next]!)) {
|
|
144
|
+
if (block.kind !== "BM-REVIEW") block.blankInside = true;
|
|
145
|
+
index = next;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (isTemplateLine(line)) block.template = true;
|
|
151
|
+
block.lines.push(line);
|
|
152
|
+
index += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!block.template) blocks.push(block);
|
|
156
|
+
}
|
|
157
|
+
return blocks;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `key: value` lines of a block, in order; other lines as `null` keys. */
|
|
161
|
+
function fieldsOf(lines: readonly string[]): Array<{ key: string | null; value: string; line: string }> {
|
|
162
|
+
return lines.map((line) => {
|
|
163
|
+
const match = FIELD.exec(line);
|
|
164
|
+
return match === null ? { key: null, value: "", line } : { key: match[1]!, value: (match[2] ?? "").trim(), line };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function shorten(line: string): string {
|
|
169
|
+
const flat = line.trim();
|
|
170
|
+
return flat.length > 60 ? `${flat.slice(0, 57)}…` : flat;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkReport(block: RawBlock, questions: RawBlock | undefined, issue: (field: string | null, message: string) => void): string | null {
|
|
174
|
+
const fields = fieldsOf(block.lines);
|
|
175
|
+
const seen = new Map<string, string>();
|
|
176
|
+
const order: string[] = [];
|
|
177
|
+
for (const { key, value, line } of fields) {
|
|
178
|
+
if (key === null) {
|
|
179
|
+
issue(null, `line "${shorten(line)}" is not a field of the template`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!(REPORT_FIELDS as readonly string[]).includes(key)) {
|
|
183
|
+
issue(key, "is not a field of the template");
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (seen.has(key)) {
|
|
187
|
+
issue(key, "appears twice");
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
seen.set(key, value);
|
|
191
|
+
order.push(key);
|
|
192
|
+
}
|
|
193
|
+
for (const key of REPORT_FIELDS) if (!seen.has(key)) issue(key, "is missing");
|
|
194
|
+
const expected = REPORT_FIELDS.filter((key) => seen.has(key));
|
|
195
|
+
const outOfPlace = order.findIndex((key, position) => key !== expected[position]);
|
|
196
|
+
if (outOfPlace !== -1) {
|
|
197
|
+
issue(null, `fields must follow the template order (${REPORT_FIELDS.join(", ")}); "${order[outOfPlace]}" is out of place`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const requestId = seen.get("requestId");
|
|
201
|
+
if (requestId !== undefined && !REQUEST_ID.test(requestId)) issue("requestId", `must look like req-YYYYMMDDTHHMMSSZ (got "${shorten(requestId)}")`);
|
|
202
|
+
const phase = seen.get("phase");
|
|
203
|
+
if (phase !== undefined && !PHASES.includes(phase)) issue("phase", `must be one of ${PHASES.join(", ")} (got "${shorten(phase)}")`);
|
|
204
|
+
const tier = seen.get("tier");
|
|
205
|
+
if (tier !== undefined && !TIER.test(tier)) {
|
|
206
|
+
issue("tier", 'must be "Small|Medium|Large (changed: no)" or "… (changed: from <tier>, <reason>)"');
|
|
207
|
+
}
|
|
208
|
+
for (const key of BEAD_FIELDS) {
|
|
209
|
+
const value = seen.get(key);
|
|
210
|
+
if (value === undefined || isNone(value)) continue;
|
|
211
|
+
if (value.trim() === "") {
|
|
212
|
+
issue(key, "must be none or full bead ids separated by commas");
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const bad = value.split(",").map((part) => part.trim()).filter((part) => !BEAD_ID.test(part) || /^\.\d/.test(part));
|
|
216
|
+
if (bad.length > 0) issue(key, `must be none or full bead ids separated by commas ("${shorten(bad[0]!)}" is not one)`);
|
|
217
|
+
}
|
|
218
|
+
const skills = seen.get("skillsUsed");
|
|
219
|
+
if (skills !== undefined && !isNone(skills) && skills.split(",").some((part) => !SKILL.test(part.trim()))) {
|
|
220
|
+
issue("skillsUsed", "must be none or skill names separated by commas");
|
|
221
|
+
}
|
|
222
|
+
const findings = seen.get("reviewFindingsOpen");
|
|
223
|
+
if (findings !== undefined && !isNone(findings) && findings.split(";").some((part) => !FINDING_OPEN.test(part.trim()))) {
|
|
224
|
+
issue("reviewFindingsOpen", 'must be none or "b<n>: <finding>" items separated by ";"');
|
|
225
|
+
}
|
|
226
|
+
for (const key of ["filesChanged", "buildAndTests", "blockers"]) {
|
|
227
|
+
if (seen.get(key) === "") issue(key, "is empty; write none");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (phase === "blocked") {
|
|
231
|
+
if (questions === undefined) {
|
|
232
|
+
issue(null, "a blocked report must be followed by a BM-QUESTIONS block in the same message");
|
|
233
|
+
} else {
|
|
234
|
+
const ids = questionIds(questions);
|
|
235
|
+
const match = BLOCKERS_QUESTIONS.exec(seen.get("blockers") ?? "");
|
|
236
|
+
const listed = match === null ? null : match[2]!.split(", ");
|
|
237
|
+
if (match === null || Number(match[1]) !== listed!.length || listed!.join(",") !== ids.join(",")) {
|
|
238
|
+
issue("blockers", `must start "${ids.length} ${ids.length === 1 ? "question" : "questions"}: ${ids.join(", ")} — see BM-QUESTIONS"`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return requestId ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function questionIds(block: RawBlock): string[] {
|
|
246
|
+
return block.lines.map((line) => QUESTION.exec(line)).filter((match) => match !== null).map((match) => `Q${match![1]}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function checkQuestions(block: RawBlock, reportRequestId: string | null, issue: (field: string | null, message: string) => void): string | null {
|
|
250
|
+
const [first, ...rest] = block.lines;
|
|
251
|
+
const head = first === undefined ? null : FIELD.exec(first);
|
|
252
|
+
let requestId: string | null = null;
|
|
253
|
+
if (head === null || head[1] !== "requestId") {
|
|
254
|
+
issue("requestId", "must be the first line of the block");
|
|
255
|
+
} else {
|
|
256
|
+
requestId = (head[2] ?? "").trim();
|
|
257
|
+
if (!REQUEST_ID.test(requestId)) issue("requestId", `must look like req-YYYYMMDDTHHMMSSZ (got "${shorten(requestId)}")`);
|
|
258
|
+
else if (reportRequestId !== null && requestId !== reportRequestId) issue("requestId", `must equal the report's ${reportRequestId}`);
|
|
259
|
+
}
|
|
260
|
+
const body = head !== null && head[1] === "requestId" ? rest : block.lines;
|
|
261
|
+
|
|
262
|
+
const questions: Array<{ id: string; number: number; options: string[] }> = [];
|
|
263
|
+
for (const line of body) {
|
|
264
|
+
const question = QUESTION.exec(line);
|
|
265
|
+
if (question !== null) {
|
|
266
|
+
questions.push({ id: `Q${question[1]}`, number: Number(question[1]), options: [] });
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (OPTION.test(line) && questions.length > 0) {
|
|
270
|
+
questions.at(-1)!.options.push(line);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
issue(null, `line "${shorten(line)}" is neither "Q<n>: <question>" nor "- <letter>: <option>"`);
|
|
274
|
+
}
|
|
275
|
+
if (questions.length < 1 || questions.length > 5) issue(null, `needs 1 to 5 questions (found ${questions.length})`);
|
|
276
|
+
const seen = new Set<string>();
|
|
277
|
+
let previous = 0;
|
|
278
|
+
for (const question of questions) {
|
|
279
|
+
if (seen.has(question.id)) issue(question.id, "appears twice");
|
|
280
|
+
else if (question.number <= previous) issue(question.id, `must come after Q${previous}: numbers go up`);
|
|
281
|
+
seen.add(question.id);
|
|
282
|
+
previous = Math.max(previous, question.number);
|
|
283
|
+
if (question.options.length < 2) issue(question.id, `needs at least 2 options (found ${question.options.length})`);
|
|
284
|
+
const letters = question.options.map((option) => OPTION.exec(option)![1]!);
|
|
285
|
+
if (letters.some((letter, position) => letter !== String.fromCharCode(97 + position))) {
|
|
286
|
+
issue(question.id, `option letters must run a, b, c… (found ${letters.join(", ")})`);
|
|
287
|
+
}
|
|
288
|
+
const recommended = question.options.filter((option) => RECOMMENDED.test(option)).length;
|
|
289
|
+
if (recommended !== 1) issue(question.id, `needs exactly one option ending in (recommended) (found ${recommended})`);
|
|
290
|
+
}
|
|
291
|
+
return requestId;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function checkAnswers(block: RawBlock, issue: (field: string | null, message: string) => void): string | null {
|
|
295
|
+
const [first, ...rest] = block.lines;
|
|
296
|
+
const head = first === undefined ? null : FIELD.exec(first);
|
|
297
|
+
let requestId: string | null = null;
|
|
298
|
+
if (head === null || head[1] !== "requestId") issue("requestId", "must be the first line of the block");
|
|
299
|
+
else {
|
|
300
|
+
requestId = (head[2] ?? "").trim();
|
|
301
|
+
if (!REQUEST_ID.test(requestId)) issue("requestId", `must look like req-YYYYMMDDTHHMMSSZ (got "${shorten(requestId)}")`);
|
|
302
|
+
}
|
|
303
|
+
const body = head !== null && head[1] === "requestId" ? rest : block.lines;
|
|
304
|
+
const seen = new Set<string>();
|
|
305
|
+
for (const line of body) {
|
|
306
|
+
const answer = ANSWER.exec(line);
|
|
307
|
+
if (answer === null) {
|
|
308
|
+
issue(null, `line "${shorten(line)}" must be "Q<n>: <letter> — <option>" or "Q<n>: other — <words>"`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const id = `Q${answer[1]}`;
|
|
312
|
+
if (seen.has(id)) issue(id, "is answered twice");
|
|
313
|
+
seen.add(id);
|
|
314
|
+
}
|
|
315
|
+
if (seen.size === 0) issue(null, "answers no question");
|
|
316
|
+
return requestId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function checkReview(block: RawBlock, issue: (field: string | null, message: string) => void): string | null {
|
|
320
|
+
if (block.stopped) return null;
|
|
321
|
+
const seen = new Map<string, string>();
|
|
322
|
+
const findings: Array<Map<string, string>> = [];
|
|
323
|
+
let inFindings = false;
|
|
324
|
+
for (const line of block.lines) {
|
|
325
|
+
const item = /^- ([A-Za-z]+):(?:\s(.*))?$/.exec(line);
|
|
326
|
+
const nested = /^\s+([A-Za-z]+):(?:\s(.*))?$/.exec(line);
|
|
327
|
+
if (inFindings && item !== null) {
|
|
328
|
+
findings.push(new Map([[item[1]!, (item[2] ?? "").trim()]]));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (inFindings && nested !== null && findings.length > 0) {
|
|
332
|
+
findings.at(-1)!.set(nested[1]!, (nested[2] ?? "").trim());
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const field = FIELD.exec(line);
|
|
336
|
+
if (field === null) {
|
|
337
|
+
issue(null, `line "${shorten(line)}" is not a field of the template`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const key = field[1]!;
|
|
341
|
+
inFindings = key === "findings";
|
|
342
|
+
if (!(REVIEW_FIELDS as readonly string[]).includes(key)) issue(key, "is not a field of the template");
|
|
343
|
+
else if (seen.has(key)) issue(key, "appears twice");
|
|
344
|
+
else seen.set(key, (field[2] ?? "").trim());
|
|
345
|
+
}
|
|
346
|
+
for (const key of REVIEW_FIELDS) if (!seen.has(key)) issue(key, "is missing");
|
|
347
|
+
const requestId = seen.get("requestId");
|
|
348
|
+
if (requestId !== undefined && !REQUEST_ID.test(requestId)) issue("requestId", `must look like req-YYYYMMDDTHHMMSSZ (got "${shorten(requestId)}")`);
|
|
349
|
+
const batchId = seen.get("batchId");
|
|
350
|
+
if (batchId !== undefined && !/^b\d+$/.test(batchId)) issue("batchId", `must look like b<n> (got "${shorten(batchId)}")`);
|
|
351
|
+
const kind = seen.get("reviewKind");
|
|
352
|
+
if (kind !== undefined && kind !== "first" && kind !== "re-review") issue("reviewKind", "must be first or re-review");
|
|
353
|
+
const verdict = seen.get("verdict");
|
|
354
|
+
if (verdict !== undefined && verdict !== "pass" && verdict !== "changes-required") issue("verdict", "must be pass or changes-required");
|
|
355
|
+
|
|
356
|
+
const listed = seen.get("findings");
|
|
357
|
+
if (listed !== undefined) {
|
|
358
|
+
if (isNone(listed) && findings.length > 0) issue("findings", "says none but lists findings");
|
|
359
|
+
if (!isNone(listed) && listed !== "") issue("findings", 'must be "none" or be followed by "- severity: …" items');
|
|
360
|
+
if (listed === "" && findings.length === 0) issue("findings", 'lists no finding; write "findings: none"');
|
|
361
|
+
}
|
|
362
|
+
findings.forEach((finding, position) => {
|
|
363
|
+
const name = `finding ${position + 1}`;
|
|
364
|
+
for (const key of FINDING_FIELDS) if (!finding.has(key) || finding.get(key) === "") issue(name, `is missing "${key}"`);
|
|
365
|
+
for (const key of finding.keys()) if (!(FINDING_FIELDS as readonly string[]).includes(key)) issue(name, `has an unknown field "${key}"`);
|
|
366
|
+
const severity = finding.get("severity");
|
|
367
|
+
if (severity !== undefined && severity !== "blocking" && severity !== "non-blocking") issue(name, "severity must be blocking or non-blocking");
|
|
368
|
+
});
|
|
369
|
+
const blocking = findings.some((finding) => finding.get("severity") === "blocking");
|
|
370
|
+
if (verdict === "pass" && blocking) issue("verdict", "must be changes-required: a finding is blocking");
|
|
371
|
+
if (verdict === "changes-required" && !blocking) issue("verdict", "must be pass: no finding is blocking");
|
|
372
|
+
return requestId ?? null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Every BM-* block of a message, in order, each with its template issues.
|
|
377
|
+
* Blocks that only show the format (a `<placeholder>` or `a | b` choices) are
|
|
378
|
+
* left out. Never throws.
|
|
379
|
+
*/
|
|
380
|
+
export function checkBlocks(message: unknown): CheckedBlock[] {
|
|
381
|
+
try {
|
|
382
|
+
if (typeof message !== "string" || message === "") return [];
|
|
383
|
+
const tooLong = message.length > MAX_CHECKED_CHARS;
|
|
384
|
+
const raw = blocksOf(tooLong ? message.slice(0, MAX_CHECKED_CHARS) : message);
|
|
385
|
+
let reportRequestId: string | null = null;
|
|
386
|
+
const out = raw.map((block, position): CheckedBlock => {
|
|
387
|
+
const issues: FormatIssue[] = [];
|
|
388
|
+
const issue = (field: string | null, text: string) => issues.push({ kind: block.kind, field, message: text });
|
|
389
|
+
if (!block.bare) issue(null, `the block must start with the bare line ${block.stopped ? "BM-REVIEW STOPPED" : block.kind}`);
|
|
390
|
+
if (block.blankInside) issue(null, "has a blank line inside; the template has none");
|
|
391
|
+
let requestId: string | null = null;
|
|
392
|
+
switch (block.kind) {
|
|
393
|
+
case "BM-REPORT": {
|
|
394
|
+
const next = raw[position + 1];
|
|
395
|
+
requestId = checkReport(block, next?.kind === "BM-QUESTIONS" ? next : undefined, issue);
|
|
396
|
+
reportRequestId = requestId;
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "BM-QUESTIONS":
|
|
400
|
+
requestId = checkQuestions(block, raw[position - 1]?.kind === "BM-REPORT" ? reportRequestId : null, issue);
|
|
401
|
+
break;
|
|
402
|
+
case "BM-ANSWERS":
|
|
403
|
+
requestId = checkAnswers(block, issue);
|
|
404
|
+
break;
|
|
405
|
+
case "BM-REVIEW":
|
|
406
|
+
requestId = checkReview(block, issue);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
return { kind: block.kind, requestId, text: [block.stopped ? "BM-REVIEW STOPPED" : block.kind, ...block.lines].join("\n"), issues };
|
|
410
|
+
});
|
|
411
|
+
if (tooLong && out.length > 0) {
|
|
412
|
+
const last = out.at(-1)!;
|
|
413
|
+
last.issues.push({ kind: last.kind, field: null, message: `message too long to check in full (over ${MAX_CHECKED_CHARS} characters)` });
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
} catch {
|
|
417
|
+
return [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** `BM-REPORT phase: must be …` — one line per issue, as the card lists them. */
|
|
422
|
+
export function issueText(issue: FormatIssue): string {
|
|
423
|
+
return issue.field === null ? `${issue.kind}: ${issue.message}` : `${issue.kind} ${issue.field}: ${issue.message}`;
|
|
424
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for the `BM-QUESTIONS` block a Worker puts right after its `blocked`
|
|
3
|
+
* `BM-REPORT`, and writer of the `BM-ANSWERS` block that answers it
|
|
4
|
+
* (delta 20260918c-question-cards §4.1–§4.3).
|
|
5
|
+
*
|
|
6
|
+
* Like the report reader next door, it reads what a language model wrote, so
|
|
7
|
+
* it is forgiving: quoting, fences, bold ids, `(a)` instead of `a:`, wrapped
|
|
8
|
+
* lines. What it cannot read it leaves out rather than guesses — a card
|
|
9
|
+
* without buttons falls back to a free-text reply, while a guessed split could
|
|
10
|
+
* put an option under the wrong question.
|
|
11
|
+
*
|
|
12
|
+
* Kept apart from `bm-report.ts` so the consumed `BM-REPORT` contract and its
|
|
13
|
+
* fixtures do not change: a `BM-QUESTIONS` line has no `key:` shape, so it
|
|
14
|
+
* already ends a report block there.
|
|
15
|
+
*
|
|
16
|
+
* Pure and environment-neutral: the chat card runs it on every message.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface QuestionOption {
|
|
20
|
+
/** Lowercase letter: `a`, `b`, … */
|
|
21
|
+
key: string;
|
|
22
|
+
text: string;
|
|
23
|
+
recommended: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Question {
|
|
27
|
+
/** `Q<n>`; unique within one request, because Workers count on across rounds. */
|
|
28
|
+
id: string;
|
|
29
|
+
text: string;
|
|
30
|
+
options: QuestionOption[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface QuestionSet {
|
|
34
|
+
requestId: string | null;
|
|
35
|
+
questions: Question[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One answer: an option's key, or the user's own words. */
|
|
39
|
+
export type Pick = { key: string } | { other: string };
|
|
40
|
+
|
|
41
|
+
/** Characters read from the marker on. The marker itself is found in the whole message. */
|
|
42
|
+
export const MAX_BLOCK_CHARS = 20_000;
|
|
43
|
+
export const MAX_QUESTIONS = 10;
|
|
44
|
+
export const MAX_OPTIONS = 8;
|
|
45
|
+
export const MAX_TEXT_CHARS = 1000;
|
|
46
|
+
|
|
47
|
+
const QUESTIONS_MARKER = /^\s*(?:>\s*)*(?:[-*]\s+)?(?:\*\*)?bm-questions(?:\*\*)?\s*$/i;
|
|
48
|
+
/** Any paseo-bm block start: the end of this block. */
|
|
49
|
+
const ANY_MARKER = /^\s*(?:>\s*)*(?:[-*]\s+)?(?:\*\*)?bm-(?:report|review|questions|answers)\b/i;
|
|
50
|
+
const FENCE = /^\s*(?:>\s*)*(?:```|~~~)/;
|
|
51
|
+
const QUOTE = /^\s*(?:>\s?)+/;
|
|
52
|
+
const REQUEST_ID = /^\s*(?:[-*]\s+)?requestId\s*:\s*(.*)$/i;
|
|
53
|
+
const QUESTION = /^\s*(?:[-*]\s+)?(?:\*\*)?Q(\d{1,3})(?:\*\*)?\s*[:.)]\s*(?:\*\*)?\s*(.*)$/;
|
|
54
|
+
/** `- a: …`, `- a) …`, `- a. …`, `- (a) …` */
|
|
55
|
+
const BULLET_OPTION = /^\s*[-*]\s+(?:\(([a-z])\)|([a-z])\s*[:.)])\s*(.*)$/i;
|
|
56
|
+
/** `(a) …`, `a) …` — without a bullet the parenthesis is required, so prose `a: …` is not an option. */
|
|
57
|
+
const PAREN_OPTION = /^\s*(?:\(([a-z])\)|([a-z])\))\s+(.*)$/i;
|
|
58
|
+
const RECOMMENDED = /\s*[([]\s*recommended\s*[)\]]/i;
|
|
59
|
+
const RECOMMENDED_ALL = new RegExp(RECOMMENDED.source, "gi");
|
|
60
|
+
const INDENTED = /^\s{2,}\S/;
|
|
61
|
+
const ABSENT = new Set(["", "none", "n/a", "na", "-", "null", "nil"]);
|
|
62
|
+
|
|
63
|
+
function cut(text: string): string {
|
|
64
|
+
return text.length <= MAX_TEXT_CHARS ? text : `${text.slice(0, MAX_TEXT_CHARS - 1)}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Appends a wrapped line, stopping once the text is already at its cap. */
|
|
68
|
+
function appended(text: string, more: string): string {
|
|
69
|
+
if (text.length >= MAX_TEXT_CHARS) return text;
|
|
70
|
+
return text === "" ? more : `${text} ${more}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cleanRequestId(raw: string): string | null {
|
|
74
|
+
const value = raw.trim().replace(/^[`*]+|[`*]+$/g, "").trim();
|
|
75
|
+
return ABSENT.has(value.toLowerCase()) ? null : value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Draft {
|
|
79
|
+
id: string;
|
|
80
|
+
text: string;
|
|
81
|
+
options: Array<{ key: string; text: string }>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function finish(draft: Draft): Question {
|
|
85
|
+
const options = draft.options.map((option) => {
|
|
86
|
+
const recommended = RECOMMENDED.test(option.text);
|
|
87
|
+
return { key: option.key, text: cut(option.text.replace(RECOMMENDED_ALL, "").trim()), recommended };
|
|
88
|
+
});
|
|
89
|
+
// More than one recommendation is no recommendation: the card never picks.
|
|
90
|
+
const marked = options.filter((option) => option.recommended).length;
|
|
91
|
+
return {
|
|
92
|
+
id: draft.id,
|
|
93
|
+
text: cut(draft.text.replace(/\*\*/g, "").trim()),
|
|
94
|
+
options: marked > 1 ? options.map((option) => ({ ...option, recommended: false })) : options,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The last `BM-QUESTIONS` block of a message, or null when it has none.
|
|
100
|
+
*
|
|
101
|
+
* The block follows a report that can be long, so the marker is looked for
|
|
102
|
+
* across the whole message (one anchored test per line, linear); only the
|
|
103
|
+
* reading from the marker on is capped at `MAX_BLOCK_CHARS`.
|
|
104
|
+
*/
|
|
105
|
+
export function parseQuestions(text: string): QuestionSet | null {
|
|
106
|
+
if (typeof text !== "string" || text === "") return null;
|
|
107
|
+
const lines = text.split(/\r?\n/);
|
|
108
|
+
let start = -1;
|
|
109
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
110
|
+
if (QUESTIONS_MARKER.test(lines[index]!)) {
|
|
111
|
+
start = index;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (start === -1) return null;
|
|
116
|
+
|
|
117
|
+
let requestId: string | null = null;
|
|
118
|
+
const drafts: Draft[] = [];
|
|
119
|
+
const seenIds = new Set<string>();
|
|
120
|
+
/** Null before the first question and after a duplicate id, whose options are dropped with it. */
|
|
121
|
+
let current: Draft | null = null;
|
|
122
|
+
let budget = MAX_BLOCK_CHARS - lines[start]!.length - 1;
|
|
123
|
+
let started = false;
|
|
124
|
+
|
|
125
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
126
|
+
const raw = lines[index]!;
|
|
127
|
+
budget -= raw.length + 1;
|
|
128
|
+
if (budget < 0) break;
|
|
129
|
+
const line = raw.replace(QUOTE, "");
|
|
130
|
+
if (line.trim() === "") continue;
|
|
131
|
+
if (FENCE.test(raw)) {
|
|
132
|
+
// A fence right after the marker opens the block; a later one closes it.
|
|
133
|
+
if (!started) continue;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (ANY_MARKER.test(raw)) break;
|
|
137
|
+
|
|
138
|
+
const request = REQUEST_ID.exec(line);
|
|
139
|
+
if (request !== null) {
|
|
140
|
+
started = true;
|
|
141
|
+
if (requestId === null) requestId = cleanRequestId(request[1] ?? "");
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const question = QUESTION.exec(line);
|
|
146
|
+
if (question !== null) {
|
|
147
|
+
started = true;
|
|
148
|
+
const id = `Q${Number.parseInt(question[1]!, 10)}`;
|
|
149
|
+
if (seenIds.has(id)) {
|
|
150
|
+
current = null;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (drafts.length >= MAX_QUESTIONS) break;
|
|
154
|
+
seenIds.add(id);
|
|
155
|
+
current = { id, text: cut(question[2] ?? ""), options: [] };
|
|
156
|
+
drafts.push(current);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const option = BULLET_OPTION.exec(line) ?? PAREN_OPTION.exec(line);
|
|
161
|
+
if (option !== null) {
|
|
162
|
+
if (current === null) continue;
|
|
163
|
+
const key = (option[1] ?? option[2] ?? "").toLowerCase();
|
|
164
|
+
const body = (option[3] ?? "").replace(/^[:.]\s*/, "");
|
|
165
|
+
if (current.options.length < MAX_OPTIONS && !current.options.some((known) => known.key === key)) {
|
|
166
|
+
current.options.push({ key, text: cut(body) });
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (INDENTED.test(line)) {
|
|
172
|
+
const last = current?.options.at(-1);
|
|
173
|
+
if (last !== undefined) last.text = appended(last.text, line.trim());
|
|
174
|
+
else if (current !== null) current.text = appended(current.text, line.trim());
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Prose before the first question is an introduction; after it, the end.
|
|
179
|
+
if (drafts.length === 0) continue;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { requestId, questions: drafts.map(finish) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function oneLine(text: string): string {
|
|
187
|
+
return text.replace(/\s*\r?\n\s*/g, " ").trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The `BM-ANSWERS` block for `picks`, one line per question in question order.
|
|
192
|
+
*
|
|
193
|
+
* Every question must have an answer: a missing pick, an unknown key or a
|
|
194
|
+
* blank `other` throws, so a programming error can never send a partial
|
|
195
|
+
* answer the Worker would take for the user's whole reply.
|
|
196
|
+
*/
|
|
197
|
+
export function answersText(requestId: string, questions: readonly Question[], picks: Readonly<Record<string, Pick>>): string {
|
|
198
|
+
const lines = ["BM-ANSWERS", `requestId: ${requestId}`];
|
|
199
|
+
for (const question of questions) {
|
|
200
|
+
const pick = picks[question.id];
|
|
201
|
+
if (pick === undefined) throw new Error(`No answer for ${question.id}.`);
|
|
202
|
+
if ("key" in pick) {
|
|
203
|
+
const option = question.options.find((candidate) => candidate.key === pick.key);
|
|
204
|
+
if (option === undefined) throw new Error(`${question.id} has no option "${pick.key}".`);
|
|
205
|
+
lines.push(`${question.id}: ${option.key} — ${oneLine(option.text)}`);
|
|
206
|
+
} else {
|
|
207
|
+
const words = oneLine(pick.other);
|
|
208
|
+
if (words === "") throw new Error(`The answer to ${question.id} is empty.`);
|
|
209
|
+
lines.push(`${question.id}: other — ${words}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|