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,40 @@
1
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
2
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
3
+ import { searchRpc } from "../shared/rpc";
4
+ import { clampSearchLimit, runBvJson, sanitizeSearchQuery } from "./bv";
5
+ import { normalizeSearchResults } from "./normalize";
6
+ import { resolveWorkspaceTarget } from "./workspace";
7
+
8
+ type SearchOutput = RpcOutput<typeof searchRpc>;
9
+
10
+ export async function searchIssues(
11
+ input: RpcInput<typeof searchRpc>,
12
+ context: PluginHandlerContext,
13
+ ): Promise<SearchOutput> {
14
+ const limit = clampSearchLimit(input.limit);
15
+ const query = sanitizeSearchQuery(input.query);
16
+ if (query === null) {
17
+ return {
18
+ query: "",
19
+ limit,
20
+ results: [],
21
+ error: { code: "internal", message: "Enter at least one searchable character.", exitCode: null },
22
+ };
23
+ }
24
+
25
+ const workspace = await resolveWorkspaceTarget(context, input.workspaceId);
26
+ if (!workspace.ok) {
27
+ return { query, limit, results: [], error: workspace.error };
28
+ }
29
+
30
+ const result = await runBvJson(
31
+ "search",
32
+ workspace.value.directory,
33
+ (payload) => normalizeSearchResults(payload, limit),
34
+ { query, limit },
35
+ );
36
+ if (!result.ok) {
37
+ return { query, limit, results: [], error: result.error };
38
+ }
39
+ return { query, limit, results: result.value, error: null };
40
+ }
@@ -0,0 +1,122 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import type { TrackerKind, TrackerState } from "../shared/beads";
4
+ import { runBvJson } from "./bv";
5
+ import { ExpiringCache } from "./cache";
6
+ import { normalizeSource } from "./normalize";
7
+ import { detectTracker, readTrackerMetadata, UNKNOWN_TRACKER } from "./workspace";
8
+
9
+ const TRACKER_TTL_MS = 120_000;
10
+
11
+ /** Exact live tracker route established from bv's selected source and metadata. */
12
+ export interface TrackerRoute {
13
+ readonly kind: TrackerKind;
14
+ readonly beadsDirectory: string;
15
+ readonly database: string;
16
+ }
17
+
18
+ export interface TrackerResolution {
19
+ readonly state: TrackerState;
20
+ readonly route: TrackerRoute | null;
21
+ }
22
+
23
+ const UNKNOWN_RESOLUTION: TrackerResolution = { state: UNKNOWN_TRACKER, route: null };
24
+ const trackerCache = new ExpiringCache<TrackerResolution>(TRACKER_TTL_MS);
25
+ const trackerReads = new Map<string, Promise<TrackerResolution>>();
26
+
27
+ export function clearTrackerCache(): void {
28
+ trackerCache.clear();
29
+ trackerReads.clear();
30
+ }
31
+
32
+ function trackerCacheKey(workspaceId: string, directory: string): string {
33
+ return `${workspaceId}\0${directory}`;
34
+ }
35
+
36
+ export function rememberTracker(workspaceId: string, directory: string, resolution: TrackerResolution): void {
37
+ if (resolution.state.kind !== null) trackerCache.set(trackerCacheKey(workspaceId, directory), resolution);
38
+ }
39
+
40
+ async function buildTrackerRoute(
41
+ kind: TrackerKind,
42
+ payload: unknown,
43
+ directory: string,
44
+ ): Promise<TrackerRoute | null> {
45
+ const metadata = await readTrackerMetadata(payload, directory);
46
+ if (metadata === null) return null;
47
+
48
+ const source = normalizeSource(payload);
49
+ if (source?.sourcePath === null || source?.sourcePath === undefined || !isAbsolute(source.sourcePath)) return null;
50
+ const selectedSource = resolve(source.sourcePath);
51
+ const resolveMetadataPath = (value: string | null): string | null =>
52
+ value === null ? null : isAbsolute(value) ? resolve(value) : resolve(metadata.beadsDirectory, value);
53
+
54
+ let database: string | null;
55
+ if (kind === "bd") {
56
+ if (metadata.backend !== "dolt" || selectedSource !== resolve(metadata.beadsDirectory, "issues.jsonl")) return null;
57
+ database = metadata.beadsDirectory;
58
+ } else {
59
+ if (metadata.backend === "dolt") return null;
60
+ database = resolveMetadataPath(metadata.database);
61
+ const jsonlExport = resolveMetadataPath(metadata.jsonlExport);
62
+ if (database === null || (selectedSource !== database && selectedSource !== jsonlExport)) return null;
63
+ }
64
+ if (database.includes("\0") || !isAbsolute(database)) return null;
65
+
66
+ try {
67
+ const info = await stat(database);
68
+ if (kind === "bd" ? !info.isDirectory() : !info.isFile()) return null;
69
+ } catch {
70
+ return null;
71
+ }
72
+
73
+ return { kind, beadsDirectory: metadata.beadsDirectory, database };
74
+ }
75
+
76
+ /**
77
+ * Establishes both tracker identity and the exact database route. Detail reads
78
+ * fail closed when metadata cannot bind the CLI to bv's selected live source.
79
+ */
80
+ export async function resolveTrackerFromPayload(payload: unknown, directory: string): Promise<TrackerResolution> {
81
+ const detected = await detectTracker(payload, directory);
82
+ if (detected.kind === null || !detected.available) return { state: detected, route: null };
83
+
84
+ const route = await buildTrackerRoute(detected.kind, payload, directory);
85
+ if (route === null) {
86
+ return {
87
+ state: {
88
+ kind: detected.kind,
89
+ available: false,
90
+ detail: `The ${detected.kind} CLI is installed, but its exact read-only database route could not be established.`,
91
+ },
92
+ route: null,
93
+ };
94
+ }
95
+ return { state: detected, route };
96
+ }
97
+
98
+ /** Resolves and caches the exact read-only tracker route for one workspace. */
99
+ export async function resolveTracker(workspaceId: string, directory: string): Promise<TrackerResolution> {
100
+ const key = trackerCacheKey(workspaceId, directory);
101
+ const cached = trackerCache.get(key);
102
+ if (cached !== null) return cached;
103
+
104
+ const active = trackerReads.get(key);
105
+ if (active !== undefined) return await active;
106
+
107
+ const read = (async () => {
108
+ const triage = await runBvJson("triage", directory, (payload) => payload);
109
+ if (!triage.ok) {
110
+ return { ...UNKNOWN_RESOLUTION, state: { ...UNKNOWN_TRACKER, detail: triage.error.message } };
111
+ }
112
+ const resolution = await resolveTrackerFromPayload(triage.value, directory);
113
+ rememberTracker(workspaceId, directory, resolution);
114
+ return resolution;
115
+ })();
116
+ trackerReads.set(key, read);
117
+ try {
118
+ return await read;
119
+ } finally {
120
+ trackerReads.delete(key);
121
+ }
122
+ }
@@ -0,0 +1,153 @@
1
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import type { TrackerKind, TrackerState } from "../shared/beads";
5
+ import { failure, resolveExecutable, type CommandResult } from "./command";
6
+ import { asRecord, normalizeSource } from "./normalize";
7
+
8
+ export interface WorkspaceTarget {
9
+ readonly id: string;
10
+ readonly name: string;
11
+ readonly directory: string;
12
+ }
13
+
14
+ /**
15
+ * Every subprocess cwd comes from Paseo's own workspace record, never from
16
+ * client input. A workspace without a usable absolute directory is rejected.
17
+ */
18
+ export async function resolveWorkspaceTarget(
19
+ context: PluginHandlerContext,
20
+ workspaceId: string,
21
+ ): Promise<CommandResult<WorkspaceTarget>> {
22
+ let directory: string | null = null;
23
+ let name: string | null = null;
24
+ try {
25
+ const workspace = await context.paseo.workspaces.ref(workspaceId).refresh();
26
+ if (workspace === null) {
27
+ return failure("workspace_unresolved", `Paseo does not know workspace ${workspaceId}.`);
28
+ }
29
+ directory = workspace.workspaceDirectory;
30
+ name = workspace.title ?? workspace.name;
31
+ } catch (error) {
32
+ const message = error instanceof Error ? error.message : "workspace lookup failed";
33
+ return failure("workspace_unresolved", `Could not resolve workspace ${workspaceId}: ${message}`);
34
+ }
35
+
36
+ if (directory === null || directory.length === 0 || !isAbsolute(directory)) {
37
+ return failure("cwd_invalid", `Workspace ${workspaceId} has no absolute directory to run bv in.`);
38
+ }
39
+ try {
40
+ const info = await stat(directory);
41
+ if (!info.isDirectory()) {
42
+ return failure("cwd_invalid", `Workspace path ${directory} is not a directory.`);
43
+ }
44
+ } catch {
45
+ return failure("cwd_invalid", `Workspace path ${directory} is not reachable on the daemon.`);
46
+ }
47
+
48
+ return { ok: true, value: { id: workspaceId, name: name ?? workspaceId, directory } };
49
+ }
50
+
51
+ export const UNKNOWN_TRACKER: TrackerState = {
52
+ kind: null,
53
+ available: false,
54
+ detail: "No Beads tracker CLI (br or bd) could be established for this workspace.",
55
+ };
56
+
57
+ /**
58
+ * `bv --robot-triage` names the tracker that owns the project in each
59
+ * recommendation's `actions.tracker`. That is the authoritative signal; the
60
+ * installed-binary check is only a fallback and stays inconclusive when both
61
+ * `br` and `bd` are present.
62
+ */
63
+ export function readTrackerFromTriage(payload: unknown): TrackerKind | null {
64
+ const triage = asRecord(asRecord(payload)?.["triage"]);
65
+ if (triage === null) return null;
66
+ const quickRef = asRecord(triage["quick_ref"]);
67
+ const groups: readonly unknown[] = [
68
+ triage["recommendations"],
69
+ quickRef?.["top_picks"],
70
+ triage["quick_wins"],
71
+ ];
72
+ for (const group of groups) {
73
+ if (!Array.isArray(group)) continue;
74
+ for (const entry of group) {
75
+ const actions = asRecord(asRecord(entry)?.["actions"]);
76
+ const tracker = actions?.["tracker"];
77
+ if (tracker === "br" || tracker === "bd") return tracker;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ export interface TrackerMetadata {
84
+ readonly beadsDirectory: string;
85
+ readonly backend: string;
86
+ readonly database: string | null;
87
+ readonly jsonlExport: string | null;
88
+ }
89
+
90
+ /** Reads only tracker routing metadata, preferring bv's selected source over cwd discovery. */
91
+ export async function readTrackerMetadata(payload: unknown, directory: string): Promise<TrackerMetadata | null> {
92
+ const sourcePath = normalizeSource(payload)?.sourcePath ?? null;
93
+ const candidates = [
94
+ sourcePath !== null && isAbsolute(sourcePath) ? dirname(sourcePath) : null,
95
+ join(directory, ".beads"),
96
+ ].filter((candidate): candidate is string => candidate !== null);
97
+
98
+ for (const beadsDirectory of [...new Set(candidates)]) {
99
+ try {
100
+ const raw = await readFile(join(beadsDirectory, "metadata.json"), "utf8");
101
+ const record = asRecord(JSON.parse(raw) as unknown);
102
+ if (record === null) continue;
103
+ const backend = typeof record["backend"] === "string" ? record["backend"].trim().toLowerCase() : "";
104
+ const database = typeof record["database"] === "string" ? record["database"].trim() : null;
105
+ const jsonlExport = typeof record["jsonl_export"] === "string" ? record["jsonl_export"].trim() : null;
106
+ return {
107
+ beadsDirectory,
108
+ backend,
109
+ database: database === "" ? null : database,
110
+ jsonlExport: jsonlExport === "" ? null : jsonlExport,
111
+ };
112
+ } catch {
113
+ // Try the cwd fallback. Redirected/worktree sources normally succeed above.
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function trackerFromMetadata(metadata: TrackerMetadata | null): TrackerKind | null {
120
+ if (metadata === null) return null;
121
+ if (metadata.backend === "dolt") return "bd";
122
+ const database = metadata.database?.toLowerCase() ?? "";
123
+ if (metadata.backend === "sqlite" || /\.(db|sqlite|sqlite3)$/.test(database)) return "br";
124
+ return null;
125
+ }
126
+
127
+ export async function detectTracker(payload: unknown, directory?: string): Promise<TrackerState> {
128
+ const metadata = directory === undefined ? null : await readTrackerMetadata(payload, directory);
129
+ const declared = readTrackerFromTriage(payload) ?? trackerFromMetadata(metadata);
130
+ if (declared !== null) {
131
+ const executable = await resolveExecutable(declared);
132
+ if (executable === null) {
133
+ return {
134
+ kind: declared,
135
+ available: false,
136
+ detail: `This project uses ${declared}, but ${declared} is not on the daemon PATH.`,
137
+ };
138
+ }
139
+ return { kind: declared, available: true, detail: null };
140
+ }
141
+
142
+ const [br, bd] = await Promise.all([resolveExecutable("br"), resolveExecutable("bd")]);
143
+ if (br !== null && bd === null) return { kind: "br", available: true, detail: "Inferred from the installed CLI." };
144
+ if (bd !== null && br === null) return { kind: "bd", available: true, detail: "Inferred from the installed CLI." };
145
+ if (br !== null && bd !== null) {
146
+ return {
147
+ kind: null,
148
+ available: false,
149
+ detail: "Both br and bd are installed and bv did not name the project tracker, so detail reads are disabled.",
150
+ };
151
+ }
152
+ return UNKNOWN_TRACKER;
153
+ }
@@ -0,0 +1,325 @@
1
+ import { z } from "zod";
2
+
3
+ /** Fixed set of command failure codes surfaced to the client. */
4
+ export const CommandErrorCodeSchema = z.enum([
5
+ "unavailable",
6
+ "timeout",
7
+ "exit",
8
+ "output_limit",
9
+ "invalid_json",
10
+ "cwd_invalid",
11
+ "workspace_unresolved",
12
+ "tracker_unknown",
13
+ "internal",
14
+ ]);
15
+ export type CommandErrorCode = z.output<typeof CommandErrorCodeSchema>;
16
+
17
+ export const CommandErrorSchema = z.object({
18
+ code: CommandErrorCodeSchema,
19
+ message: z.string(),
20
+ exitCode: z.number().nullable(),
21
+ });
22
+ export type CommandError = z.output<typeof CommandErrorSchema>;
23
+
24
+ /** Per-section degradation so one missing analysis never fails the dashboard. */
25
+ export const SectionStateSchema = z.object({
26
+ status: z.enum(["ok", "unavailable"]),
27
+ error: CommandErrorSchema.nullable(),
28
+ });
29
+ export type SectionState = z.output<typeof SectionStateSchema>;
30
+
31
+ /**
32
+ * Beads statuses, readiness, and authority states are treated as opaque strings.
33
+ * Unknown values from a newer `bv` must render, not fail validation.
34
+ */
35
+ export const SourceAuthoritySchema = z.object({
36
+ state: z.string(),
37
+ readiness: z.string(),
38
+ claimSafe: z.boolean(),
39
+ stale: z.boolean(),
40
+ loaded: z.number(),
41
+ failed: z.number(),
42
+ valid: z.number(),
43
+ visible: z.number(),
44
+ tombstones: z.number(),
45
+ warnings: z.array(z.string()),
46
+ });
47
+ export type SourceAuthority = z.output<typeof SourceAuthoritySchema>;
48
+
49
+ export const SourceSnapshotSchema = z.object({
50
+ generatedAt: z.string().nullable(),
51
+ dataHash: z.string().nullable(),
52
+ sourcePath: z.string().nullable(),
53
+ sourceKind: z.string().nullable(),
54
+ toolVersion: z.string().nullable(),
55
+ authority: SourceAuthoritySchema.nullable(),
56
+ });
57
+ export type SourceSnapshot = z.output<typeof SourceSnapshotSchema>;
58
+
59
+ /**
60
+ * `bv`'s own counts, kept verbatim. Two of them do not mean what a reader
61
+ * assumes: `blocked` counts only issues whose *status* is `blocked`, and
62
+ * `actionable` includes epics with no open blocker. Dependency-blocked work is
63
+ * `waiting`, read from `project_health`; the panel's own headline counts come
64
+ * from the graph instead (see `client/project.ts`).
65
+ */
66
+ export const ProjectCountsSchema = z.object({
67
+ open: z.number(),
68
+ actionable: z.number(),
69
+ blocked: z.number(),
70
+ inProgress: z.number(),
71
+ notClosed: z.number(),
72
+ notActionable: z.number(),
73
+ total: z.number(),
74
+ /** `project_health.counts.dependency_blocked`; null when `bv` omitted it. */
75
+ waiting: z.number().nullable(),
76
+ /** `project_health.counts.closed`; null when `bv` omitted it. */
77
+ closed: z.number().nullable(),
78
+ });
79
+ export type ProjectCounts = z.output<typeof ProjectCountsSchema>;
80
+
81
+ /** Pace and structural health from `project_health`, which triage already carries. */
82
+ export const ProjectHealthSchema = z.object({
83
+ closedLast7Days: z.number().nullable(),
84
+ closedLast30Days: z.number().nullable(),
85
+ /** True when `bv` marked the velocity as an estimate. */
86
+ velocityEstimated: z.boolean(),
87
+ /** Null when `bv` did not report cycle detection. */
88
+ hasCycles: z.boolean().nullable(),
89
+ });
90
+ export type ProjectHealth = z.output<typeof ProjectHealthSchema>;
91
+
92
+ export const RecommendationSchema = z.object({
93
+ id: z.string(),
94
+ title: z.string(),
95
+ status: z.string(),
96
+ type: z.string().nullable(),
97
+ priority: z.number().nullable(),
98
+ assignee: z.string().nullable(),
99
+ labels: z.array(z.string()),
100
+ score: z.number().nullable(),
101
+ action: z.string().nullable(),
102
+ reasons: z.array(z.string()),
103
+ blockedBy: z.array(z.string()),
104
+ unblocks: z.array(z.string()),
105
+ claimable: z.boolean(),
106
+ });
107
+ export type Recommendation = z.output<typeof RecommendationSchema>;
108
+
109
+ export const BlockerSchema = z.object({
110
+ id: z.string(),
111
+ title: z.string(),
112
+ unblocksCount: z.number(),
113
+ unblocks: z.array(z.string()),
114
+ actionable: z.boolean(),
115
+ });
116
+ export type Blocker = z.output<typeof BlockerSchema>;
117
+
118
+ export const TrackItemSchema = z.object({
119
+ id: z.string(),
120
+ title: z.string(),
121
+ status: z.string(),
122
+ priority: z.number().nullable(),
123
+ unblocks: z.array(z.string()),
124
+ });
125
+
126
+ export const TrackSchema = z.object({
127
+ id: z.string(),
128
+ reason: z.string().nullable(),
129
+ items: z.array(TrackItemSchema),
130
+ /** Items `bv` listed in this track, which exceeds `items.length` when capped. */
131
+ totalItems: z.number(),
132
+ });
133
+ export type Track = z.output<typeof TrackSchema>;
134
+
135
+ export const PlanSummarySchema = z.object({
136
+ totalActionable: z.number().nullable(),
137
+ totalBlocked: z.number().nullable(),
138
+ /** Tracks `bv` reported, before the payload cap. */
139
+ totalTracks: z.number(),
140
+ highestImpact: z.string().nullable(),
141
+ impactReason: z.string().nullable(),
142
+ });
143
+ export type PlanSummary = z.output<typeof PlanSummarySchema>;
144
+
145
+ /**
146
+ * One issue from `bv --robot-graph`, which is the only `bv` read that returns
147
+ * the *whole* project rather than an analysis-selected subset. Status stays an
148
+ * opaque string; the dependencies are derived from the graph's `blocks` edges,
149
+ * where `from` is blocked by `to`. A `blocks` edge outlives the blocker being
150
+ * closed, so only blockers that are still open count.
151
+ */
152
+ export const BoardIssueSchema = z.object({
153
+ id: z.string(),
154
+ title: z.string(),
155
+ status: z.string(),
156
+ priority: z.number().nullable(),
157
+ labels: z.array(z.string()),
158
+ /** Ids of the blockers that are not closed yet, in edge order. */
159
+ blockedBy: z.array(z.string()),
160
+ /** How many issues that are not closed yet wait on this one. */
161
+ unblocksCount: z.number(),
162
+ /** Parent issue id. The graph emits `parent-child` as child → parent. */
163
+ parentId: z.string().nullable(),
164
+ /**
165
+ * Issues naming this one as parent, counted before any truncation, so an
166
+ * epic whose closed children were dropped is still known to be a container.
167
+ */
168
+ childCount: z.number(),
169
+ /** `epic`, `task`, `bug`, … from the tracker; null when the overlay is absent. */
170
+ type: z.string().nullable(),
171
+ /** From the tracker overlay; null when unassigned or the overlay is absent. */
172
+ assignee: z.string().nullable(),
173
+ });
174
+ export type BoardIssue = z.output<typeof BoardIssueSchema>;
175
+
176
+ export const BoardSnapshotSchema = z.object({
177
+ issues: z.array(BoardIssueSchema),
178
+ /** True when the tracker supplied the type/assignee overlay for these issues. */
179
+ typed: z.boolean(),
180
+ /** Issue count `bv` reported before any cap was applied. */
181
+ total: z.number(),
182
+ /** True when {@link BOARD_ISSUE_LIMIT} dropped closed issues from `issues`. */
183
+ truncated: z.boolean(),
184
+ });
185
+ export type BoardSnapshot = z.output<typeof BoardSnapshotSchema>;
186
+
187
+ /**
188
+ * Upper bound on issues carried to the client in one dashboard payload.
189
+ * Measured against a real 776-issue project carrying the tracker's type and
190
+ * assignee overlay: 215 KB normalized, 283 B per issue. This cap therefore
191
+ * costs ~553 KB in the worst case, which a phone fetches over the relay, so it
192
+ * is deliberately below what the 4 MB subprocess output cap would allow.
193
+ * Re-measure this when a field is added to {@link BoardIssueSchema}.
194
+ */
195
+ export const BOARD_ISSUE_LIMIT = 2000;
196
+
197
+ /**
198
+ * The statuses Beads itself defines, verified against `br 0.5.12`:
199
+ * "Built-in statuses: open, in_progress, blocked, deferred, draft, closed,
200
+ * tombstone, pinned", plus `hooked` from `bd`. A project may declare more in
201
+ * `.beads/policy.yaml`; those are custom, so the panel shows them verbatim and
202
+ * never guesses what they mean.
203
+ */
204
+ export const BEADS_STATUSES = {
205
+ open: "open",
206
+ inProgress: "in_progress",
207
+ hooked: "hooked",
208
+ blocked: "blocked",
209
+ deferred: "deferred",
210
+ draft: "draft",
211
+ pinned: "pinned",
212
+ closed: "closed",
213
+ tombstone: "tombstone",
214
+ } as const;
215
+
216
+ /** Finished work: closed, or deleted and kept only as a tombstone. */
217
+ const CLOSED_STATUSES: readonly string[] = [BEADS_STATUSES.closed, BEADS_STATUSES.tombstone];
218
+
219
+ export function isClosedStatus(status: string): boolean {
220
+ return CLOSED_STATUSES.includes(status.trim().toLowerCase());
221
+ }
222
+
223
+ export const AlertSchema = z.object({
224
+ type: z.string(),
225
+ severity: z.string(),
226
+ message: z.string(),
227
+ issueId: z.string().nullable(),
228
+ detectedAt: z.string().nullable(),
229
+ suggestedAction: z.string().nullable(),
230
+ labels: z.array(z.string()),
231
+ });
232
+ export type Alert = z.output<typeof AlertSchema>;
233
+
234
+ export const AlertSummarySchema = z.object({
235
+ total: z.number(),
236
+ critical: z.number(),
237
+ warning: z.number(),
238
+ info: z.number(),
239
+ });
240
+ export type AlertSummary = z.output<typeof AlertSummarySchema>;
241
+
242
+ /** `bv` is required; the per-project tracker is either `br` or `bd`. */
243
+ export const TrackerKindSchema = z.enum(["br", "bd"]);
244
+ export type TrackerKind = z.output<typeof TrackerKindSchema>;
245
+
246
+ export const ToolStateSchema = z.object({
247
+ available: z.boolean(),
248
+ version: z.string().nullable(),
249
+ error: CommandErrorSchema.nullable(),
250
+ });
251
+
252
+ export const TrackerStateSchema = z.object({
253
+ kind: TrackerKindSchema.nullable(),
254
+ available: z.boolean(),
255
+ detail: z.string().nullable(),
256
+ });
257
+ export type TrackerState = z.output<typeof TrackerStateSchema>;
258
+
259
+ export const IssueRefSchema = z.object({
260
+ id: z.string(),
261
+ title: z.string().nullable(),
262
+ status: z.string().nullable(),
263
+ relation: z.string().nullable(),
264
+ });
265
+
266
+ export const IssueCommentSchema = z.object({
267
+ id: z.string(),
268
+ author: z.string().nullable(),
269
+ text: z.string(),
270
+ createdAt: z.string().nullable(),
271
+ });
272
+
273
+ export const IssueDetailSchema = z.object({
274
+ id: z.string(),
275
+ title: z.string(),
276
+ status: z.string(),
277
+ type: z.string().nullable(),
278
+ priority: z.number().nullable(),
279
+ assignee: z.string().nullable(),
280
+ description: z.string().nullable(),
281
+ design: z.string().nullable(),
282
+ acceptanceCriteria: z.string().nullable(),
283
+ notes: z.string().nullable(),
284
+ labels: z.array(z.string()),
285
+ parent: z.string().nullable(),
286
+ dependencies: z.array(IssueRefSchema),
287
+ dependents: z.array(IssueRefSchema),
288
+ comments: z.array(IssueCommentSchema),
289
+ createdAt: z.string().nullable(),
290
+ updatedAt: z.string().nullable(),
291
+ closedAt: z.string().nullable(),
292
+ closeReason: z.string().nullable(),
293
+ });
294
+ export type IssueDetail = z.output<typeof IssueDetailSchema>;
295
+
296
+ export const SearchResultSchema = z.object({
297
+ id: z.string(),
298
+ title: z.string(),
299
+ score: z.number().nullable(),
300
+ });
301
+ export type SearchResult = z.output<typeof SearchResultSchema>;
302
+
303
+ /** Bounds shared by the client and the server so both agree on limits. */
304
+ export const SEARCH_QUERY_MAX_LENGTH = 120;
305
+ export const SEARCH_LIMIT_MIN = 1;
306
+ export const SEARCH_LIMIT_MAX = 25;
307
+ export const SEARCH_LIMIT_DEFAULT = 10;
308
+ export const ISSUE_ID_MAX_LENGTH = 128;
309
+ export const ATTACHMENT_RESULT_LIMIT = 8;
310
+
311
+ export const SearchQuerySchema = z.string().min(1).max(SEARCH_QUERY_MAX_LENGTH);
312
+ export const SearchLimitSchema = z.number().int().min(SEARCH_LIMIT_MIN).max(SEARCH_LIMIT_MAX);
313
+ export const IssueIdSchema = z
314
+ .string()
315
+ .min(1)
316
+ .max(ISSUE_ID_MAX_LENGTH)
317
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, "Beads issue ids are alphanumeric with . _ -");
318
+
319
+ export const ATTACHMENT_URL_SCHEME = "paseo-beads:";
320
+ export const ATTACHMENT_RESOURCE_TYPE = "beads_issue";
321
+
322
+ /** Stable, parseable identity for an issue inside one Paseo workspace. */
323
+ export function buildIssueUrl(workspaceId: string, issueId: string): string {
324
+ return `${ATTACHMENT_URL_SCHEME}//workspace/${encodeURIComponent(workspaceId)}/issue/${encodeURIComponent(issueId)}`;
325
+ }