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,108 @@
1
+ /**
2
+ * The "Beads" button on every open workspace's header (delta 20260918e §4.6,
3
+ * REQ-060 o; owner decision Q14 a).
4
+ *
5
+ * Paseo 0.8's mobile app has no "+" new-tab button, and its "…" Workspace
6
+ * actions sheet has a fixed list that plugins cannot extend, so the Beads tab
7
+ * (`bm-beads`) had no way in on a phone. The workspace header does draw plugin
8
+ * header buttons on mobile (the first one inline), and a button can open a
9
+ * panel. On desktop the same button is a shortcut next to "+".
10
+ *
11
+ * A header button belongs to ONE workspace, so one is added per open workspace
12
+ * and removed when the workspace goes, following the list the way
13
+ * `waiting-pills` follows its Workers: read now, every `BEADS_HEADER_POLL_MS`,
14
+ * and whenever Paseo reports a workspace update. A failed read keeps the
15
+ * buttons as they are.
16
+ *
17
+ * No JSX and no React Native: the button is data, and the test drives it with a
18
+ * fake client.
19
+ */
20
+ import type { PluginButton, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
21
+ import { BEADS_TAB_PANEL_ID } from "./dashboard-view";
22
+
23
+ /** One id for every workspace's button: Paseo keys a header button by id AND workspace. */
24
+ export const BEADS_HEADER_BUTTON_ID = "bm-beads-open";
25
+
26
+ /** How often the list of open workspaces is read again. */
27
+ export const BEADS_HEADER_POLL_MS = 15_000;
28
+
29
+ /** The button itself: an icon only, so it fits a phone's header. */
30
+ export function beadsHeaderButton(openTab: () => void): PluginButton {
31
+ return {
32
+ title: "Open the Beads tab: beads and metrics of this workspace",
33
+ icon: "ListChecks",
34
+ behavior: { kind: "action", onPress: openTab },
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Which buttons to add and remove: one per open workspace. A workspace being
40
+ * archived counts as gone, as it does in the Workspaces list.
41
+ */
42
+ export function planHeaderButtons(
43
+ shown: ReadonlySet<string>,
44
+ listed: ReadonlyArray<{ id: string; archivingAt?: string | null }>,
45
+ ): { add: string[]; remove: string[] } {
46
+ const open = new Set(listed.filter((workspace) => !workspace.archivingAt).map((workspace) => workspace.id));
47
+ return {
48
+ add: [...open].filter((id) => !shown.has(id)),
49
+ remove: [...shown].filter((id) => !open.has(id)),
50
+ };
51
+ }
52
+
53
+ /** Starts the buttons; returns the cleanup that stops following and removes them all. */
54
+ export function registerBeadsHeaderButtons(client: PluginClientContext): () => void {
55
+ const shown = new Map<string, PluginButtonRegistration>();
56
+ let pending = false;
57
+ let stopped = false;
58
+ let unsubscribe: (() => void) | undefined;
59
+
60
+ // The workspace is a parameter, not a loop variable: the phone runs this
61
+ // bundle on Hermes, where every closure made in a loop sees the loop's last
62
+ // value, so each button would open the last workspace listed.
63
+ const addButton = (workspaceId: string) =>
64
+ client.addHeaderButton({
65
+ id: BEADS_HEADER_BUTTON_ID,
66
+ workspaceId,
67
+ button: beadsHeaderButton(() => client.openPanel(BEADS_TAB_PANEL_ID, { workspaceId })),
68
+ });
69
+
70
+ const refresh = async () => {
71
+ if (pending || stopped) return;
72
+ pending = true;
73
+ try {
74
+ const { entries } = await client.paseo.workspaces.list({});
75
+ if (stopped) return;
76
+ const plan = planHeaderButtons(new Set(shown.keys()), entries);
77
+ for (const workspaceId of plan.remove) {
78
+ shown.get(workspaceId)?.remove();
79
+ shown.delete(workspaceId);
80
+ }
81
+ for (const workspaceId of plan.add) shown.set(workspaceId, addButton(workspaceId));
82
+ } catch {
83
+ // A failed read keeps the buttons as they are: better a button one
84
+ // period late than buttons that flicker whenever the daemon is slow.
85
+ } finally {
86
+ pending = false;
87
+ }
88
+ };
89
+
90
+ void refresh();
91
+ try {
92
+ // A newly opened workspace should not wait a whole period for its button.
93
+ unsubscribe = client.paseo.workspaces.subscribe(() => void refresh());
94
+ } catch {
95
+ // No live updates on this host: the timer still follows the list.
96
+ }
97
+ const timer = setInterval(() => void refresh(), BEADS_HEADER_POLL_MS);
98
+ // Node (tests) would otherwise stay alive for the timer; hosts without unref ignore this.
99
+ (timer as { unref?: () => void }).unref?.();
100
+
101
+ return () => {
102
+ stopped = true;
103
+ clearInterval(timer);
104
+ unsubscribe?.();
105
+ for (const registration of shown.values()) registration.remove();
106
+ shown.clear();
107
+ };
108
+ }
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Everything the Beads screen shows and decides, without a renderer
3
+ * (design delta 20260916-beads-screen).
4
+ *
5
+ * Pure: no React, no React Native, no `server/` import.
6
+ */
7
+ import type { BeadAction, BeadRow, BeadStats, BeadWork } from "../shared/contracts";
8
+ import { formatDuration, type Badge, type Bar, type OverviewCard, type Tone } from "./dashboard-model";
9
+
10
+ const DAY_MS = 86_400_000;
11
+
12
+ export function priorityLabel(priority: number | null): string {
13
+ return priority === null ? "P?" : `P${priority}`;
14
+ }
15
+
16
+ export type StatusBucket = "ready" | "blocked" | "in_progress" | "closed";
17
+
18
+ /** The bucket a bead is filtered and counted under. */
19
+ export function statusBucket(bead: Pick<BeadRow, "status" | "ready">): StatusBucket {
20
+ if (bead.status === "closed") return "closed";
21
+ if (bead.status === "in_progress") return "in_progress";
22
+ if (bead.status === "blocked") return "blocked";
23
+ return bead.ready ? "ready" : "blocked";
24
+ }
25
+
26
+ /**
27
+ * The one colour of each status (owner decisions Q3 and Q8, delta 20260918e):
28
+ * the title and the chip of a bead both read it, so they cannot disagree.
29
+ */
30
+ export const STATUS_TONE: Readonly<Record<StatusBucket, Tone>> = {
31
+ ready: "info", // accent: not started
32
+ in_progress: "warning",
33
+ blocked: "danger",
34
+ closed: "success",
35
+ };
36
+
37
+ const STATUS_TEXT: Readonly<Record<StatusBucket, string>> = {
38
+ ready: "Ready",
39
+ in_progress: "In progress",
40
+ blocked: "Blocked",
41
+ closed: "Closed",
42
+ };
43
+
44
+ export function statusBadge(bead: Pick<BeadRow, "status" | "ready">): Badge {
45
+ const bucket = statusBucket(bead);
46
+ return { text: STATUS_TEXT[bucket], tone: STATUS_TONE[bucket] };
47
+ }
48
+
49
+ /** The colour of a bead's title in a list: only the title is coloured, never the row (Q8). */
50
+ export function beadTitleTone(bead: Pick<BeadRow, "status" | "ready">): Tone {
51
+ return STATUS_TONE[statusBucket(bead)];
52
+ }
53
+
54
+ function median(values: readonly number[]): number | null {
55
+ if (values.length === 0) return null;
56
+ const sorted = [...values].sort((a, b) => a - b);
57
+ const middle = Math.floor(sorted.length / 2);
58
+ return sorted.length % 2 === 1 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2;
59
+ }
60
+
61
+ export function formatDays(ms: number | null): string {
62
+ if (ms === null) return "—";
63
+ const days = ms / DAY_MS;
64
+ if (days < 1) return `${Math.max(1, Math.round(ms / 3_600_000))}h`;
65
+ return `${days < 10 ? days.toFixed(1) : Math.round(days)}d`;
66
+ }
67
+
68
+ const parse = (at: string | null): number | null => {
69
+ if (at === null) return null;
70
+ const value = Date.parse(at);
71
+ return Number.isNaN(value) ? null : value;
72
+ };
73
+
74
+ export interface BeadsOverview {
75
+ /** 1. Status. */
76
+ status: OverviewCard[];
77
+ /** 2. Progress, epics excluded. */
78
+ progress: { closed: number; total: number; share: number; label: string };
79
+ /** 3. By type. (The per-day created/closed count was dropped at the owner's request, delta 20260918e REQ-060 m.) */
80
+ byType: Bar[];
81
+ /** 4. By priority. */
82
+ byPriority: Bar[];
83
+ /** 5. Time. */
84
+ timing: OverviewCard[];
85
+ }
86
+
87
+ const STALE_MS = 7 * DAY_MS;
88
+
89
+ export function beadsOverview(beads: readonly BeadRow[], stats: BeadStats, now: Date): BeadsOverview {
90
+ const work = beads.filter((bead) => bead.issueType !== "epic");
91
+ const closedWork = work.filter((bead) => bead.status === "closed").length;
92
+
93
+ const count = (key: (bead: BeadRow) => string, order?: readonly string[]): Bar[] => {
94
+ const counts = new Map<string, number>();
95
+ for (const bead of beads) counts.set(key(bead), (counts.get(key(bead)) ?? 0) + 1);
96
+ const entries = [...counts.entries()];
97
+ entries.sort((a, b) =>
98
+ order === undefined ? b[1] - a[1] : order.indexOf(a[0]) - order.indexOf(b[0]) || a[0].localeCompare(b[0]),
99
+ );
100
+ return entries.map(([label, value]) => ({ label, value, display: String(value) }));
101
+ };
102
+
103
+ const cycle = beads
104
+ .map((bead) => {
105
+ const created = parse(bead.createdAt);
106
+ const closed = parse(bead.closedAt);
107
+ return created === null || closed === null ? null : closed - created;
108
+ })
109
+ .filter((value): value is number => value !== null && value >= 0);
110
+ const oldestInProgress = Math.max(
111
+ -1,
112
+ ...beads
113
+ .filter((bead) => bead.status === "in_progress")
114
+ .map((bead) => {
115
+ const since = parse(bead.work?.started?.at ?? null) ?? parse(bead.updatedAt) ?? parse(bead.createdAt);
116
+ return since === null ? -1 : now.getTime() - since;
117
+ }),
118
+ );
119
+ const stale = beads.filter((bead) => {
120
+ if (bead.status === "closed") return false;
121
+ const updated = parse(bead.updatedAt);
122
+ return updated !== null && now.getTime() - updated > STALE_MS;
123
+ }).length;
124
+
125
+ return {
126
+ status: [
127
+ { label: "Total", value: String(stats.total), hint: stats.present ? "in .beads/issues.jsonl" : "no .beads/ here" },
128
+ { label: "Ready", value: String(stats.ready), hint: "can start now" },
129
+ { label: "In progress", value: String(stats.inProgress), hint: "being worked on" },
130
+ { label: "Blocked", value: String(stats.blocked), hint: "waiting on other beads" },
131
+ { label: "Closed", value: String(stats.closed), hint: "done" },
132
+ ],
133
+ progress: {
134
+ closed: closedWork,
135
+ total: work.length,
136
+ share: work.length === 0 ? 0 : closedWork / work.length,
137
+ label:
138
+ work.length === 0
139
+ ? "No work beads yet"
140
+ : `${Math.round((closedWork / work.length) * 100)}% done · ${closedWork}/${work.length} (epics not counted)`,
141
+ },
142
+ byType: count((bead) => bead.issueType),
143
+ byPriority: count((bead) => priorityLabel(bead.priority), ["P0", "P1", "P2", "P3", "P4", "P?"]),
144
+ timing: [
145
+ { label: "Median time to close", value: formatDays(median(cycle)), hint: `over ${cycle.length} closed bead(s)` },
146
+ {
147
+ label: "Longest in progress",
148
+ value: oldestInProgress < 0 ? "—" : formatDays(oldestInProgress),
149
+ hint: "since it started, or its last update",
150
+ },
151
+ { label: "Stale", value: String(stale), hint: "open, no update for 7+ days" },
152
+ ],
153
+ };
154
+ }
155
+
156
+ /**
157
+ * The done / total figure at the top of the Beads screen (owner decision Q11):
158
+ * it reads `beadsOverview(...).progress`, so it counts exactly like the
159
+ * Progress card — every bead but epics, whatever the filters show.
160
+ */
161
+ export function doneText(progress: { closed: number; total: number }): { text: string; label: string } {
162
+ return {
163
+ text: `✓ ${progress.closed} / ${progress.total} done`,
164
+ label: `${progress.closed} of ${progress.total} beads done, epics not counted`,
165
+ };
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Filters.
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface BeadFilter {
173
+ statuses: ReadonlySet<string>;
174
+ types: ReadonlySet<string>;
175
+ priorities: ReadonlySet<string>;
176
+ labels: ReadonlySet<string>;
177
+ text: string;
178
+ }
179
+
180
+ export const EMPTY_FILTER: BeadFilter = {
181
+ statuses: new Set(),
182
+ types: new Set(),
183
+ priorities: new Set(),
184
+ labels: new Set(),
185
+ text: "",
186
+ };
187
+
188
+ export interface FacetValue {
189
+ value: string;
190
+ /** Beads this value would show, given every OTHER active filter. */
191
+ count: number;
192
+ }
193
+
194
+ export interface Facets {
195
+ statuses: FacetValue[];
196
+ types: FacetValue[];
197
+ priorities: FacetValue[];
198
+ /** Label prefixes (`feature`, `wp`, …) with their values, most useful first. */
199
+ labelGroups: Array<{ category: string; values: FacetValue[]; selected: number }>;
200
+ }
201
+
202
+ export const STATUS_ORDER = ["ready", "in_progress", "blocked", "closed"] as const;
203
+
204
+ /** Categories people filter by most, shown first; `other` (no prefix) last. */
205
+ const CATEGORY_ORDER = ["feature", "area", "component", "stack", "risk", "phase", "wp", "change", "service"];
206
+
207
+ /** How many values a label group shows before "+N more". */
208
+ export const LABEL_PREVIEW = 5;
209
+
210
+ /**
211
+ * Facets with counts, the way faceted search usually works: a value's count
212
+ * is the number of beads it would show when combined with the other facets.
213
+ * Values that would show nothing are dropped unless they are selected, so a
214
+ * long label list shrinks as soon as anything else is filtered.
215
+ */
216
+ export function facetsOf(beads: readonly BeadRow[], filter: BeadFilter = EMPTY_FILTER): Facets {
217
+ const without = (patch: Partial<BeadFilter>) => filterBeads(beads, { ...filter, ...patch });
218
+ const counted = (
219
+ rows: readonly BeadRow[],
220
+ valuesOf: (bead: BeadRow) => string[],
221
+ selected: ReadonlySet<string>,
222
+ ): FacetValue[] => {
223
+ const counts = new Map<string, number>();
224
+ for (const bead of rows) for (const value of valuesOf(bead)) counts.set(value, (counts.get(value) ?? 0) + 1);
225
+ for (const value of selected) if (!counts.has(value)) counts.set(value, 0);
226
+ return [...counts.entries()].map(([value, count]) => ({ value, count }));
227
+ };
228
+
229
+ const statuses = counted(without({ statuses: new Set() }), (bead) => [statusBucket(bead)], filter.statuses).sort(
230
+ (a, b) => STATUS_ORDER.indexOf(a.value as never) - STATUS_ORDER.indexOf(b.value as never),
231
+ );
232
+ const types = counted(without({ types: new Set() }), (bead) => [bead.issueType], filter.types).sort(
233
+ (a, b) => b.count - a.count || a.value.localeCompare(b.value),
234
+ );
235
+ const priorities = counted(without({ priorities: new Set() }), (bead) => [priorityLabel(bead.priority)], filter.priorities).sort(
236
+ (a, b) => a.value.localeCompare(b.value),
237
+ );
238
+
239
+ const labelRows = without({ labels: new Set() });
240
+ const categoryOf = (label: string) => (label.includes(":") ? label.slice(0, label.indexOf(":")) : "other");
241
+ const byCategory = new Map<string, FacetValue[]>();
242
+ for (const entry of counted(labelRows, (bead) => bead.labels, filter.labels)) {
243
+ const list = byCategory.get(categoryOf(entry.value)) ?? [];
244
+ list.push(entry);
245
+ byCategory.set(categoryOf(entry.value), list);
246
+ }
247
+ const rank = (category: string) => {
248
+ const index = CATEGORY_ORDER.indexOf(category);
249
+ return category === "other" ? 1000 : index === -1 ? 500 : index;
250
+ };
251
+ const labelGroups = [...byCategory.entries()]
252
+ .map(([category, values]) => ({
253
+ category,
254
+ values: values.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)),
255
+ selected: values.filter((value) => filter.labels.has(value.value)).length,
256
+ }))
257
+ // A group where every bead has the same label cannot narrow anything.
258
+ .filter((group) => group.selected > 0 || !(group.values.length === 1 && group.values[0]!.count === labelRows.length))
259
+ .sort((a, b) => rank(a.category) - rank(b.category) || a.category.localeCompare(b.category));
260
+
261
+ const visible = (values: FacetValue[], selected: ReadonlySet<string>) =>
262
+ values.filter((value) => value.count > 0 || selected.has(value.value));
263
+ return {
264
+ statuses: visible(statuses, filter.statuses),
265
+ types: visible(types, filter.types),
266
+ priorities: visible(priorities, filter.priorities),
267
+ labelGroups: labelGroups
268
+ .map((group) => ({ ...group, values: visible(group.values, filter.labels) }))
269
+ .filter((group) => group.values.length > 0),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * The values a label group shows: selected ones always, then the most common
275
+ * up to `LABEL_PREVIEW` unless the group is expanded.
276
+ */
277
+ export function previewValues(values: readonly FacetValue[], selected: ReadonlySet<string>, expanded: boolean): {
278
+ shown: FacetValue[];
279
+ hidden: number;
280
+ } {
281
+ if (expanded) return { shown: [...values], hidden: 0 };
282
+ const picked = values.filter((value) => selected.has(value.value));
283
+ for (const value of values) {
284
+ if (picked.length >= LABEL_PREVIEW) break;
285
+ if (!selected.has(value.value)) picked.push(value);
286
+ }
287
+ return { shown: picked, hidden: values.length - picked.length };
288
+ }
289
+
290
+ /** Every active filter as a removable chip. */
291
+ export function activeFilters(filter: BeadFilter): Array<{ facet: keyof Omit<BeadFilter, "text">; value: string }> {
292
+ return (["statuses", "types", "priorities", "labels"] as const).flatMap((facet) =>
293
+ [...filter[facet]].map((value) => ({ facet, value })),
294
+ );
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Sorting.
299
+ // ---------------------------------------------------------------------------
300
+
301
+ export type SortKey = "updated" | "created" | "closed" | "priority";
302
+
303
+ export const SORT_OPTIONS: ReadonlyArray<{ key: SortKey; label: string }> = [
304
+ { key: "updated", label: "Updated" },
305
+ { key: "created", label: "Created" },
306
+ { key: "closed", label: "Closed" },
307
+ { key: "priority", label: "Priority" },
308
+ ];
309
+
310
+ /**
311
+ * Newest first by default (`descending`). Beads without the sorted value (an
312
+ * open bead has no close time) always go last, whichever the direction.
313
+ */
314
+ export function sortBeads(beads: readonly BeadRow[], key: SortKey, descending = true): BeadRow[] {
315
+ const valueOf = (bead: BeadRow): string | number | null => {
316
+ switch (key) {
317
+ case "updated":
318
+ return bead.updatedAt;
319
+ case "created":
320
+ return bead.createdAt;
321
+ case "closed":
322
+ return bead.closedAt;
323
+ case "priority":
324
+ // P0 is the most urgent, so "descending" urgency is ascending numbers.
325
+ return bead.priority === null ? null : -bead.priority;
326
+ }
327
+ };
328
+ return [...beads].sort((a, b) => {
329
+ const left = valueOf(a);
330
+ const right = valueOf(b);
331
+ if (left === null && right === null) return a.id.localeCompare(b.id);
332
+ if (left === null) return 1;
333
+ if (right === null) return -1;
334
+ const order = left < right ? -1 : left > right ? 1 : 0;
335
+ return (descending ? -order : order) || a.id.localeCompare(b.id);
336
+ });
337
+ }
338
+
339
+ /** Within one facet any selected value matches; across facets all must match. */
340
+ export function filterBeads(beads: readonly BeadRow[], filter: BeadFilter): BeadRow[] {
341
+ const text = filter.text.trim().toLowerCase();
342
+ return beads.filter((bead) => {
343
+ if (filter.statuses.size > 0 && !filter.statuses.has(statusBucket(bead))) return false;
344
+ if (filter.types.size > 0 && !filter.types.has(bead.issueType)) return false;
345
+ if (filter.priorities.size > 0 && !filter.priorities.has(priorityLabel(bead.priority))) return false;
346
+ if (filter.labels.size > 0 && !bead.labels.some((label) => filter.labels.has(label))) return false;
347
+ if (text !== "" && !`${bead.id} ${bead.title ?? ""}`.toLowerCase().includes(text)) return false;
348
+ return true;
349
+ });
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Groups and the closed-beads toggle (delta 20260918e §4.4, owner Q9 and Q10).
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /** The order of the list's groups: work in progress first, then what is stuck. */
357
+ export const STATUS_GROUP_ORDER: readonly StatusBucket[] = ["in_progress", "blocked", "ready", "closed"];
358
+
359
+ export interface BeadGroup {
360
+ bucket: StatusBucket;
361
+ /** The chip's words, so a group header and its rows' chips read the same. */
362
+ label: string;
363
+ tone: Tone;
364
+ /** Every bead of the group, even when the list limit shows fewer. */
365
+ total: number;
366
+ beads: BeadRow[];
367
+ }
368
+
369
+ /**
370
+ * Splits an already filtered and sorted list into its status groups, keeping
371
+ * the order inside each group. Empty groups are dropped, and so is Closed while
372
+ * closed beads are hidden. The list limit is spent in display order: a group
373
+ * the limit leaves empty is dropped too, and its beads count as truncated.
374
+ */
375
+ export function groupBeads(
376
+ beads: readonly BeadRow[],
377
+ options: { showClosed: boolean; limit: number },
378
+ ): { groups: BeadGroup[]; visible: number; closed: number; truncated: number } {
379
+ const byBucket = new Map<StatusBucket, BeadRow[]>(STATUS_GROUP_ORDER.map((bucket) => [bucket, []]));
380
+ for (const bead of beads) byBucket.get(statusBucket(bead))!.push(bead);
381
+ const closed = byBucket.get("closed")!.length;
382
+ const groups: BeadGroup[] = [];
383
+ let visible = 0;
384
+ let budget = options.limit;
385
+ for (const bucket of STATUS_GROUP_ORDER) {
386
+ if (bucket === "closed" && !options.showClosed) continue;
387
+ const all = byBucket.get(bucket)!;
388
+ visible += all.length;
389
+ const taken = all.slice(0, Math.max(0, budget));
390
+ budget -= taken.length;
391
+ if (taken.length === 0) continue;
392
+ groups.push({ bucket, label: STATUS_TEXT[bucket], tone: STATUS_TONE[bucket], total: all.length, beads: taken });
393
+ }
394
+ const drawn = groups.reduce((sum, group) => sum + group.beads.length, 0);
395
+ return { groups, visible, closed, truncated: visible - drawn };
396
+ }
397
+
398
+ /** One entry of the Beads list: a group's title line, or a bead row. */
399
+ export type BeadListItem =
400
+ | { kind: "group"; key: string; label: string; total: number; tone: Tone }
401
+ | { kind: "bead"; key: string; bead: BeadRow };
402
+
403
+ /**
404
+ * The grouped list as one flat run of items: each group's title, then its
405
+ * beads (delta 20260918f F9). Drawn as siblings under one parent and keyed by
406
+ * bead id, a bead that moves to another group after a refresh keeps its row,
407
+ * so an open detail and the result of an action stay put. Order, empty groups
408
+ * and the row limit are exactly those of `groupBeads`.
409
+ */
410
+ export function beadListItems(grouped: { groups: readonly BeadGroup[] }): BeadListItem[] {
411
+ return grouped.groups.flatMap((group): BeadListItem[] => [
412
+ { kind: "group", key: `group:${group.bucket}`, label: group.label, total: group.total, tone: group.tone },
413
+ ...group.beads.map((bead): BeadListItem => ({ kind: "bead", key: bead.id, bead })),
414
+ ]);
415
+ }
416
+
417
+ /** A boolean that outlives the screen but not the app session: one per loaded client bundle. */
418
+ export interface SessionToggle {
419
+ get(): boolean;
420
+ set(value: boolean): void;
421
+ subscribe(listener: () => void): () => void;
422
+ }
423
+
424
+ export function createSessionToggle(initial: boolean): SessionToggle {
425
+ let value = initial;
426
+ const listeners = new Set<() => void>();
427
+ return {
428
+ get: () => value,
429
+ set(next) {
430
+ value = next;
431
+ for (const listener of listeners) listener();
432
+ },
433
+ subscribe(listener) {
434
+ listeners.add(listener);
435
+ return () => {
436
+ listeners.delete(listener);
437
+ };
438
+ },
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Whether the Beads screen shows closed beads. Hidden by default, remembered
444
+ * while the app runs, back to hidden after a reload (owner decision Q9). The
445
+ * screen on the surface and the one in the "Beads" tab share it.
446
+ */
447
+ export const closedBeadsVisibility = createSessionToggle(false);
448
+
449
+ export function toggle(set: ReadonlySet<string>, value: string): Set<string> {
450
+ const next = new Set(set);
451
+ if (next.has(value)) next.delete(value);
452
+ else next.add(value);
453
+ return next;
454
+ }
455
+
456
+ // ---------------------------------------------------------------------------
457
+ // Actions.
458
+ // ---------------------------------------------------------------------------
459
+
460
+ export interface ActionSpec {
461
+ label: string;
462
+ title: string;
463
+ body: string;
464
+ confirmLabel: string;
465
+ danger: boolean;
466
+ }
467
+
468
+ export function actionSpec(action: BeadAction, bead: Pick<BeadRow, "id" | "status">): ActionSpec {
469
+ switch (action) {
470
+ case "implement":
471
+ return {
472
+ label: "Assign a Worker",
473
+ title: `Assign ${bead.id} to a Worker?`,
474
+ body: "The Beads Manager creates a Worker that implements this bead until it is closed with evidence. This uses model quota.",
475
+ confirmLabel: "Yes, assign a Worker",
476
+ danger: false,
477
+ };
478
+ case "delete":
479
+ return {
480
+ label: "Delete",
481
+ title: `Ask a Worker to delete ${bead.id}?`,
482
+ body: "A Worker first checks whether this bead is still needed. It deletes the bead only if it is not, and tells you why either way.",
483
+ confirmLabel: "Yes, assess and delete",
484
+ danger: true,
485
+ };
486
+ case "close":
487
+ return {
488
+ label: "Close",
489
+ title: `Ask a Worker to close ${bead.id}?`,
490
+ body: "A Worker checks whether the acceptance criteria are met. It closes the bead with evidence only if they are, otherwise it reports what is missing.",
491
+ confirmLabel: "Yes, check and close",
492
+ danger: false,
493
+ };
494
+ }
495
+ }
496
+
497
+ /** Which actions make sense for a bead. A closed bead cannot be closed again. */
498
+ export function actionsFor(bead: Pick<BeadRow, "status">): BeadAction[] {
499
+ return bead.status === "closed" ? ["delete"] : ["implement", "close", "delete"];
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Who is working on an in-progress bead.
504
+ // ---------------------------------------------------------------------------
505
+
506
+ type WorkMark = NonNullable<BeadWork["started"]>;
507
+
508
+ /** Local `DD/MM HH:MM`: the screen is read by the person at this machine. */
509
+ export function formatClock(at: string): string {
510
+ const date = new Date(at);
511
+ if (Number.isNaN(date.getTime())) return at;
512
+ const two = (value: number) => String(value).padStart(2, "0");
513
+ return `${two(date.getDate())}/${two(date.getMonth() + 1)} ${two(date.getHours())}:${two(date.getMinutes())}`;
514
+ }
515
+
516
+ function workerName(mark: WorkMark): string {
517
+ return mark.title ?? `Worker ${mark.agentId.slice(0, 8)}`;
518
+ }
519
+
520
+ function agentState(mark: WorkMark): string {
521
+ return mark.status === null ? "no longer in Paseo" : mark.status;
522
+ }
523
+
524
+ export interface WorkSummary {
525
+ /** One line for the list row. */
526
+ headline: string;
527
+ /** Lines for the open bead. */
528
+ lines: string[];
529
+ /** The Worker to open: the one that started it, else the last one seen. */
530
+ agentId: string | null;
531
+ /** Tone of the headline: running work reads differently from a stalled bead. */
532
+ tone: Badge["tone"];
533
+ }
534
+
535
+ /**
536
+ * What the Beads screen says about an in-progress bead. Never guesses: a start
537
+ * time is shown only when a Worker's command set the status.
538
+ */
539
+ export function workSummary(bead: Pick<BeadRow, "status" | "work">, now: Date): WorkSummary | null {
540
+ if (bead.status !== "in_progress") return null;
541
+ const { started = null, last = null } = bead.work ?? {};
542
+ const current = started !== null && (last === null || last.agentId === started.agentId || last.at <= started.at) ? started : last;
543
+ if (current === null) {
544
+ return {
545
+ headline: "In progress · no Worker recorded on it",
546
+ lines: ["No Worker command or report named this bead since paseo-bm started recording."],
547
+ agentId: null,
548
+ tone: "muted",
549
+ };
550
+ }
551
+ // The start belongs to whoever started it; a Worker that picked the bead up
552
+ // later is shown with its own last activity instead.
553
+ const since =
554
+ current === started
555
+ ? `since ${formatClock(started.at)} (${formatDuration(Math.max(0, now.getTime() - Date.parse(started.at)))})`
556
+ : started === null
557
+ ? "start not recorded"
558
+ : `last active ${formatClock(current.at)}`;
559
+ const lines: string[] = [];
560
+ lines.push(
561
+ started === null
562
+ ? "Started: not recorded (the status was set before recording, or not with a br command)."
563
+ : `Started ${formatClock(started.at)} by ${workerName(started)}.`,
564
+ );
565
+ if (last !== null) lines.push(`Last activity ${formatClock(last.at)} by ${workerName(last)}.`);
566
+ lines.push(`${workerName(current)} is ${agentState(current)} now.`);
567
+ return {
568
+ headline: `${workerName(current)} · ${since} · ${agentState(current)}`,
569
+ lines,
570
+ agentId: current.agentId,
571
+ tone: current.status === "running" ? "info" : current.status === null ? "warning" : "muted",
572
+ };
573
+ }
574
+
575
+ /** A bead as a chat chip: its title first, then its id. */
576
+ export function beadChipText(bead: Pick<BeadRow, "id" | "title">, maxTitle = 48): string {
577
+ const title = bead.title?.trim() ?? "";
578
+ if (title === "") return bead.id;
579
+ const short = title.length > maxTitle ? `${title.slice(0, maxTitle - 1)}…` : title;
580
+ return `${short} · ${bead.id}`;
581
+ }