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,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader for the `BM-REPORT` and `BM-REVIEW` blocks the roles emit
|
|
3
|
+
* (WP-204, Dashboard Design §6 "Bộ đọc BM-REPORT").
|
|
4
|
+
*
|
|
5
|
+
* The format is defined in `plugin/roles/worker.md` (Reporting) and
|
|
6
|
+
* `plugin/roles/reviewer.md`. It is produced by a language model, so this
|
|
7
|
+
* parser is deliberately forgiving: a missing field, an unknown field, a
|
|
8
|
+
* different key case, several blocks in one message, or a block quoted back
|
|
9
|
+
* inside a later message must never break a trace (REQ-050a). A field it
|
|
10
|
+
* cannot read becomes `null`, which the Dashboard renders as "unknown" rather
|
|
11
|
+
* than inventing a value.
|
|
12
|
+
*
|
|
13
|
+
* It is also a *consumed contract*: when the role instructions change the block
|
|
14
|
+
* shape, this parser must change in the same commit and must keep reading the
|
|
15
|
+
* previous shape (REQ-050b). The fixtures in
|
|
16
|
+
* `test/plugin-bm-report.test.ts` are the enforcement.
|
|
17
|
+
*/
|
|
18
|
+
import type { GuardrailReport, ParsedReport, ParsedReview, ReportPhase, Tier } from "./contracts";
|
|
19
|
+
|
|
20
|
+
/** Values that mean "nothing here" in a block (roles/worker.md says to write `none`). */
|
|
21
|
+
const ABSENT_VALUES = new Set(["", "none", "n/a", "na", "-", "null", "nil"]);
|
|
22
|
+
|
|
23
|
+
const REPORT_MARKER = /^\s*>?\s*(?:[-*]\s*)?bm-report\s*$/i;
|
|
24
|
+
const REVIEW_MARKER = /^\s*>?\s*(?:[-*]\s*)?bm-review\b(.*)$/i;
|
|
25
|
+
const FENCE = /^\s*>?\s*(?:```|~~~)/;
|
|
26
|
+
const KEY_VALUE = /^\s*>?\s*([A-Za-z][A-Za-z0-9 _-]*)\s*:\s*(.*)$/;
|
|
27
|
+
|
|
28
|
+
/** Bead ids as `br` mints them: `bm-wp-201-cp6.1`, `repo-37g`, … (AGENTS.md). */
|
|
29
|
+
export const BEAD_ID = /^[a-z][a-z0-9]*-[a-z0-9][a-z0-9.-]*$/i;
|
|
30
|
+
|
|
31
|
+
const PHASES: readonly ReportPhase[] = [
|
|
32
|
+
"received",
|
|
33
|
+
"documents-done",
|
|
34
|
+
"beads-done",
|
|
35
|
+
"bead-implemented",
|
|
36
|
+
"blocked",
|
|
37
|
+
"finished",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const TIERS: readonly Tier[] = ["Small", "Medium", "Large"];
|
|
41
|
+
|
|
42
|
+
/** Strips a leading quote marker so a report quoted back still parses. */
|
|
43
|
+
function unquote(line: string): string {
|
|
44
|
+
return line.replace(/^\s*>+\s?/, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeKey(key: string): string {
|
|
48
|
+
return key.replace(/[\s_-]/g, "").toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `none`, `-`, `n/a` and the empty string all mean "no value" (REQ-050a). */
|
|
52
|
+
export function valueOrNull(raw: string | undefined): string | null {
|
|
53
|
+
if (raw === undefined) return null;
|
|
54
|
+
const trimmed = raw.trim().replace(/^`+|`+$/g, "").trim();
|
|
55
|
+
if (ABSENT_VALUES.has(trimmed.toLowerCase())) return null;
|
|
56
|
+
return trimmed === "" ? null : trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Longest list value read. Group resolution is quadratic on crafted input
|
|
61
|
+
* (review bm-wp-220-nvk.1: 40 KB of nested parentheses took 1.6 s), and no real
|
|
62
|
+
* report comes close; a longer value is cut and its field marked incomplete.
|
|
63
|
+
*/
|
|
64
|
+
export const MAX_LIST_CHARS = 8000;
|
|
65
|
+
|
|
66
|
+
function cappedValue(raw: string | undefined): { value: string; cut: boolean } | null {
|
|
67
|
+
const value = valueOrNull(raw);
|
|
68
|
+
if (value === null) return null;
|
|
69
|
+
if (value.length <= MAX_LIST_CHARS) return { value, cut: false };
|
|
70
|
+
// Drop the piece the cut went through: `bm-wp-215-60a.435` cut to
|
|
71
|
+
// `bm-wp-215-60a.4` would name a different, possibly real, bead.
|
|
72
|
+
let end = MAX_LIST_CHARS;
|
|
73
|
+
while (end > 0 && !/[\s,;]/.test(value[end - 1]!)) end -= 1;
|
|
74
|
+
return { value: value.slice(0, end), cut: true };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A child shorthand such as `.2` or `.2.1`, written after a full id (delta 20260917 §5.1). */
|
|
78
|
+
const SHORTHAND_ID = /^\.\d+(?:\.\d+)*$/;
|
|
79
|
+
const CHILD_SUFFIX = /(?:\.\d+)+$/;
|
|
80
|
+
|
|
81
|
+
/** One list field read from a report. */
|
|
82
|
+
export interface BeadIdList {
|
|
83
|
+
ids: string[];
|
|
84
|
+
/**
|
|
85
|
+
* False when a piece was neither a bead id, an expandable shorthand, nor an
|
|
86
|
+
* absent value: the ids are then a lower bound of what the Worker meant.
|
|
87
|
+
*/
|
|
88
|
+
complete: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Splits on commas, semicolons and whitespace, and strips wrapping punctuation. */
|
|
92
|
+
function listPieces(value: string): string[] {
|
|
93
|
+
return value
|
|
94
|
+
.split(/[\s,;]+/)
|
|
95
|
+
.map((piece) => piece.replace(/^[`([]+|[`)\].,;:]+$/g, "").trim())
|
|
96
|
+
.filter((piece) => piece !== "");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolves `( … )` groups before splitting. A group holding only bead ids is
|
|
101
|
+
* unwrapped (`(bm-wp-202-4xi.1).` is an id); any other group is a comment and
|
|
102
|
+
* is dropped whole (`(epic)`, `(b2 fix: no-logging AC)` — the second one used
|
|
103
|
+
* to count `no-logging` as a bead). Innermost groups first.
|
|
104
|
+
*/
|
|
105
|
+
function resolveGroups(value: string): string {
|
|
106
|
+
let current = value;
|
|
107
|
+
for (;;) {
|
|
108
|
+
const next = current.replace(/\(([^()]*)\)/g, (_group, inner: string) => {
|
|
109
|
+
const pieces = listPieces(inner);
|
|
110
|
+
return pieces.length > 0 && pieces.every((piece) => BEAD_ID.test(piece)) ? ` ${inner} ` : " ";
|
|
111
|
+
});
|
|
112
|
+
if (next === current) return next;
|
|
113
|
+
current = next;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reads a bead-id list field. `.N` shorthand is expanded against the root of
|
|
119
|
+
* the nearest full id before it (`x-gcj, x-gcj.1, .2` → `x-gcj.2`); a
|
|
120
|
+
* shorthand with no full id before it is dropped and makes the list incomplete.
|
|
121
|
+
*/
|
|
122
|
+
export function parseBeadIdList(raw: string | undefined): BeadIdList {
|
|
123
|
+
const read = cappedValue(raw);
|
|
124
|
+
if (read === null) return { ids: [], complete: true };
|
|
125
|
+
const value = read.value;
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const ids: string[] = [];
|
|
128
|
+
let root: string | null = null;
|
|
129
|
+
let complete = !read.cut;
|
|
130
|
+
for (const piece of listPieces(resolveGroups(value))) {
|
|
131
|
+
let id: string | null = null;
|
|
132
|
+
if (SHORTHAND_ID.test(piece)) {
|
|
133
|
+
id = root === null ? null : `${root}${piece}`;
|
|
134
|
+
} else if (BEAD_ID.test(piece)) {
|
|
135
|
+
id = piece;
|
|
136
|
+
root = piece.replace(CHILD_SUFFIX, "");
|
|
137
|
+
} else if (ABSENT_VALUES.has(piece.toLowerCase())) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (id === null) {
|
|
141
|
+
complete = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const key = id.toLowerCase();
|
|
145
|
+
if (seen.has(key)) continue;
|
|
146
|
+
seen.add(key);
|
|
147
|
+
ids.push(id);
|
|
148
|
+
}
|
|
149
|
+
return { ids, complete };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Splits a list field on commas or whitespace and keeps only plausible bead ids. */
|
|
153
|
+
export function parseBeadIds(raw: string | undefined): string[] {
|
|
154
|
+
return parseBeadIdList(raw).ids;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A skill directory name such as `feature-workflow` (roles/worker.md, `skillsUsed`). */
|
|
158
|
+
const SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/i;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reads `skillsUsed` (delta 20260917 §4.9): the skills the Worker loaded for
|
|
162
|
+
* the request so far, comma-separated. Comment groups are handled like
|
|
163
|
+
* bead-id lists. Items are split on commas and semicolons only, so prose
|
|
164
|
+
* (`reviewing plan`, `some notes here`) is one item that is not a skill name:
|
|
165
|
+
* it is dropped and the field is marked incomplete, rather than read as
|
|
166
|
+
* several skills.
|
|
167
|
+
*/
|
|
168
|
+
export function parseSkillList(raw: string | undefined): { names: string[]; complete: boolean } {
|
|
169
|
+
const read = cappedValue(raw);
|
|
170
|
+
if (read === null) return { names: [], complete: true };
|
|
171
|
+
const value = read.value;
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
const names: string[] = [];
|
|
174
|
+
let complete = !read.cut;
|
|
175
|
+
for (const part of resolveGroups(value).split(/[,;]+/)) {
|
|
176
|
+
const item = part.replace(/^[\s`]+|[\s`.]+$/g, "");
|
|
177
|
+
if (item === "" || ABSENT_VALUES.has(item.toLowerCase())) continue;
|
|
178
|
+
if (!SKILL_NAME.test(item)) {
|
|
179
|
+
complete = false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const key = item.toLowerCase();
|
|
183
|
+
if (seen.has(key)) continue;
|
|
184
|
+
seen.add(key);
|
|
185
|
+
names.push(item);
|
|
186
|
+
}
|
|
187
|
+
return { names, complete };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Splits a plain list field (file paths) without the bead-id filter. */
|
|
191
|
+
function parseList(raw: string | undefined): string[] {
|
|
192
|
+
const value = valueOrNull(raw);
|
|
193
|
+
if (value === null) return [];
|
|
194
|
+
return value
|
|
195
|
+
.split(/[\s,;]+/)
|
|
196
|
+
.map((piece) => piece.replace(/^[`([]+|[`)\].,;]+$/g, "").trim())
|
|
197
|
+
.filter((piece) => piece !== "");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function firstNumber(source: string, pattern: RegExp): number | null {
|
|
201
|
+
const match = pattern.exec(source);
|
|
202
|
+
if (match?.[1] === undefined) return null;
|
|
203
|
+
const parsed = Number.parseInt(match[1], 10);
|
|
204
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Reads the `guardrail:` line, which is prose with numbers in it:
|
|
209
|
+
* `batch <id> reviews <n>/<max>; polish <n>/<max>; total <n>/<budget>; userAllowedExtra <n>`.
|
|
210
|
+
*
|
|
211
|
+
* Every number is optional. `raw` keeps the original text so the Dashboard can
|
|
212
|
+
* show exactly what the Worker claimed next to what was observed (REQ-042c).
|
|
213
|
+
*/
|
|
214
|
+
export function parseGuardrail(raw: string | undefined): GuardrailReport | null {
|
|
215
|
+
const value = valueOrNull(raw);
|
|
216
|
+
if (value === null) return null;
|
|
217
|
+
const batchId = /batch\s+([A-Za-z0-9._:-]+)/i.exec(value)?.[1] ?? null;
|
|
218
|
+
const reviews = /reviews\s+(\d+)\s*\/\s*(\d+)/i.exec(value);
|
|
219
|
+
const polish = /polish\s+(\d+)\s*\/\s*(\d+)/i.exec(value);
|
|
220
|
+
const total = /total\s+(\d+)\s*\/\s*(\d+)/i.exec(value);
|
|
221
|
+
return {
|
|
222
|
+
batchId,
|
|
223
|
+
batchReviews: reviews?.[1] === undefined ? null : Number.parseInt(reviews[1], 10),
|
|
224
|
+
batchMax: reviews?.[2] === undefined ? null : Number.parseInt(reviews[2], 10),
|
|
225
|
+
polish: polish?.[1] === undefined ? null : Number.parseInt(polish[1], 10),
|
|
226
|
+
polishMax: polish?.[2] === undefined ? null : Number.parseInt(polish[2], 10),
|
|
227
|
+
total: total?.[1] === undefined ? null : Number.parseInt(total[1], 10),
|
|
228
|
+
budget: total?.[2] === undefined ? null : Number.parseInt(total[2], 10),
|
|
229
|
+
userAllowedExtra: firstNumber(value, /userAllowedExtra\s+(\d+)/i),
|
|
230
|
+
raw: value,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parsePhase(raw: string | undefined): ReportPhase | null {
|
|
235
|
+
const value = valueOrNull(raw);
|
|
236
|
+
if (value === null) return null;
|
|
237
|
+
const normalized = value.toLowerCase().replace(/\s+/g, "-");
|
|
238
|
+
return PHASES.find((phase) => normalized.startsWith(phase)) ?? null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseTier(raw: string | undefined): Tier | null {
|
|
242
|
+
const value = valueOrNull(raw);
|
|
243
|
+
if (value === null) return null;
|
|
244
|
+
const head = value.toLowerCase();
|
|
245
|
+
return TIERS.find((tier) => head.startsWith(tier.toLowerCase())) ?? null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Known report keys, normalised. Anything else is kept in `unparsedFields`. */
|
|
249
|
+
const REPORT_KEYS = new Map<string, keyof ParsedReport | "phase" | "tier" | "guardrail">([
|
|
250
|
+
["requestid", "requestId"],
|
|
251
|
+
["phase", "phase"],
|
|
252
|
+
["tier", "tier"],
|
|
253
|
+
["fileschanged", "filesChanged"],
|
|
254
|
+
["beadscreated", "beadsCreated"],
|
|
255
|
+
["beadsupdated", "beadsUpdated"],
|
|
256
|
+
["beadsclosed", "beadsClosed"],
|
|
257
|
+
["beadsready", "beadsReady"],
|
|
258
|
+
["reviewfindingsopen", "reviewFindingsOpen"],
|
|
259
|
+
["buildandtests", "buildAndTests"],
|
|
260
|
+
["skillsused", "skillsUsed"],
|
|
261
|
+
["blockers", "blockers"],
|
|
262
|
+
["guardrail", "guardrail"],
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
interface RawBlock {
|
|
266
|
+
fields: Map<string, string>;
|
|
267
|
+
unknown: string[];
|
|
268
|
+
/** Text after `BM-REVIEW` on the marker line, e.g. `BM-REVIEW STOPPED`. */
|
|
269
|
+
markerSuffix: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Splits a message into blocks. A block starts at a marker line and ends at the
|
|
274
|
+
* next marker, a closing fence, or a line that is clearly prose (no `key:`).
|
|
275
|
+
*/
|
|
276
|
+
function extractBlocks(text: string, marker: RegExp): RawBlock[] {
|
|
277
|
+
const lines = text.split(/\r?\n/);
|
|
278
|
+
const blocks: RawBlock[] = [];
|
|
279
|
+
let current: RawBlock | null = null;
|
|
280
|
+
|
|
281
|
+
for (const rawLine of lines) {
|
|
282
|
+
const line = unquote(rawLine);
|
|
283
|
+
const markerMatch = marker.exec(rawLine);
|
|
284
|
+
if (markerMatch !== null) {
|
|
285
|
+
if (current !== null) blocks.push(current);
|
|
286
|
+
current = { fields: new Map(), unknown: [], markerSuffix: (markerMatch[1] ?? "").trim() };
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (current === null) continue;
|
|
290
|
+
if (FENCE.test(rawLine)) {
|
|
291
|
+
// A fence right after the marker opens the block; a later one closes it.
|
|
292
|
+
if (current.fields.size === 0 && current.unknown.length === 0) continue;
|
|
293
|
+
blocks.push(current);
|
|
294
|
+
current = null;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (line.trim() === "") continue;
|
|
298
|
+
const kv = KEY_VALUE.exec(rawLine);
|
|
299
|
+
if (kv === null) {
|
|
300
|
+
// Prose ends the block: reports are a contiguous run of key: value lines.
|
|
301
|
+
blocks.push(current);
|
|
302
|
+
current = null;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const key = normalizeKey(kv[1] ?? "");
|
|
306
|
+
const value = kv[2] ?? "";
|
|
307
|
+
if (REPORT_KEYS.has(key) || key === "batchid" || key === "verdict" || key === "blocking") {
|
|
308
|
+
if (!current.fields.has(key)) current.fields.set(key, value);
|
|
309
|
+
} else {
|
|
310
|
+
current.unknown.push((kv[1] ?? "").trim());
|
|
311
|
+
if (!current.fields.has(key)) current.fields.set(key, value);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (current !== null) blocks.push(current);
|
|
315
|
+
return blocks;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface ParseContext {
|
|
319
|
+
agentId: string;
|
|
320
|
+
/** Timestamp of the message the block came in, used as the block's `at`. */
|
|
321
|
+
at: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Reads every `BM-REPORT` block in one message.
|
|
326
|
+
*
|
|
327
|
+
* Duplicates are dropped on `(agentId, phase, requestId, at)`: a Worker that
|
|
328
|
+
* quotes its own earlier report back must not be counted twice (design §6).
|
|
329
|
+
*/
|
|
330
|
+
export function parseReports(text: string, context: ParseContext): ParsedReport[] {
|
|
331
|
+
if (typeof text !== "string" || text === "") return [];
|
|
332
|
+
const seen = new Set<string>();
|
|
333
|
+
const out: ParsedReport[] = [];
|
|
334
|
+
|
|
335
|
+
for (const block of extractBlocks(text, REPORT_MARKER)) {
|
|
336
|
+
if (block.fields.size === 0) continue;
|
|
337
|
+
const field = (key: string): string | undefined => block.fields.get(key);
|
|
338
|
+
const lists = {
|
|
339
|
+
beadsCreated: parseBeadIdList(field("beadscreated")),
|
|
340
|
+
beadsUpdated: parseBeadIdList(field("beadsupdated")),
|
|
341
|
+
beadsClosed: parseBeadIdList(field("beadsclosed")),
|
|
342
|
+
beadsReady: parseBeadIdList(field("beadsready")),
|
|
343
|
+
};
|
|
344
|
+
const skills = parseSkillList(field("skillsused"));
|
|
345
|
+
const report: ParsedReport = {
|
|
346
|
+
agentId: context.agentId,
|
|
347
|
+
at: context.at,
|
|
348
|
+
requestId: valueOrNull(field("requestid")),
|
|
349
|
+
phase: parsePhase(field("phase")),
|
|
350
|
+
tier: parseTier(field("tier")),
|
|
351
|
+
filesChanged: parseList(field("fileschanged")),
|
|
352
|
+
beadsCreated: lists.beadsCreated.ids,
|
|
353
|
+
beadsUpdated: lists.beadsUpdated.ids,
|
|
354
|
+
beadsClosed: lists.beadsClosed.ids,
|
|
355
|
+
beadsReady: lists.beadsReady.ids,
|
|
356
|
+
reviewFindingsOpen: valueOrNull(field("reviewfindingsopen")),
|
|
357
|
+
buildAndTests: valueOrNull(field("buildandtests")),
|
|
358
|
+
skillsUsed: skills.names,
|
|
359
|
+
blockers: valueOrNull(field("blockers")),
|
|
360
|
+
guardrail: parseGuardrail(field("guardrail")),
|
|
361
|
+
unparsedFields: [...new Set(block.unknown)],
|
|
362
|
+
incompleteFields: [
|
|
363
|
+
...Object.entries(lists)
|
|
364
|
+
.filter(([, list]) => !list.complete)
|
|
365
|
+
.map(([name]) => name),
|
|
366
|
+
...(skills.complete ? [] : ["skillsUsed"]),
|
|
367
|
+
],
|
|
368
|
+
};
|
|
369
|
+
const key = `${report.agentId}::${report.phase ?? ""}::${report.requestId ?? ""}::${report.at}`;
|
|
370
|
+
if (seen.has(key)) continue;
|
|
371
|
+
seen.add(key);
|
|
372
|
+
out.push(report);
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Reads every `BM-REVIEW` block in one message.
|
|
379
|
+
*
|
|
380
|
+
* `BM-REVIEW STOPPED` (the stop answer from bm-wq6) carries its verdict on the
|
|
381
|
+
* marker line rather than in a field, so the suffix is read too.
|
|
382
|
+
*/
|
|
383
|
+
export function parseReviews(text: string, context: ParseContext): ParsedReview[] {
|
|
384
|
+
if (typeof text !== "string" || text === "") return [];
|
|
385
|
+
const out: ParsedReview[] = [];
|
|
386
|
+
for (const block of extractBlocks(text, REVIEW_MARKER)) {
|
|
387
|
+
const suffix = valueOrNull(block.markerSuffix);
|
|
388
|
+
const verdict = valueOrNull(block.fields.get("verdict")) ?? suffix;
|
|
389
|
+
if (verdict === null && block.fields.size === 0) continue;
|
|
390
|
+
// reviewer.md lists findings as "- severity: blocking | non-blocking" lines,
|
|
391
|
+
// which end the key/value block, so they are counted in the message text.
|
|
392
|
+
// A `blocking: <n>` field, if a Reviewer writes one, still wins. Before this
|
|
393
|
+
// the count was always null for real reviews.
|
|
394
|
+
const blockingRaw = valueOrNull(block.fields.get("blocking"));
|
|
395
|
+
const blockingCount =
|
|
396
|
+
blockingRaw !== null
|
|
397
|
+
? firstNumber(blockingRaw, /(\d+)/)
|
|
398
|
+
: block.fields.has("verdict")
|
|
399
|
+
? (text.match(/severity\s*:\s*blocking\b/gi) ?? []).length
|
|
400
|
+
: null;
|
|
401
|
+
out.push({
|
|
402
|
+
agentId: context.agentId,
|
|
403
|
+
at: context.at,
|
|
404
|
+
batchId: valueOrNull(block.fields.get("batchid")),
|
|
405
|
+
verdict,
|
|
406
|
+
blockingCount,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* `requestId` as it appears in prose, tolerating the markdown the agents
|
|
414
|
+
* actually write.
|
|
415
|
+
*
|
|
416
|
+
* Relaxed after the WP-214 acceptance run: the strict form only matched the
|
|
417
|
+
* `BM-REPORT` block. Real Manager and Worker messages write it as
|
|
418
|
+
* "- `requestId`: `req-20260916T064147Z`", and the backticks made the old
|
|
419
|
+
* pattern miss it, which cost the trace its `exact` linking.
|
|
420
|
+
*/
|
|
421
|
+
export const REQUEST_ID_PATTERN = /requestId[`*\s]*:[`*\s]*(req-[A-Za-z0-9TZ:_-]+)/i;
|
|
422
|
+
|
|
423
|
+
/** First `requestId` in a piece of text, or null. */
|
|
424
|
+
export function requestIdFromText(text: string): string | null {
|
|
425
|
+
if (typeof text !== "string") return null;
|
|
426
|
+
const found = REQUEST_ID_PATTERN.exec(text)?.[1] ?? null;
|
|
427
|
+
return found === null ? null : found.replace(/[`*]+$/, "");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** True when a message is a role report rather than a user request (design §6 step 2). */
|
|
431
|
+
export function looksLikeReport(text: string): boolean {
|
|
432
|
+
return typeof text === "string" && text.split(/\r?\n/).some((line) => REPORT_MARKER.test(line));
|
|
433
|
+
}
|