paseo-beads 0.1.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,687 @@
1
+ import {
2
+ BOARD_ISSUE_LIMIT,
3
+ isClosedStatus,
4
+ type Alert,
5
+ type AlertSummary,
6
+ type Blocker,
7
+ type BoardIssue,
8
+ type BoardSnapshot,
9
+ type IssueDetail,
10
+ type PlanSummary,
11
+ type ProjectCounts,
12
+ type ProjectHealth,
13
+ type Recommendation,
14
+ type SearchResult,
15
+ type SourceAuthority,
16
+ type SourceSnapshot,
17
+ type Track,
18
+ } from "../shared/beads";
19
+
20
+ /**
21
+ * `bv` and the trackers are external tools with an evolving payload shape.
22
+ * Every reader below is defensive: unknown fields are ignored, unknown enum
23
+ * values stay opaque strings, and a missing section becomes `null` rather than
24
+ * a thrown error. Nothing here interprets `.beads/*.jsonl` or recomputes graph
25
+ * analysis; it only reshapes what the tools already decided.
26
+ */
27
+
28
+ type Json = Record<string, unknown>;
29
+
30
+ export function asRecord(value: unknown): Json | null {
31
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
32
+ return value as Json;
33
+ }
34
+
35
+ function readString(source: Json | null, key: string): string | null {
36
+ if (source === null) return null;
37
+ const value = source[key];
38
+ if (typeof value !== "string") return null;
39
+ const trimmed = value.trim();
40
+ return trimmed.length === 0 ? null : trimmed;
41
+ }
42
+
43
+ function readNumber(source: Json | null, key: string): number | null {
44
+ if (source === null) return null;
45
+ const value = source[key];
46
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
47
+ }
48
+
49
+ function readCount(source: Json | null, key: string): number {
50
+ return readNumber(source, key) ?? 0;
51
+ }
52
+
53
+ function readBoolean(source: Json | null, key: string): boolean {
54
+ if (source === null) return false;
55
+ return source[key] === true;
56
+ }
57
+
58
+ function readArray(source: Json | null, key: string): readonly unknown[] {
59
+ if (source === null) return [];
60
+ const value = source[key];
61
+ return Array.isArray(value) ? value : [];
62
+ }
63
+
64
+ function readStringArray(source: Json | null, key: string): string[] {
65
+ return readArray(source, key).filter((entry): entry is string => typeof entry === "string");
66
+ }
67
+
68
+ /** Accepts either a plain string or a nested `{ text }`-shaped comment body. */
69
+ function readText(source: Json | null, ...keys: readonly string[]): string | null {
70
+ for (const key of keys) {
71
+ const value = readString(source, key);
72
+ if (value !== null) return value;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ export function normalizeAuthority(payload: unknown): SourceAuthority | null {
78
+ const authority = asRecord(payload);
79
+ if (authority === null) return null;
80
+ const sources = readArray(authority, "sources").map(asRecord);
81
+ const warnings: string[] = [];
82
+ let stale = false;
83
+ for (const source of sources) {
84
+ if (source === null) continue;
85
+ if (source["stale"] === true) stale = true;
86
+ for (const warning of readStringArray(source, "warnings")) warnings.push(warning);
87
+ const error = readString(source, "error");
88
+ if (error !== null) warnings.push(error);
89
+ }
90
+ return {
91
+ state: readString(authority, "state") ?? "unknown",
92
+ readiness: readString(authority, "readiness") ?? "unknown",
93
+ claimSafe: readBoolean(authority, "claim_safe"),
94
+ stale,
95
+ loaded: readCount(authority, "loaded"),
96
+ failed: readCount(authority, "failed"),
97
+ valid: readCount(authority, "valid"),
98
+ visible: readCount(authority, "visible"),
99
+ tombstones: readCount(authority, "tombstones"),
100
+ warnings,
101
+ };
102
+ }
103
+
104
+ /** Provenance envelope shared by every `bv --robot-*` payload. */
105
+ export function normalizeSource(payload: unknown): SourceSnapshot | null {
106
+ const root = asRecord(payload);
107
+ if (root === null) return null;
108
+ return {
109
+ generatedAt: readString(root, "generated_at"),
110
+ dataHash: readString(root, "data_hash"),
111
+ sourcePath: readString(root, "source_path"),
112
+ sourceKind: readString(root, "source_kind"),
113
+ toolVersion: readString(root, "version"),
114
+ authority: normalizeAuthority(root["source_authority"]),
115
+ };
116
+ }
117
+
118
+ /** A `bv` payload carrying a top-level `error` means the project failed to load. */
119
+ export function readPayloadError(payload: unknown): string | null {
120
+ return readString(asRecord(payload), "error");
121
+ }
122
+
123
+ export function normalizeCounts(payload: unknown): ProjectCounts | null {
124
+ const root = asRecord(payload);
125
+ const triage = asRecord(root?.["triage"]);
126
+ const quickRef = asRecord(triage?.["quick_ref"]);
127
+ if (quickRef === null) return null;
128
+ const meta = asRecord(triage?.["meta"]);
129
+ const healthCounts = asRecord(asRecord(triage?.["project_health"])?.["counts"]);
130
+ return {
131
+ open: readCount(quickRef, "open_count"),
132
+ actionable: readCount(quickRef, "actionable_count"),
133
+ blocked: readCount(quickRef, "blocked_count"),
134
+ inProgress: readCount(quickRef, "in_progress_count"),
135
+ notClosed: readCount(quickRef, "not_closed_count"),
136
+ notActionable: readCount(quickRef, "not_actionable_count"),
137
+ total: readCount(meta, "issue_count"),
138
+ waiting: readNumber(healthCounts, "dependency_blocked"),
139
+ closed: readNumber(healthCounts, "closed"),
140
+ };
141
+ }
142
+
143
+ /** Velocity and cycle state from triage's `project_health`; null when absent. */
144
+ export function normalizeHealth(payload: unknown): ProjectHealth | null {
145
+ const triage = asRecord(asRecord(payload)?.["triage"]);
146
+ const health = asRecord(triage?.["project_health"]);
147
+ if (health === null) return null;
148
+ const velocity = asRecord(health["velocity"]);
149
+ const graph = asRecord(health["graph"]);
150
+ const cycles = graph?.["has_cycles"];
151
+ return {
152
+ closedLast7Days: readNumber(velocity, "closed_last_7_days"),
153
+ closedLast30Days: readNumber(velocity, "closed_last_30_days"),
154
+ velocityEstimated: readBoolean(velocity, "estimated"),
155
+ hasCycles: typeof cycles === "boolean" ? cycles : null,
156
+ };
157
+ }
158
+
159
+ function normalizeRecommendation(entry: unknown): Recommendation | null {
160
+ const record = asRecord(entry);
161
+ const id = readString(record, "id");
162
+ if (record === null || id === null) return null;
163
+ return {
164
+ id,
165
+ title: readString(record, "title") ?? id,
166
+ status: readString(record, "status") ?? "unknown",
167
+ type: readString(record, "type") ?? readString(record, "issue_type"),
168
+ priority: readNumber(record, "priority"),
169
+ assignee: readString(record, "assignee"),
170
+ labels: readStringArray(record, "labels"),
171
+ score: readNumber(record, "score"),
172
+ action: readString(record, "action"),
173
+ reasons: readStringArray(record, "reasons"),
174
+ blockedBy: readStringArray(record, "blocked_by"),
175
+ unblocks: readStringArray(record, "unblocks_ids"),
176
+ claimable: readBoolean(record, "claimable"),
177
+ };
178
+ }
179
+
180
+ export function normalizeRecommendations(payload: unknown, limit = 12): Recommendation[] {
181
+ const triage = asRecord(asRecord(payload)?.["triage"]);
182
+ const picks = readArray(asRecord(triage?.["quick_ref"]), "top_picks");
183
+ const source = picks.length > 0 ? picks : readArray(triage, "recommendations");
184
+ const normalized: Recommendation[] = [];
185
+ for (const entry of source) {
186
+ const recommendation = normalizeRecommendation(entry);
187
+ if (recommendation !== null) normalized.push(recommendation);
188
+ if (normalized.length >= limit) break;
189
+ }
190
+ return normalized;
191
+ }
192
+
193
+ export function normalizeBlockers(payload: unknown, limit = 12): Blocker[] {
194
+ const triage = asRecord(asRecord(payload)?.["triage"]);
195
+ const blockers: Blocker[] = [];
196
+ for (const entry of readArray(triage, "blockers_to_clear")) {
197
+ const record = asRecord(entry);
198
+ const id = readString(record, "id");
199
+ if (record === null || id === null) continue;
200
+ const unblocks = readStringArray(record, "unblocks_ids");
201
+ blockers.push({
202
+ id,
203
+ title: readString(record, "title") ?? id,
204
+ unblocksCount: readNumber(record, "unblocks_count") ?? unblocks.length,
205
+ unblocks,
206
+ actionable: readBoolean(record, "actionable"),
207
+ });
208
+ if (blockers.length >= limit) break;
209
+ }
210
+ return blockers;
211
+ }
212
+
213
+ export function normalizeTracks(payload: unknown, trackLimit = 12, itemLimit = 25): Track[] {
214
+ const plan = asRecord(asRecord(payload)?.["plan"]);
215
+ const tracks: Track[] = [];
216
+ for (const entry of readArray(plan, "tracks")) {
217
+ const record = asRecord(entry);
218
+ if (record === null) continue;
219
+ const items: Track["items"] = [];
220
+ const rawItems = readArray(record, "items");
221
+ for (const rawItem of rawItems) {
222
+ const item = asRecord(rawItem);
223
+ const id = readString(item, "id");
224
+ if (item === null || id === null) continue;
225
+ items.push({
226
+ id,
227
+ title: readString(item, "title") ?? id,
228
+ status: readString(item, "status") ?? "unknown",
229
+ priority: readNumber(item, "priority"),
230
+ unblocks: readStringArray(item, "unblocks"),
231
+ });
232
+ if (items.length >= itemLimit) break;
233
+ }
234
+ tracks.push({
235
+ id: readString(record, "track_id") ?? `track-${tracks.length + 1}`,
236
+ reason: readString(record, "reason"),
237
+ items,
238
+ totalItems: rawItems.length,
239
+ });
240
+ if (tracks.length >= trackLimit) break;
241
+ }
242
+ return tracks;
243
+ }
244
+
245
+ /**
246
+ * Reshapes `bv --robot-graph` into the complete issue set behind the board.
247
+ *
248
+ * `bv` keeps a `blocks` edge after its blocker closes, so counting edges would
249
+ * report "blocked by 1" on work that is ready. Only blockers that are still
250
+ * open are kept, and only dependents that are still open count as unblocked.
251
+ *
252
+ * Edge semantics, verified against `bv v0.25.0` by cross-checking `br show
253
+ * --json` on real repositories. The two edge kinds do NOT share a direction:
254
+ * - `blocks` runs `from` → `to` where `from` is blocked by `to`, so an
255
+ * inbound edge means "this issue unblocks that one".
256
+ * - `parent-child` runs `child` → `parent`, the opposite way round.
257
+ * `discovered-from` and `related` are ignored: neither implies containment or
258
+ * ordering, and inventing one would misgroup issues.
259
+ *
260
+ * When the project is larger than `limit`, open work is kept and closed issues
261
+ * are dropped first: a truncated board should still show everything a person
262
+ * can act on, and `truncated` says so out loud.
263
+ */
264
+ export function normalizeBoardIssues(
265
+ payload: unknown,
266
+ facets: TrackerFacets = EMPTY_FACETS,
267
+ limit = BOARD_ISSUE_LIMIT,
268
+ ): BoardSnapshot {
269
+ const adjacency = asRecord(asRecord(payload)?.["adjacency"]);
270
+ const rawNodes = readArray(adjacency, "nodes");
271
+ if (rawNodes.length === 0) return { issues: [], typed: facets.ok, total: 0, truncated: false };
272
+
273
+ // Statuses first: an edge only blocks while its blocker is still open.
274
+ const statuses = new Map<string, string>();
275
+ for (const rawNode of rawNodes) {
276
+ const node = asRecord(rawNode);
277
+ const id = readString(node, "id");
278
+ if (id === null || statuses.has(id)) continue;
279
+ statuses.set(id, readString(node, "status") ?? "unknown");
280
+ }
281
+ const isOpen = (id: string): boolean => {
282
+ const status = statuses.get(id);
283
+ // A blocker the graph does not list cannot be shown closed, so it still blocks.
284
+ return status === undefined || !isClosedStatus(status);
285
+ };
286
+
287
+ const blockedBy = new Map<string, string[]>();
288
+ const unblocksCounts = new Map<string, number>();
289
+ const parents = new Map<string, string>();
290
+ const childCounts = new Map<string, number>();
291
+ const seenBlocks = new Set<string>();
292
+ for (const rawEdge of readArray(adjacency, "edges")) {
293
+ const edge = asRecord(rawEdge);
294
+ const from = readString(edge, "from");
295
+ const to = readString(edge, "to");
296
+ if (from === null || to === null) continue;
297
+ const type = readString(edge, "type");
298
+ if (type === "blocks") {
299
+ // A repeated edge is one dependency, not two.
300
+ const pair = `${from}\u0000${to}`;
301
+ if (seenBlocks.has(pair)) continue;
302
+ seenBlocks.add(pair);
303
+ if (isOpen(to)) {
304
+ const list = blockedBy.get(from);
305
+ if (list === undefined) blockedBy.set(from, [to]);
306
+ else list.push(to);
307
+ }
308
+ if (isOpen(from)) unblocksCounts.set(to, (unblocksCounts.get(to) ?? 0) + 1);
309
+ } else if (type === "parent-child" && !parents.has(from)) {
310
+ parents.set(from, to);
311
+ childCounts.set(to, (childCounts.get(to) ?? 0) + 1);
312
+ }
313
+ }
314
+
315
+ const open: BoardIssue[] = [];
316
+ const closed: BoardIssue[] = [];
317
+ const seen = new Set<string>();
318
+ for (const rawNode of rawNodes) {
319
+ const node = asRecord(rawNode);
320
+ const id = readString(node, "id");
321
+ if (id === null || seen.has(id)) continue;
322
+ seen.add(id);
323
+ const status = statuses.get(id) ?? "unknown";
324
+ const facet = facets.byId.get(id);
325
+ const issue: BoardIssue = {
326
+ id,
327
+ title: readString(node, "title") ?? id,
328
+ status,
329
+ priority: readNumber(node, "priority"),
330
+ labels: readStringArray(node, "labels"),
331
+ blockedBy: blockedBy.get(id) ?? [],
332
+ unblocksCount: unblocksCounts.get(id) ?? 0,
333
+ parentId: parents.get(id) ?? null,
334
+ childCount: childCounts.get(id) ?? 0,
335
+ type: facet?.type ?? null,
336
+ assignee: facet?.assignee ?? null,
337
+ };
338
+ if (isClosedStatus(status)) closed.push(issue);
339
+ else open.push(issue);
340
+ }
341
+
342
+ const total = open.length + closed.length;
343
+ if (total <= limit) {
344
+ return { issues: [...open, ...closed], typed: facets.ok, total, truncated: false };
345
+ }
346
+ // Closed containers above open work are kept before any other closed issue,
347
+ // so a truncated board still groups every open issue under its real epic.
348
+ const ancestors = new Set<string>();
349
+ for (const issue of open) {
350
+ for (let parent = parents.get(issue.id); parent !== undefined && !ancestors.has(parent); parent = parents.get(parent)) {
351
+ ancestors.add(parent);
352
+ }
353
+ }
354
+ const keptClosed = closed.filter((issue) => ancestors.has(issue.id));
355
+ const otherClosed = closed.filter((issue) => !ancestors.has(issue.id));
356
+ const kept = [...open, ...keptClosed];
357
+ return {
358
+ issues: [...kept, ...otherClosed.slice(0, Math.max(0, limit - kept.length))],
359
+ typed: facets.ok,
360
+ total,
361
+ truncated: true,
362
+ };
363
+ }
364
+
365
+ /** Type and assignee for one issue, the two facets the graph does not carry. */
366
+ export interface TrackerFacet {
367
+ readonly type: string | null;
368
+ readonly assignee: string | null;
369
+ }
370
+
371
+ export interface TrackerFacets {
372
+ /** False when the tracker was absent or rejected the read; the board then has no types. */
373
+ readonly ok: boolean;
374
+ readonly byId: ReadonlyMap<string, TrackerFacet>;
375
+ }
376
+
377
+ export const EMPTY_FACETS: TrackerFacets = { ok: false, byId: new Map() };
378
+
379
+ /** Bounds on the facet CSV, so a runaway tracker cannot allocate without limit. */
380
+ const FACET_MAX_ROWS = 20_000;
381
+ const FACET_MAX_FIELD_LENGTH = 512;
382
+
383
+ /**
384
+ * Reads RFC 4180 CSV records.
385
+ *
386
+ * The trackers do quote: `br` wrapped 167 of 784 rows when asked for a column
387
+ * holding free text, because titles and assignees are unconstrained strings. A
388
+ * line-splitting parser would mis-split or silently drop exactly those rows, so
389
+ * this walks characters instead and handles quoted commas, doubled quotes, and
390
+ * newlines inside a quoted field.
391
+ *
392
+ * Bounds are enforced while scanning: an over-long field or an unterminated
393
+ * quote ends the read rather than growing without limit.
394
+ */
395
+ export function readCsvRecords(
396
+ csv: string,
397
+ maxRecords = FACET_MAX_ROWS,
398
+ maxFieldLength = FACET_MAX_FIELD_LENGTH,
399
+ ): { readonly records: readonly (readonly string[])[]; readonly truncated: boolean } {
400
+ const records: string[][] = [];
401
+ let record: string[] = [];
402
+ let field = "";
403
+ let quoted = false;
404
+ let started = false;
405
+
406
+ const endField = (): boolean => {
407
+ if (field.length > maxFieldLength) return false;
408
+ record.push(field);
409
+ field = "";
410
+ started = false;
411
+ return true;
412
+ };
413
+ const endRecord = (): boolean => {
414
+ if (!endField()) return false;
415
+ // A trailing newline produces one empty field, which is not a record.
416
+ if (record.length > 1 || record[0] !== "") records.push(record);
417
+ record = [];
418
+ return true;
419
+ };
420
+
421
+ for (let index = 0; index < csv.length; index += 1) {
422
+ const char = csv[index];
423
+ if (quoted) {
424
+ if (char === '"') {
425
+ if (csv[index + 1] === '"') {
426
+ field += '"';
427
+ index += 1;
428
+ } else {
429
+ quoted = false;
430
+ }
431
+ } else {
432
+ field += char;
433
+ }
434
+ if (field.length > maxFieldLength) return { records, truncated: true };
435
+ continue;
436
+ }
437
+ if (char === '"' && !started) {
438
+ quoted = true;
439
+ started = true;
440
+ continue;
441
+ }
442
+ if (char === ",") {
443
+ if (!endField()) return { records, truncated: true };
444
+ continue;
445
+ }
446
+ if (char === "\n" || char === "\r") {
447
+ if (char === "\r" && csv[index + 1] === "\n") index += 1;
448
+ if (!endRecord()) return { records, truncated: true };
449
+ if (records.length >= maxRecords) return { records, truncated: index < csv.length - 1 };
450
+ continue;
451
+ }
452
+ field += char;
453
+ started = true;
454
+ if (field.length > maxFieldLength) return { records, truncated: true };
455
+ }
456
+
457
+ if (quoted) return { records, truncated: true };
458
+ if (field.length > 0 || record.length > 0) {
459
+ if (!endRecord()) return { records, truncated: true };
460
+ }
461
+ return { records, truncated: false };
462
+ }
463
+
464
+ const FACET_HEADER: readonly string[] = ["id", "issue_type", "assignee"];
465
+
466
+ /**
467
+ * Parses `id,issue_type,assignee` from `br`/`bd list`.
468
+ *
469
+ * A partial overlay is worse than none: an issue whose row was dropped is
470
+ * indistinguishable from a genuinely untyped one, and it would sit in the
471
+ * board's catch-all group with nothing to explain why. So a truncated or
472
+ * malformed read reports `ok: false`, which disables the axes that need types
473
+ * rather than quietly misgrouping issues.
474
+ */
475
+ export function parseTrackerFacets(csv: string): TrackerFacets {
476
+ const { records, truncated } = readCsvRecords(csv);
477
+ if (truncated) return EMPTY_FACETS;
478
+
479
+ const byId = new Map<string, TrackerFacet>();
480
+ for (const [index, record] of records.entries()) {
481
+ if (record.length !== FACET_HEADER.length) continue;
482
+ const id = record[0]?.trim() ?? "";
483
+ if (id.length === 0) continue;
484
+ // Tolerate a tracker that omits the header rather than losing the first row.
485
+ if (index === 0 && FACET_HEADER.every((name, column) => record[column]?.trim() === name)) continue;
486
+ const type = record[1]?.trim() ?? "";
487
+ const assignee = record[2]?.trim() ?? "";
488
+ byId.set(id, {
489
+ type: type.length === 0 ? null : type.toLowerCase(),
490
+ assignee: assignee.length === 0 ? null : assignee,
491
+ });
492
+ }
493
+ return { ok: byId.size > 0, byId };
494
+ }
495
+
496
+ export function normalizePlanSummary(payload: unknown): PlanSummary | null {
497
+ const plan = asRecord(asRecord(payload)?.["plan"]);
498
+ if (plan === null) return null;
499
+ const summary = asRecord(plan["summary"]);
500
+ return {
501
+ totalActionable: readNumber(plan, "total_actionable"),
502
+ totalBlocked: readNumber(plan, "total_blocked"),
503
+ totalTracks: readArray(plan, "tracks").length,
504
+ highestImpact: readString(summary, "highest_impact"),
505
+ impactReason: readString(summary, "impact_reason"),
506
+ };
507
+ }
508
+
509
+ const SEVERITY_ORDER: Readonly<Record<string, number>> = { critical: 0, warning: 1, info: 2 };
510
+
511
+ export function normalizeAlerts(payload: unknown, limit = 20): Alert[] {
512
+ const alerts: Alert[] = [];
513
+ for (const entry of readArray(asRecord(payload), "alerts")) {
514
+ const record = asRecord(entry);
515
+ const message = readString(record, "message");
516
+ if (record === null || message === null) continue;
517
+ alerts.push({
518
+ type: readString(record, "type") ?? "unknown",
519
+ severity: readString(record, "severity") ?? "info",
520
+ message,
521
+ issueId: readString(record, "issue_id"),
522
+ detectedAt: readString(record, "detected_at"),
523
+ suggestedAction: readString(record, "suggested_action"),
524
+ labels: readStringArray(record, "labels"),
525
+ });
526
+ }
527
+ alerts.sort((left, right) => {
528
+ const leftRank = SEVERITY_ORDER[left.severity] ?? 3;
529
+ const rightRank = SEVERITY_ORDER[right.severity] ?? 3;
530
+ return leftRank - rightRank;
531
+ });
532
+ return alerts.slice(0, limit);
533
+ }
534
+
535
+ export function normalizeAlertSummary(payload: unknown): AlertSummary | null {
536
+ const summary = asRecord(asRecord(payload)?.["summary"]);
537
+ if (summary === null) return null;
538
+ return {
539
+ total: readCount(summary, "total"),
540
+ critical: readCount(summary, "critical"),
541
+ warning: readCount(summary, "warning"),
542
+ info: readCount(summary, "info"),
543
+ };
544
+ }
545
+
546
+ export function normalizeSearchResults(payload: unknown, limit: number): SearchResult[] {
547
+ const results: SearchResult[] = [];
548
+ for (const entry of readArray(asRecord(payload), "results")) {
549
+ const record = asRecord(entry);
550
+ const id = readString(record, "issue_id") ?? readString(record, "id");
551
+ if (record === null || id === null) continue;
552
+ results.push({
553
+ id,
554
+ title: readString(record, "title") ?? id,
555
+ score: readNumber(record, "score"),
556
+ });
557
+ if (results.length >= limit) break;
558
+ }
559
+ return results;
560
+ }
561
+
562
+ function normalizeRefs(source: Json | null, key: string, limit = 25): IssueDetail["dependencies"] {
563
+ const refs: IssueDetail["dependencies"] = [];
564
+ for (const entry of readArray(source, key)) {
565
+ const record = asRecord(entry);
566
+ const id = readString(record, "id") ?? readString(record, "depends_on_id") ?? readString(record, "issue_id");
567
+ if (record === null || id === null) continue;
568
+ refs.push({
569
+ id,
570
+ title: readString(record, "title"),
571
+ status: readString(record, "status"),
572
+ relation: readString(record, "dependency_type") ?? readString(record, "type"),
573
+ });
574
+ if (refs.length >= limit) break;
575
+ }
576
+ return refs;
577
+ }
578
+
579
+ function normalizeComments(source: Json | null, limit = 30): IssueDetail["comments"] {
580
+ const comments: IssueDetail["comments"] = [];
581
+ for (const entry of readArray(source, "comments")) {
582
+ const record = asRecord(entry);
583
+ if (record === null) continue;
584
+ const text = readText(record, "text", "body");
585
+ if (text === null) continue;
586
+ const rawId = record["id"];
587
+ const id =
588
+ typeof rawId === "string" && rawId.length > 0
589
+ ? rawId
590
+ : typeof rawId === "number"
591
+ ? String(rawId)
592
+ : `comment-${comments.length + 1}`;
593
+ comments.push({
594
+ id,
595
+ author: readString(record, "author"),
596
+ text,
597
+ createdAt: readString(record, "created_at"),
598
+ });
599
+ if (comments.length >= limit) break;
600
+ }
601
+ return comments;
602
+ }
603
+
604
+ /**
605
+ * `br`/`bd` answer `show --json` with either an object or a single-element
606
+ * array. Both shapes normalize to one detail record.
607
+ */
608
+ export function normalizeIssueDetail(payload: unknown): IssueDetail | null {
609
+ const candidate = Array.isArray(payload) ? (payload.length === 1 ? payload[0] : undefined) : payload;
610
+ const record = asRecord(candidate);
611
+ const id = readString(record, "id");
612
+ if (record === null || id === null) return null;
613
+ return {
614
+ id,
615
+ title: readString(record, "title") ?? id,
616
+ status: readString(record, "status") ?? "unknown",
617
+ type: readString(record, "issue_type") ?? readString(record, "type"),
618
+ priority: readNumber(record, "priority"),
619
+ assignee: readString(record, "assignee"),
620
+ description: readString(record, "description"),
621
+ design: readString(record, "design"),
622
+ acceptanceCriteria: readString(record, "acceptance_criteria"),
623
+ notes: readString(record, "notes"),
624
+ labels: readStringArray(record, "labels"),
625
+ parent: readString(record, "parent"),
626
+ dependencies: normalizeRefs(record, "dependencies"),
627
+ dependents: normalizeRefs(record, "dependents"),
628
+ comments: normalizeComments(record),
629
+ createdAt: readString(record, "created_at"),
630
+ updatedAt: readString(record, "updated_at"),
631
+ closedAt: readString(record, "closed_at"),
632
+ closeReason: readString(record, "close_reason"),
633
+ };
634
+ }
635
+
636
+ const SNAPSHOT_FIELD_LIMIT = 1600;
637
+
638
+ function snapshotSection(label: string, value: string | null): string {
639
+ if (value === null) return "";
640
+ const trimmed = value.length > SNAPSHOT_FIELD_LIMIT ? `${value.slice(0, SNAPSHOT_FIELD_LIMIT)}…` : value;
641
+ return `\n\n## ${label}\n${trimmed}`;
642
+ }
643
+
644
+ /** Plain-text snapshot handed to the agent when an issue is attached. */
645
+ export function buildIssueSnapshot(input: {
646
+ readonly workspaceName: string;
647
+ readonly workspaceDirectory: string | null;
648
+ readonly issueId: string;
649
+ readonly title: string;
650
+ readonly detail: IssueDetail | null;
651
+ }): string {
652
+ const header = [
653
+ `Beads issue ${input.issueId}: ${input.title}`,
654
+ `Paseo workspace: ${input.workspaceName}`,
655
+ input.workspaceDirectory === null ? null : `Directory: ${input.workspaceDirectory}`,
656
+ ]
657
+ .filter((line): line is string => line !== null)
658
+ .join("\n");
659
+
660
+ const detail = input.detail;
661
+ if (detail === null) {
662
+ return `${header}\n\nDetail unavailable: only search metadata could be read for this issue.`;
663
+ }
664
+
665
+ const facts = [
666
+ `Status: ${detail.status}`,
667
+ detail.type === null ? null : `Type: ${detail.type}`,
668
+ detail.priority === null ? null : `Priority: P${detail.priority}`,
669
+ detail.assignee === null ? null : `Assignee: ${detail.assignee}`,
670
+ detail.labels.length === 0 ? null : `Labels: ${detail.labels.join(", ")}`,
671
+ detail.parent === null ? null : `Parent: ${detail.parent}`,
672
+ detail.dependencies.length === 0 ? null : `Depends on: ${detail.dependencies.map((ref) => ref.id).join(", ")}`,
673
+ detail.dependents.length === 0 ? null : `Blocks: ${detail.dependents.map((ref) => ref.id).join(", ")}`,
674
+ detail.updatedAt === null ? null : `Updated: ${detail.updatedAt}`,
675
+ ]
676
+ .filter((line): line is string => line !== null)
677
+ .join("\n");
678
+
679
+ return [
680
+ header,
681
+ `\n${facts}`,
682
+ snapshotSection("Description", detail.description),
683
+ snapshotSection("Design", detail.design),
684
+ snapshotSection("Acceptance criteria", detail.acceptanceCriteria),
685
+ snapshotSection("Notes", detail.notes),
686
+ ].join("");
687
+ }