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,342 @@
1
+ /**
2
+ * Read-only reader for a workspace's bead store (WP-208, Dashboard Design §10).
3
+ *
4
+ * `.beads/issues.jsonl` at the repository root is the committed source of truth
5
+ * for `br`, so the Dashboard reads exactly that file and nothing else:
6
+ *
7
+ * - no `br` subprocess — the daemon's `PATH` is not ours to assume, and
8
+ * spawning processes from a plugin is a trust boundary we do not need;
9
+ * - no `beads.db` — that is a local cache and `br` holds locks on it;
10
+ * - no writes, ever. `br` owns this data (AGENTS.md).
11
+ *
12
+ * "Ready" is computed the way `br ready` defines it: open, and every `blocks`
13
+ * dependency closed. `parent-child` edges do not block — getting that wrong
14
+ * produces a number that looks plausible and is wrong, which is worse than an
15
+ * obvious failure.
16
+ */
17
+ import { lstatSync, readFileSync, statSync } from "node:fs";
18
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
19
+ import { DashboardError, type BeadStats } from "../shared/contracts";
20
+
21
+ /** Largest bead store this reads in one go (REQ-049a, Q-034). */
22
+ export const MAX_BEADS_FILE_BYTES = 32 * 1024 * 1024;
23
+
24
+ /** Path of the bead store inside a workspace. */
25
+ export const BEADS_RELATIVE_PATH = join(".beads", "issues.jsonl");
26
+
27
+ /** One bead, reduced to what the Dashboard needs. */
28
+ export interface BeadRecord {
29
+ id: string;
30
+ title: string | null;
31
+ status: string;
32
+ /** `task`, `bug`, `epic`, …; epics are containers and are never "ready". */
33
+ issueType: string;
34
+ updatedAt: string;
35
+ labels: string[];
36
+ /** Ids this bead is blocked by (`blocks` dependencies only). */
37
+ blockedBy: string[];
38
+ priority: number | null;
39
+ createdAt: string | null;
40
+ closedAt: string | null;
41
+ closeReason: string | null;
42
+ description: string | null;
43
+ /** The `parent-child` parent, when the bead has one. */
44
+ parentId: string | null;
45
+ }
46
+
47
+ export interface ReadBeadsResult {
48
+ beads: Map<string, BeadRecord>;
49
+ skippedLines: number;
50
+ present: boolean;
51
+ source: string;
52
+ readAt: string;
53
+ }
54
+
55
+ interface CacheEntry {
56
+ mtimeMs: number;
57
+ size: number;
58
+ result: ReadBeadsResult;
59
+ }
60
+
61
+ const cache = new Map<string, CacheEntry>();
62
+
63
+ /** Drops the bead-store cache. Test helper and post-mutation escape hatch. */
64
+ export function clearBeadsCache(): void {
65
+ cache.clear();
66
+ }
67
+
68
+ function unreadable(detail: string, cause?: unknown): DashboardError {
69
+ return new DashboardError("E_BEADS_STORE_UNREADABLE", detail, cause ? { cause } : undefined);
70
+ }
71
+
72
+ /**
73
+ * Resolves `<workspace>/.beads/issues.jsonl`, refusing to leave the workspace.
74
+ *
75
+ * Same no-follow principle as the trace store (design §3.8) and the installer's
76
+ * `src/paths-guard.ts`: a symlinked `.beads` or `issues.jsonl` could point at
77
+ * any file on the machine, and reading it would leak whatever it holds into the
78
+ * Dashboard.
79
+ */
80
+ export function beadsStorePath(workspaceDirectory: string): string {
81
+ if (!isAbsolute(workspaceDirectory)) {
82
+ throw unreadable(`workspace directory must be absolute: ${workspaceDirectory}`);
83
+ }
84
+ const root = resolve(workspaceDirectory);
85
+ const target = resolve(join(root, BEADS_RELATIVE_PATH));
86
+ const rel = relative(root, target);
87
+ if (rel.startsWith("..") || isAbsolute(rel)) {
88
+ throw unreadable(`bead store path escapes the workspace: ${target}`);
89
+ }
90
+
91
+ let current = root;
92
+ for (const part of rel.split(sep)) {
93
+ if (part === "" || part === ".") continue;
94
+ current = join(current, part);
95
+ try {
96
+ if (lstatSync(current).isSymbolicLink()) {
97
+ throw unreadable(`refusing to follow a symlinked bead store path: ${current}`);
98
+ }
99
+ } catch (error) {
100
+ if (error instanceof DashboardError) throw error;
101
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return target;
102
+ throw unreadable(`cannot inspect ${current}`, error);
103
+ }
104
+ }
105
+ return target;
106
+ }
107
+
108
+ function asStringArray(value: unknown): string[] {
109
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
110
+ }
111
+
112
+ function parentOf(value: unknown): string | null {
113
+ if (!Array.isArray(value)) return null;
114
+ for (const entry of value) {
115
+ if (typeof entry !== "object" || entry === null) continue;
116
+ const dep = entry as { type?: unknown; depends_on_id?: unknown };
117
+ if (dep.type === "parent-child" && typeof dep.depends_on_id === "string") return dep.depends_on_id;
118
+ }
119
+ return null;
120
+ }
121
+
122
+ const text = (value: unknown): string | null => (typeof value === "string" && value !== "" ? value : null);
123
+
124
+ function blockedByOf(value: unknown): string[] {
125
+ if (!Array.isArray(value)) return [];
126
+ const out: string[] = [];
127
+ for (const entry of value) {
128
+ if (typeof entry !== "object" || entry === null) continue;
129
+ const dep = entry as { type?: unknown; depends_on_id?: unknown };
130
+ if (dep.type !== "blocks") continue;
131
+ if (typeof dep.depends_on_id === "string") out.push(dep.depends_on_id);
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * Reads and indexes the bead store.
138
+ *
139
+ * A missing file is an empty state (`present: false`), not an error: a
140
+ * workspace without beads is a perfectly normal workspace (REQ-046d). A single
141
+ * unparseable line is skipped and counted, so one bad line cannot hide the
142
+ * other ninety-nine (REQ-046e).
143
+ */
144
+ export function readBeads(workspaceDirectory: string): ReadBeadsResult {
145
+ const path = beadsStorePath(workspaceDirectory);
146
+ const readAt = new Date().toISOString();
147
+
148
+ let stats: { mtimeMs: number; size: number };
149
+ try {
150
+ const stat = statSync(path);
151
+ stats = { mtimeMs: stat.mtimeMs, size: stat.size };
152
+ } catch (error) {
153
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
154
+ return { beads: new Map(), skippedLines: 0, present: false, source: path, readAt };
155
+ }
156
+ throw unreadable(`cannot read ${path}`, error);
157
+ }
158
+
159
+ if (stats.size > MAX_BEADS_FILE_BYTES) {
160
+ throw unreadable(
161
+ `${path} is ${stats.size} bytes, over the ${MAX_BEADS_FILE_BYTES}-byte limit this reader accepts`,
162
+ );
163
+ }
164
+
165
+ const cached = cache.get(path);
166
+ if (cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
167
+ return { ...cached.result, readAt };
168
+ }
169
+
170
+ let body: string;
171
+ try {
172
+ body = readFileSync(path, "utf8");
173
+ } catch (error) {
174
+ throw unreadable(`cannot read ${path}`, error);
175
+ }
176
+
177
+ const beads = new Map<string, BeadRecord>();
178
+ let skippedLines = 0;
179
+ for (const line of body.split("\n")) {
180
+ if (line.trim() === "") continue;
181
+ let parsed: unknown;
182
+ try {
183
+ parsed = JSON.parse(line) as unknown;
184
+ } catch {
185
+ skippedLines += 1;
186
+ continue;
187
+ }
188
+ if (typeof parsed !== "object" || parsed === null) {
189
+ skippedLines += 1;
190
+ continue;
191
+ }
192
+ const row = parsed as Record<string, unknown>;
193
+ const id = row["id"];
194
+ const status = row["status"];
195
+ if (typeof id !== "string" || id === "" || typeof status !== "string") {
196
+ skippedLines += 1;
197
+ continue;
198
+ }
199
+ const record: BeadRecord = {
200
+ id,
201
+ title: typeof row["title"] === "string" ? (row["title"] as string) : null,
202
+ status,
203
+ issueType: typeof row["issue_type"] === "string" ? (row["issue_type"] as string) : "task",
204
+ updatedAt: typeof row["updated_at"] === "string" ? (row["updated_at"] as string) : "",
205
+ labels: asStringArray(row["labels"]),
206
+ blockedBy: blockedByOf(row["dependencies"]),
207
+ // `br` writes priority as a number; some exports write it as a string.
208
+ priority: Number.isFinite(Number(row["priority"])) && row["priority"] !== null && row["priority"] !== "" ? Number(row["priority"]) : null,
209
+ createdAt: text(row["created_at"]),
210
+ closedAt: text(row["closed_at"]),
211
+ closeReason: text(row["close_reason"]),
212
+ description: text(row["description"]),
213
+ parentId: parentOf(row["dependencies"]),
214
+ };
215
+ // Several lines can carry the same id; the latest `updated_at` wins.
216
+ const existing = beads.get(id);
217
+ if (existing === undefined || record.updatedAt >= existing.updatedAt) beads.set(id, record);
218
+ }
219
+
220
+ const result: ReadBeadsResult = { beads, skippedLines, present: true, source: path, readAt };
221
+ cache.set(path, { ...stats, result });
222
+ return result;
223
+ }
224
+
225
+ /**
226
+ * Startable right now, matching what `br ready` lists: open, every `blocks`
227
+ * dependency closed, and **not an epic**.
228
+ *
229
+ * Two rules here were derived by comparing against two real stores rather than
230
+ * from the field names:
231
+ *
232
+ * - **Epics are excluded.** They are containers; `br ready` never offers one as
233
+ * work. Counting them produced 12 ready where `br` reported 3.
234
+ * - **A dependency pointing at an id that is not in the store counts as NOT
235
+ * closed.** Treating an unknown edge as satisfied would advertise work as
236
+ * startable when nobody can tell whether it is.
237
+ */
238
+ export function isReady(bead: BeadRecord, beads: Map<string, BeadRecord>): boolean {
239
+ if (bead.status === "closed" || bead.status === "in_progress") return false;
240
+ if (bead.issueType === "epic") return false;
241
+ return bead.blockedBy.every((id) => beads.get(id)?.status === "closed");
242
+ }
243
+
244
+ /**
245
+ * Counts for one workspace, shaped for the `beads.stats` RPC (REQ-046a).
246
+ *
247
+ * The buckets follow `br stats`, which was checked against two real stores
248
+ * while this was written, and they are not all statuses:
249
+ *
250
+ * - `total = open + inProgress + closed`;
251
+ * - **`ready` is derived**: an open non-epic bead whose every `blocks`
252
+ * dependency is closed;
253
+ * - **`blocked` is derived** as the rest of the open work (`open - ready`),
254
+ * which is why an open epic counts as blocked;
255
+ * - so `open = ready + blocked`.
256
+ *
257
+ * Reading `blocked` off a `status` field instead would report 0 on a store
258
+ * where `br` reports 22 — a number that looks fine and is wrong.
259
+ */
260
+ export function beadStats(workspaceDirectory: string): BeadStats {
261
+ const { beads, skippedLines, present, source, readAt } = readBeads(workspaceDirectory);
262
+ let open = 0;
263
+ let inProgress = 0;
264
+ let blocked = 0;
265
+ let closed = 0;
266
+ let ready = 0;
267
+ for (const bead of beads.values()) {
268
+ switch (bead.status) {
269
+ case "closed":
270
+ closed += 1;
271
+ break;
272
+ case "in_progress":
273
+ inProgress += 1;
274
+ break;
275
+ case "blocked":
276
+ // `br` derives this bucket, but honour an explicit status too.
277
+ blocked += 1;
278
+ break;
279
+ default:
280
+ // Everything else is open work: `open` is the bucket `br` reports, and
281
+ // it splits into ready and blocked.
282
+ open += 1;
283
+ if (isReady(bead, beads)) ready += 1;
284
+ else blocked += 1;
285
+ break;
286
+ }
287
+ }
288
+ return {
289
+ total: beads.size,
290
+ open,
291
+ inProgress,
292
+ blocked,
293
+ closed,
294
+ ready,
295
+ readAt,
296
+ source,
297
+ skippedLines,
298
+ present,
299
+ };
300
+ }
301
+
302
+ /** One bead as the trace detail shows it (REQ-044a). */
303
+ export interface BeadLookup {
304
+ id: string;
305
+ title: string | null;
306
+ status: string;
307
+ /** `updated_at` from the store; "" when the store has none. */
308
+ updatedAt: string;
309
+ }
310
+
311
+ /**
312
+ * Looks up the beads a trace reported, by id.
313
+ *
314
+ * Ids that are no longer in the store come back in `missing` rather than
315
+ * throwing: a bead can legitimately be gone (another repo, renamed prefix,
316
+ * store not committed yet), and the trace must still render — the UI marks it
317
+ * "not in store" (REQ-044d).
318
+ */
319
+ export function lookupBeads(
320
+ workspaceDirectory: string,
321
+ ids: readonly string[],
322
+ ): { found: BeadLookup[]; missing: string[] } {
323
+ if (ids.length === 0) return { found: [], missing: [] };
324
+ let beads: Map<string, BeadRecord>;
325
+ try {
326
+ beads = readBeads(workspaceDirectory).beads;
327
+ } catch {
328
+ // An unreadable store must not break a trace; every id is simply unknown.
329
+ return { found: [], missing: [...ids] };
330
+ }
331
+ const found: BeadLookup[] = [];
332
+ const missing: string[] = [];
333
+ for (const id of ids) {
334
+ const bead = beads.get(id);
335
+ if (bead === undefined) {
336
+ missing.push(id);
337
+ continue;
338
+ }
339
+ found.push({ id: bead.id, title: bead.title, status: bead.status, updatedAt: bead.updatedAt });
340
+ }
341
+ return { found, missing };
342
+ }