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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. 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
+ }