@worca/ui 0.40.0 → 0.42.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * File-access aggregator — reads pipeline.iteration.access events from a
3
+ * run's events.jsonl and folds payloads into the row/column model used by
4
+ * the Access Map view.
5
+ *
6
+ * Output shape:
7
+ * { enabled: false } — no access events (pre-W-064 run)
8
+ * { enabled: true, columns, tree, searches, summary }
9
+ *
10
+ * Columns: stage-ordered (STAGE_ORDER), then ascending iteration, then
11
+ * bead_id (nulls first, then lexicographic).
12
+ *
13
+ * Tree: union of reads∪writes paths, hierarchical dir/file nodes. Dir rows
14
+ * carry server-side rollups so the browser never recomputes aggregates.
15
+ *
16
+ * Searches: flat list of per-event search records with broad/zero_hit flags.
17
+ *
18
+ * GraphQueries: flat list of per-event knowledge-graph queries (graphify / CRG)
19
+ * with engine, op, target, and zero_hit flags. Empty unless the run used a
20
+ * graph engine.
21
+ *
22
+ * Summary: global aggregates. oracle:"degraded" if ANY event was degraded.
23
+ *
24
+ * Pattern: mirrors dispatch-events-aggregator.js.
25
+ */
26
+
27
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
28
+ import { join, resolve } from 'node:path';
29
+ import { STAGE_ORDER } from '../app/utils/stage-order.js';
30
+
31
+ const ACCESS_EVENT_TYPE = 'pipeline.iteration.access';
32
+
33
+ // Access fragment filename: `<stage>-<iter>.jsonl` or `<stage>-<iter>-<bead>.jsonl`.
34
+ // Stage keys never contain a hyphen (plan, coordinate, implement, plan_review…);
35
+ // iteration is digits; bead ids may contain hyphens, so they soak up the rest.
36
+ const FRAGMENT_NAME_RE = /^([a-z_]+)-(\d+)(?:-(.+))?\.jsonl$/;
37
+
38
+ /**
39
+ * Build the Access Map model from a run's events.jsonl.
40
+ *
41
+ * The completed iterations come from `pipeline.iteration.access` events (the
42
+ * runner's authoritative completion-time aggregation). When `runDir` is given,
43
+ * the still-running iteration is folded in LIVE by reading its on-disk access
44
+ * fragment directly — so the map, searches and graph-queries populate during
45
+ * the stage instead of only at completion. A live column is never double-counted
46
+ * once its completion event lands (the completion payload wins by colKey), and
47
+ * capture-integrity (leakage/oracle) stays pending for live columns since it's
48
+ * only computable from the finished iteration.
49
+ *
50
+ * @param {string} eventsPath — absolute path to events.jsonl
51
+ * @param {string|null} runDir — run directory (enables live fragment folding)
52
+ * @returns {{ enabled: false } | { enabled: true, columns, tree, searches, summary }}
53
+ */
54
+ export function buildFileAccessModel(eventsPath, runDir = null) {
55
+ // Parse completion-time access events (authoritative for finished iterations).
56
+ const accessPayloads = [];
57
+ if (eventsPath && existsSync(eventsPath)) {
58
+ let content = '';
59
+ try {
60
+ content = readFileSync(eventsPath, 'utf8');
61
+ } catch {
62
+ content = '';
63
+ }
64
+ for (const line of content.split('\n')) {
65
+ if (!line.trim()) continue;
66
+ let e;
67
+ try {
68
+ e = JSON.parse(line);
69
+ } catch {
70
+ continue;
71
+ }
72
+ if (e.event_type !== ACCESS_EVENT_TYPE) continue;
73
+ if (!e.payload) continue;
74
+ accessPayloads.push(e.payload);
75
+ }
76
+ }
77
+
78
+ // Fold the still-running iteration's fragment(s) in live — skipping any
79
+ // column that already has an authoritative completion event.
80
+ const completedCols = new Set(
81
+ accessPayloads.map((p) => colKey(p.stage, p.iteration, p.bead_id)),
82
+ );
83
+ const livePayloads = runDir
84
+ ? readLiveFragmentPayloads(runDir, completedCols)
85
+ : [];
86
+ accessPayloads.push(...livePayloads);
87
+
88
+ if (accessPayloads.length === 0) return { enabled: false };
89
+
90
+ // ------------------------------------------------------------------
91
+ // 1. Build columns (deduplicated, sorted)
92
+ // ------------------------------------------------------------------
93
+ const colMap = new Map();
94
+ for (const p of accessPayloads) {
95
+ const key = colKey(p.stage, p.iteration, p.bead_id);
96
+ if (!colMap.has(key)) {
97
+ colMap.set(key, {
98
+ key,
99
+ stage: p.stage,
100
+ iteration: p.iteration,
101
+ bead_id: p.bead_id ?? null,
102
+ agent: p.agent,
103
+ live: !!p._live,
104
+ });
105
+ }
106
+ }
107
+
108
+ const columns = [...colMap.values()].sort(compareColumns);
109
+
110
+ // ------------------------------------------------------------------
111
+ // 2. Fold payloads into per-file data and searches
112
+ // ------------------------------------------------------------------
113
+ // fileData: path → { cells: { colKey: { read?, write? } }, tracked }
114
+ const fileData = new Map();
115
+
116
+ const searches = [];
117
+ const graphQueries = [];
118
+ let oracleDegraded = false;
119
+
120
+ const summary = {
121
+ files_touched: 0,
122
+ distinct_read: 0,
123
+ total_read: 0,
124
+ distinct_write: 0,
125
+ total_write: 0,
126
+ searches: 0,
127
+ grep: 0,
128
+ glob: 0,
129
+ zero_result: 0,
130
+ root_scoped: 0,
131
+ graph_queries: 0,
132
+ graphify: 0,
133
+ crg: 0,
134
+ leakage_pct_max: 0,
135
+ oracle: 'ok',
136
+ };
137
+
138
+ for (const p of accessPayloads) {
139
+ const ck = colKey(p.stage, p.iteration, p.bead_id);
140
+ const fa = p.file_access || {};
141
+
142
+ for (const [path, count] of Object.entries(fa.reads || {})) {
143
+ const fd = ensureFile(fileData, path);
144
+ if (!fd.cells[ck]) fd.cells[ck] = {};
145
+ fd.cells[ck].read = (fd.cells[ck].read || 0) + count;
146
+ }
147
+
148
+ for (const [path, count] of Object.entries(fa.writes || {})) {
149
+ const fd = ensureFile(fileData, path);
150
+ if (!fd.cells[ck]) fd.cells[ck] = {};
151
+ fd.cells[ck].write = (fd.cells[ck].write || 0) + count;
152
+ }
153
+
154
+ for (const s of fa.searches || []) {
155
+ const isBroad = s.scope === '.' || s.scope === '';
156
+ searches.push({
157
+ colKey: ck,
158
+ stage: p.stage,
159
+ iteration: p.iteration,
160
+ tool: s.tool,
161
+ pattern: s.pattern,
162
+ scope: s.scope,
163
+ result_count: s.result_count,
164
+ broad: isBroad,
165
+ zero_hit: s.result_count === 0,
166
+ filter: s.filter ?? null,
167
+ });
168
+ }
169
+
170
+ const cap = fa.capture || {};
171
+ if (cap.oracle === 'degraded') oracleDegraded = true;
172
+ if (cap.leakage_pct != null && cap.leakage_pct > summary.leakage_pct_max) {
173
+ summary.leakage_pct_max = cap.leakage_pct;
174
+ }
175
+
176
+ summary.searches += (fa.searches || []).length;
177
+ for (const s of fa.searches || []) {
178
+ if (s.tool === 'Grep') summary.grep++;
179
+ if (s.tool === 'Glob') summary.glob++;
180
+ if (s.result_count === 0) summary.zero_result++;
181
+ if (s.scope === '.' || s.scope === '') summary.root_scoped++;
182
+ }
183
+
184
+ // Knowledge-graph queries (graphify / CRG) — structural/semantic lookups
185
+ // recorded alongside the lexical searches above. We only surface fields we
186
+ // can reliably capture from both engines: the engine, the op (graphify
187
+ // subcommand / CRG MCP tool name), and the verbatim query/args. Result
188
+ // counts and a separate "target" are op-dependent and not reliably
189
+ // available, so they are intentionally not collected.
190
+ for (const g of fa.graph_queries || []) {
191
+ graphQueries.push({
192
+ colKey: ck,
193
+ stage: p.stage,
194
+ iteration: p.iteration,
195
+ engine: g.engine,
196
+ op: g.op,
197
+ query: g.query,
198
+ });
199
+ summary.graph_queries++;
200
+ if (g.engine === 'graphify') summary.graphify++;
201
+ if (g.engine === 'crg') summary.crg++;
202
+ }
203
+ }
204
+
205
+ // Compute global file-level aggregates from the folded fileData.
206
+ for (const fd of fileData.values()) {
207
+ let fileRead = 0;
208
+ let fileWrite = 0;
209
+ for (const cell of Object.values(fd.cells)) {
210
+ fileRead += cell.read || 0;
211
+ fileWrite += cell.write || 0;
212
+ }
213
+ if (fileRead > 0) {
214
+ summary.distinct_read++;
215
+ summary.total_read += fileRead;
216
+ }
217
+ if (fileWrite > 0) {
218
+ summary.distinct_write++;
219
+ summary.total_write += fileWrite;
220
+ }
221
+ }
222
+
223
+ summary.files_touched = fileData.size;
224
+ if (oracleDegraded) summary.oracle = 'degraded';
225
+
226
+ // ------------------------------------------------------------------
227
+ // 3. Build hierarchical tree with server-side dir rollups
228
+ // ------------------------------------------------------------------
229
+ const tree = buildTree(fileData);
230
+
231
+ return { enabled: true, columns, tree, searches, graphQueries, summary };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Helpers
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function colKey(stage, iteration, beadId) {
239
+ return beadId ? `${stage}:${iteration}:${beadId}` : `${stage}:${iteration}`;
240
+ }
241
+
242
+ /**
243
+ * Read access fragments under runDir/access/ and synthesise per-iteration
244
+ * payloads (same shape as a pipeline.iteration.access event's payload) for the
245
+ * still-running iterations — i.e. any fragment whose column has no completion
246
+ * event yet. Mirrors the Python aggregation in file_access_aggregation.py,
247
+ * minus the GitPathOracle respelling (paths are repo-root-relativised here as a
248
+ * live approximation; the completion event respells authoritatively).
249
+ *
250
+ * @param {string} runDir
251
+ * @param {Set<string>} completedCols — colKeys that already have a completion event
252
+ * @returns {Array<object>} synthetic access payloads with `_live: true`
253
+ */
254
+ function readLiveFragmentPayloads(runDir, completedCols) {
255
+ const accessDir = join(runDir, 'access');
256
+ if (!existsSync(accessDir)) return [];
257
+ // runDir is `<repoRoot>/.worca/runs/<id>` → repoRoot is three levels up.
258
+ const repoRoot = resolve(runDir, '..', '..', '..');
259
+
260
+ let files;
261
+ try {
262
+ files = readdirSync(accessDir);
263
+ } catch {
264
+ return [];
265
+ }
266
+
267
+ const payloads = [];
268
+ for (const fname of files) {
269
+ const m = FRAGMENT_NAME_RE.exec(fname);
270
+ if (!m) continue;
271
+ const stage = m[1];
272
+ const iteration = Number(m[2]);
273
+ const bead_id = m[3] || null;
274
+ if (completedCols.has(colKey(stage, iteration, bead_id))) continue;
275
+
276
+ let records;
277
+ try {
278
+ records = readFileSync(join(accessDir, fname), 'utf8')
279
+ .split('\n')
280
+ .filter((l) => l.trim())
281
+ .map((l) => {
282
+ try {
283
+ return JSON.parse(l);
284
+ } catch {
285
+ return null;
286
+ }
287
+ })
288
+ .filter(Boolean);
289
+ } catch {
290
+ continue;
291
+ }
292
+ if (records.length === 0) continue;
293
+
294
+ payloads.push({
295
+ stage,
296
+ iteration,
297
+ bead_id,
298
+ agent: null,
299
+ _live: true,
300
+ file_access: fragmentRecordsToFileAccess(records, repoRoot),
301
+ });
302
+ }
303
+ return payloads;
304
+ }
305
+
306
+ /**
307
+ * Fold raw access-fragment records into the `file_access` shape the model
308
+ * builder consumes. Capture is left empty — leakage/oracle are only computable
309
+ * from the finished iteration, so they stay pending for a live column.
310
+ */
311
+ function fragmentRecordsToFileAccess(records, repoRoot) {
312
+ const reads = {};
313
+ const writes = {};
314
+ const searches = [];
315
+ const graph_queries = [];
316
+
317
+ for (const r of records) {
318
+ switch (r.op) {
319
+ case 'read': {
320
+ const p = canonicalizePath(r.path, repoRoot);
321
+ if (p) reads[p] = (reads[p] || 0) + 1;
322
+ break;
323
+ }
324
+ case 'write': {
325
+ const p = canonicalizePath(r.path, repoRoot);
326
+ if (p) writes[p] = (writes[p] || 0) + 1;
327
+ break;
328
+ }
329
+ case 'search': {
330
+ let scope = r.scope || '';
331
+ if (!scope || scope === '.') scope = '.';
332
+ const entry = {
333
+ tool: r.tool,
334
+ pattern: (r.pattern || '').slice(0, 200),
335
+ scope,
336
+ result_count: r.result_count ?? 0,
337
+ };
338
+ if ('filter' in r) entry.filter = r.filter;
339
+ searches.push(entry);
340
+ break;
341
+ }
342
+ case 'graph_query': {
343
+ if (r.engine === 'graphify' || r.engine === 'crg') {
344
+ graph_queries.push({
345
+ engine: r.engine,
346
+ op: r.graph_op || '',
347
+ query: (r.query || '').slice(0, 200),
348
+ });
349
+ }
350
+ break;
351
+ }
352
+ default:
353
+ break;
354
+ }
355
+ }
356
+
357
+ return { reads, writes, searches, graph_queries, capture: {} };
358
+ }
359
+
360
+ /**
361
+ * Relativise an absolute fragment path against the repo root (a live stand-in
362
+ * for the Python GitPathOracle respelling). Paths already relative, or outside
363
+ * the repo, are returned unchanged; the repo root itself maps to null.
364
+ */
365
+ function canonicalizePath(rawPath, repoRoot) {
366
+ if (!rawPath) return null;
367
+ if (repoRoot && rawPath.startsWith(`${repoRoot}/`)) {
368
+ return rawPath.slice(repoRoot.length + 1);
369
+ }
370
+ if (rawPath === repoRoot) return null;
371
+ return rawPath;
372
+ }
373
+
374
+ function ensureFile(fileData, path) {
375
+ if (!fileData.has(path)) {
376
+ fileData.set(path, { cells: {}, tracked: true });
377
+ }
378
+ return fileData.get(path);
379
+ }
380
+
381
+ function compareColumns(a, b) {
382
+ const ai = STAGE_ORDER.indexOf(a.stage);
383
+ const bi = STAGE_ORDER.indexOf(b.stage);
384
+ const stageA = ai === -1 ? 999 : ai;
385
+ const stageB = bi === -1 ? 999 : bi;
386
+ if (stageA !== stageB) return stageA - stageB;
387
+ if (a.iteration !== b.iteration) return a.iteration - b.iteration;
388
+ // nulls first
389
+ if (a.bead_id === null && b.bead_id !== null) return -1;
390
+ if (a.bead_id !== null && b.bead_id === null) return 1;
391
+ if (a.bead_id === b.bead_id) return 0;
392
+ return a.bead_id < b.bead_id ? -1 : 1;
393
+ }
394
+
395
+ /**
396
+ * Build a hierarchical dir/file tree from the flat fileData map.
397
+ * Dir nodes carry rolled-up totals and cells aggregated from children.
398
+ *
399
+ * @param {Map<string, {cells, tracked}>} fileData
400
+ * @returns {Array<TreeNode>}
401
+ */
402
+ function buildTree(fileData) {
403
+ // Root sentinel — not emitted, just holds top-level children.
404
+ const root = {
405
+ children: new Map(),
406
+ cells: {},
407
+ totals: { read: 0, write: 0 },
408
+ };
409
+
410
+ for (const [path, fd] of fileData) {
411
+ const parts = path.split('/');
412
+ let node = root;
413
+
414
+ // Walk/create intermediate dir nodes.
415
+ for (let i = 0; i < parts.length - 1; i++) {
416
+ const name = parts[i];
417
+ if (!node.children.has(name)) {
418
+ const dirPath = parts.slice(0, i + 1).join('/');
419
+ node.children.set(name, {
420
+ type: 'dir',
421
+ path: dirPath,
422
+ name,
423
+ children: new Map(),
424
+ cells: {},
425
+ totals: { read: 0, write: 0 },
426
+ });
427
+ }
428
+ node = node.children.get(name);
429
+ }
430
+
431
+ // Place the file leaf.
432
+ const fileName = parts[parts.length - 1];
433
+ const fileTotals = { read: 0, write: 0 };
434
+ for (const cell of Object.values(fd.cells)) {
435
+ fileTotals.read += cell.read || 0;
436
+ fileTotals.write += cell.write || 0;
437
+ }
438
+ const category =
439
+ fileTotals.write > 0 ? (fd.tracked ? 'write' : 'leaked') : 'read';
440
+
441
+ node.children.set(fileName, {
442
+ type: 'file',
443
+ path,
444
+ name: fileName,
445
+ tracked: fd.tracked,
446
+ category,
447
+ cells: fd.cells,
448
+ totals: fileTotals,
449
+ });
450
+ }
451
+
452
+ // Rollup dir totals and cells bottom-up, then serialise to arrays.
453
+ rollupDir(root);
454
+ return [...root.children.values()].map(serializeNode);
455
+ }
456
+
457
+ function rollupDir(node) {
458
+ if (node.type === 'file') return;
459
+ for (const child of node.children.values()) {
460
+ rollupDir(child);
461
+ node.totals.read += child.totals.read;
462
+ node.totals.write += child.totals.write;
463
+ for (const [ck, cell] of Object.entries(child.cells)) {
464
+ if (!node.cells[ck]) node.cells[ck] = {};
465
+ node.cells[ck].read = (node.cells[ck].read || 0) + (cell.read || 0);
466
+ node.cells[ck].write = (node.cells[ck].write || 0) + (cell.write || 0);
467
+ }
468
+ }
469
+ }
470
+
471
+ function serializeNode(node) {
472
+ if (node.type === 'file') return node;
473
+ return {
474
+ type: 'dir',
475
+ path: node.path,
476
+ name: node.name,
477
+ children: [...node.children.values()].map(serializeNode),
478
+ cells: node.cells,
479
+ totals: node.totals,
480
+ };
481
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Graph-query aggregator — reads pipeline.hook.graph_query events from a run's
3
+ * events.jsonl and turns them into live per-iteration query counts
4
+ * (graphify_invocations / crg_invocations / crg_tool_counts), assigned to the
5
+ * matching iteration in status.json by timestamp range.
6
+ *
7
+ * This is the live counterpart to the runner's completion-time tally: the
8
+ * runner writes the authoritative graphify_invocations / crg_invocations onto
9
+ * each iteration when the stage *completes*. For a still-running iteration that
10
+ * count is absent, so the graphify/CRG badges would otherwise stay blank until
11
+ * completion. This aggregator fills the gap by counting the live hook events —
12
+ * exactly like dispatch-events-aggregator does for skills/subagents — and only
13
+ * for iterations that don't already carry the runner's number (so the
14
+ * authoritative completion count is never clobbered).
15
+ */
16
+
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+
19
+ const GRAPH_QUERY_EVENT_TYPE = 'pipeline.hook.graph_query';
20
+
21
+ /**
22
+ * Classify a single parsed events.jsonl line. Returns the normalised graph-query
23
+ * entry if the line is a graph-query event, or null otherwise. Extracted so a
24
+ * combined single-pass reader (events-jsonl-reader.js) can share the exact same
25
+ * normalisation as the standalone reader below.
26
+ *
27
+ * @param {object} e — a parsed events.jsonl object
28
+ * @returns {{engine, op, timestamp}|null}
29
+ */
30
+ export function parseGraphQueryEventLine(e) {
31
+ if (!e || e.event_type !== GRAPH_QUERY_EVENT_TYPE) return null;
32
+ const payload = e.payload || {};
33
+ const engine = payload.engine;
34
+ if (engine !== 'graphify' && engine !== 'crg') return null;
35
+ return { engine, op: payload.op || '', timestamp: e.timestamp };
36
+ }
37
+
38
+ /**
39
+ * Parse events.jsonl and return only the graph-query events.
40
+ * Malformed lines are skipped so a corrupt event doesn't break the run view.
41
+ *
42
+ * @param {string} eventsPath — absolute path to events.jsonl
43
+ * @returns {Array<{engine, op, timestamp}>}
44
+ */
45
+ export function readGraphQueryEventsFromJsonl(eventsPath) {
46
+ if (!eventsPath || !existsSync(eventsPath)) return [];
47
+ let content;
48
+ try {
49
+ content = readFileSync(eventsPath, 'utf8');
50
+ } catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const line of content.split('\n')) {
55
+ if (!line.trim()) continue;
56
+ let e;
57
+ try {
58
+ e = JSON.parse(line);
59
+ } catch {
60
+ continue;
61
+ }
62
+ const entry = parseGraphQueryEventLine(e);
63
+ if (entry) out.push(entry);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * Given a list of graph-query events and a stages map from status.json, return
70
+ * a new stages map where each iteration that overlaps an event's timestamp gets
71
+ * live query counts — but ONLY when the iteration does not already carry the
72
+ * runner's authoritative count (i.e. the still-running iteration). Completed
73
+ * iterations are left exactly as the runner wrote them.
74
+ *
75
+ * Counts added per matching running iteration:
76
+ * graphify_invocations: <number> (graphify-engine events)
77
+ * crg_invocations: <number> (crg-engine events)
78
+ * crg_tool_counts: { <op>: <number> } (crg-engine, by tool — tooltip)
79
+ *
80
+ * Non-destructive: input stages object is shallow-copied; touched iterations
81
+ * get new objects. Existing iteration fields are preserved.
82
+ *
83
+ * @param {Array<{engine, op, timestamp}>} events
84
+ * @param {object} stages — status.stages
85
+ * @returns {object} enriched stages
86
+ */
87
+ export function assignGraphQueryCountsToIterations(events, stages) {
88
+ if (!stages || typeof stages !== 'object') return stages;
89
+ if (!events || events.length === 0) return stages;
90
+
91
+ // Bucket events into iterations by timestamp. Bucket key: `${stageKey}|${num}`.
92
+ const buckets = new Map();
93
+ for (const ev of events) {
94
+ if (!ev.timestamp) continue;
95
+ const eventTime = Date.parse(ev.timestamp);
96
+ if (Number.isNaN(eventTime)) continue;
97
+
98
+ let matched = false;
99
+ for (const [stageKey, stage] of Object.entries(stages)) {
100
+ const iterations = stage?.iterations;
101
+ if (!Array.isArray(iterations)) continue;
102
+ for (const iter of iterations) {
103
+ // Only fill counts for iterations the runner hasn't tallied yet —
104
+ // a present number means the stage completed and is authoritative.
105
+ if (typeof iter.graphify_invocations === 'number') continue;
106
+ if (typeof iter.crg_invocations === 'number') continue;
107
+ const start = iter.started_at ? Date.parse(iter.started_at) : NaN;
108
+ if (Number.isNaN(start)) continue;
109
+ const end = iter.completed_at
110
+ ? Date.parse(iter.completed_at)
111
+ : Number.POSITIVE_INFINITY;
112
+ if (eventTime >= start && eventTime <= end) {
113
+ const key = `${stageKey}|${iter.number}`;
114
+ if (!buckets.has(key)) buckets.set(key, []);
115
+ buckets.get(key).push(ev);
116
+ matched = true;
117
+ break;
118
+ }
119
+ }
120
+ if (matched) break;
121
+ }
122
+ }
123
+
124
+ if (buckets.size === 0) return stages;
125
+
126
+ const enrichedStages = { ...stages };
127
+ for (const [key, bucketEvents] of buckets) {
128
+ const [stageKey, iterNumStr] = key.split('|');
129
+ const iterNum = Number(iterNumStr);
130
+ const stage = enrichedStages[stageKey];
131
+ if (!stage) continue;
132
+ const counts = tally(bucketEvents);
133
+ const newIterations = stage.iterations.map((iter) =>
134
+ iter.number === iterNum ? { ...iter, ...counts } : iter,
135
+ );
136
+ enrichedStages[stageKey] = { ...stage, iterations: newIterations };
137
+ }
138
+ return enrichedStages;
139
+ }
140
+
141
+ /**
142
+ * Tally graph-query events into the count shape the badges read.
143
+ *
144
+ * @param {Array<{engine, op}>} events
145
+ * @returns {{graphify_invocations, crg_invocations, crg_tool_counts}}
146
+ */
147
+ function tally(events) {
148
+ let graphify = 0;
149
+ let crg = 0;
150
+ const crgToolCounts = {};
151
+ for (const ev of events) {
152
+ if (ev.engine === 'graphify') {
153
+ graphify += 1;
154
+ } else if (ev.engine === 'crg') {
155
+ crg += 1;
156
+ const op = ev.op || 'unknown';
157
+ crgToolCounts[op] = (crgToolCounts[op] || 0) + 1;
158
+ }
159
+ }
160
+ return {
161
+ graphify_invocations: graphify,
162
+ crg_invocations: crg,
163
+ crg_tool_counts: crgToolCounts,
164
+ };
165
+ }
@@ -134,6 +134,16 @@ function renderGitPrCreated(envelope) {
134
134
  return mdMsg(parts.join('\n'), 'info');
135
135
  }
136
136
 
137
+ function renderGitPrDeferred(envelope) {
138
+ const p = envelope.payload;
139
+ const parts = [`\u{1F7E1} **Run:** \`${runId(envelope)}\``];
140
+ parts.push(
141
+ ` **PR deferred:** branch \`${p.head_branch}\` pushed — open the run and click **Create PR** to publish.`,
142
+ );
143
+ if (p.pr_title) parts.push(` **Title:** ${p.pr_title}`);
144
+ return mdMsg(parts.join('\n'), 'warning');
145
+ }
146
+
137
147
  function renderGitPrMerged(envelope) {
138
148
  const p = envelope.payload;
139
149
  const parts = [`\u2705 **PR merged:** [#${p.pr_number}](${p.pr_url})`];
@@ -560,6 +570,7 @@ const EVENT_RENDERERS = {
560
570
  'pipeline.stage.completed': renderStageCompleted,
561
571
  'pipeline.stage.interrupted': renderStageInterrupted,
562
572
  'pipeline.git.pr_created': renderGitPrCreated,
573
+ 'pipeline.git.pr_deferred': renderGitPrDeferred,
563
574
  'pipeline.git.pr_merged': renderGitPrMerged,
564
575
  'pipeline.circuit_breaker.tripped': renderCbTripped,
565
576
  'pipeline.cost.budget_warning': renderCostBudgetWarning,