@botbuddy/cli 1.67.0 → 1.68.1

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,552 @@
1
+ // BOT-2644 / BOT-3152. Read-only host adapter (`bb status-band read`), run from the
2
+ // installed CLI. Credentials stay in this process, never in the mod.
3
+ import { open, readFile, realpath } from "node:fs/promises";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ import { join } from "node:path";
7
+ const execFileAsync = promisify(execFile);
8
+ const UUID =
9
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ const TICKET = /^BOT-[1-9]\d*$/;
11
+ // The repo's committed binding picks the tenant; each tenant owns one key prefix.
12
+ const TENANTS = { botbuddy: "BOT", "supply-guard": "ENT" };
13
+ const MCP = "https://api.bot-buddy.ai/functions/v1/mcp-server/tools/";
14
+ const UUID_LOWER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
15
+ const LINEAR_PROJECT_URL = /^https:\/\/linear\.app\/[a-z0-9-]{1,64}\/project\/[a-z0-9-]{1,200}$/;
16
+ const PROJECT_IDENTIFIER = /^P-[A-Z][A-Z0-9]{1,9}-[1-9]\d{0,5}$/;
17
+ const PROJECT_STATES = ["backlog", "planned", "started", "paused", "completed", "canceled"];
18
+ const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
19
+ // Linear state type or BB work-item stage → the band's status word.
20
+ const STATUS = {
21
+ started: "In Progress",
22
+ working: "In Progress",
23
+ review: "In Review",
24
+ completed: "Done",
25
+ complete: "Done",
26
+ canceled: "Canceled",
27
+ unstarted: "Todo",
28
+ backlog: "Backlog",
29
+ planned: "Todo",
30
+ triage: "Triage",
31
+ };
32
+ const QUERY = `query BbClaudeStatus($id: String!) {
33
+ organization { urlKey }
34
+ issue(id: $id) {
35
+ id identifier title url team { key } state { name type }
36
+ inverseRelations(first: 100) {
37
+ nodes { type issue { identifier title url team { key } state { name type } } }
38
+ pageInfo { hasNextPage endCursor }
39
+ }
40
+ }
41
+ }`;
42
+ // A review-round count from BB, or null when BB has none for the PR.
43
+ function reviewCount(v) {
44
+ return Number.isSafeInteger(v) && v >= 0 && v <= 999 ? v : null;
45
+ }
46
+ // BOT-3152 AC-2: the web top bar's Staging chip (top_bar_signals): red while the
47
+ // tenant has an open staging incident; null when BB did not say (never green).
48
+ function stagingOf(own) {
49
+ const st = own?.staging;
50
+ if (!st || typeof st !== "object" || typeof st.configured !== "boolean") return null;
51
+ const since = typeof st.open_since === "string" && Number.isFinite(Date.parse(st.open_since)) ? new Date(st.open_since).toISOString() : null;
52
+ return { configured: st.configured, red: st.configured && since !== null, since: st.configured ? since : null };
53
+ }
54
+ function refuse(code) {
55
+ throw new Error(code);
56
+ }
57
+ function label(value, max = 240) {
58
+ if (
59
+ typeof value !== "string" ||
60
+ !value.trim() ||
61
+ value.length > max ||
62
+ /[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(value)
63
+ )
64
+ refuse("invalid_response");
65
+ return value.trim();
66
+ }
67
+ function issueLink(value, key) {
68
+ let url;
69
+ try {
70
+ url = new URL(value);
71
+ } catch {
72
+ refuse("unsafe_link");
73
+ }
74
+ if (
75
+ url.protocol !== "https:" ||
76
+ url.hostname !== "linear.app" ||
77
+ url.port ||
78
+ url.username ||
79
+ url.password ||
80
+ !new RegExp(`^/botbuddy/issue/${key}(?:/|$)`).test(url.pathname) ||
81
+ url.search ||
82
+ url.hash
83
+ )
84
+ refuse("unsafe_link");
85
+ return url.href;
86
+ }
87
+ // A display string from BB, or null: never throws, so one odd title cannot
88
+ // sink the snapshot the way a bad identity or link must.
89
+ function safeText(value, max = 240) {
90
+ if (typeof value !== "string") return null;
91
+ const t = value.trim();
92
+ if (!t || /[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/u.test(t)) return null;
93
+ return t.length > max ? t.slice(0, max - 1) + "…" : t;
94
+ }
95
+ async function jsonRequest(url, init, fetchImpl, timeoutMs = 8000) {
96
+ let response;
97
+ try {
98
+ response = await fetchImpl(url, {
99
+ ...init,
100
+ redirect: "error",
101
+ signal: AbortSignal.timeout(timeoutMs),
102
+ });
103
+ } catch {
104
+ refuse("source_unreachable");
105
+ }
106
+ if (!response.ok)
107
+ refuse(
108
+ response.status === 401 || response.status === 403
109
+ ? "source_unauthorized"
110
+ : "source_unavailable",
111
+ );
112
+ let body;
113
+ try {
114
+ body = await response.json();
115
+ } catch {
116
+ refuse("invalid_response");
117
+ }
118
+ if (body?.errors || body?.error || body?.isError) refuse("source_rejected");
119
+ return body;
120
+ }
121
+ async function readSession(cwd) {
122
+ const handle = await open(join(cwd, ".botbuddy/agent-state.json"), "r").catch(
123
+ () => refuse("session_missing"),
124
+ );
125
+ try {
126
+ const info = await handle.stat();
127
+ if ((info.mode & 0o077) !== 0 || info.size > 65536)
128
+ refuse("session_file_unsafe");
129
+ try {
130
+ return JSON.parse(await handle.readFile("utf8"));
131
+ } catch {
132
+ refuse("session_invalid");
133
+ }
134
+ } finally {
135
+ await handle.close();
136
+ }
137
+ }
138
+ export async function readStatus({
139
+ cwd,
140
+ harnessSessionId,
141
+ ticketId,
142
+ linearKey,
143
+ // BOT-3135: a relay wake skips the slow epic read; the mod keeps the last epic.
144
+ skipEpic = false,
145
+ now = () => Date.now(),
146
+ fetchImpl = fetch,
147
+ branchReader = async (path) =>
148
+ (
149
+ await execFileAsync("git", ["branch", "--show-current"], { cwd: path })
150
+ ).stdout.trim(),
151
+ }) {
152
+ if (!UUID.test(harnessSessionId)) refuse("harness_session_missing");
153
+ const root = await realpath(cwd);
154
+ let binding;
155
+ try {
156
+ binding = JSON.parse(
157
+ await readFile(join(root, ".botbuddy-agent.json"), "utf8"),
158
+ );
159
+ } catch {
160
+ refuse("repo_binding_missing");
161
+ }
162
+ if (binding.schema_version !== 1 || !Object.hasOwn(TENANTS, binding.tenant))
163
+ refuse("tenant_mismatch");
164
+ const tenant = binding.tenant;
165
+ const prefix = TENANTS[tenant];
166
+ // Linear is read for the BotBuddy workspace only (exact state + blockers);
167
+ // SupplyGuard status comes from BB's own synced copy of its tickets.
168
+ const useLinear = tenant === "botbuddy";
169
+ if (useLinear && (typeof linearKey !== "string" || !linearKey.trim()))
170
+ refuse("linear_key_missing");
171
+ const state = await readSession(root);
172
+ if (
173
+ state.schema_version !== 1 ||
174
+ state.harness_session_id?.toLowerCase() !== harnessSessionId.toLowerCase()
175
+ )
176
+ refuse("session_mismatch");
177
+ if (
178
+ state.tenant !== tenant ||
179
+ !UUID.test(state.agent_id) ||
180
+ !UUID.test(state.session_id)
181
+ )
182
+ refuse("session_invalid");
183
+ if (state.worktree !== root) refuse("worktree_mismatch");
184
+ if (!(Date.parse(state.expires_at) > now())) refuse("session_expired");
185
+ if (
186
+ typeof state.agent_session_token !== "string" ||
187
+ !/^bb_(?:agent|sess)_[0-9a-f]{64}$/.test(state.agent_session_token)
188
+ )
189
+ refuse("session_invalid");
190
+ const branch = label(await branchReader(root));
191
+ const keyPattern = new RegExp(`^${prefix}-[1-9]\\d*$`);
192
+ const branchTicket = branch.match(
193
+ new RegExp(`(?:^|/)${prefix.toLowerCase()}-([1-9]\\d*)(?:-|$)`, "i"),
194
+ );
195
+ const branchKeys = branchTicket ? [`${prefix}-${branchTicket[1]}`] : [];
196
+ // `/bb-status live KEY…` picks the tickets explicitly.
197
+ const selected = ticketId === undefined ? null : [...new Set(String(ticketId).split(","))];
198
+ if (selected && (!selected.length || selected.length > 5 || !selected.every((k) => keyPattern.test(k))))
199
+ refuse("ticket_missing");
200
+ const identity = await jsonRequest(
201
+ "https://api.bot-buddy.ai/functions/v1/mcp-server/tools/whoami",
202
+ {
203
+ method: "POST",
204
+ headers: {
205
+ "Content-Type": "application/json",
206
+ "x-agent-api-key": state.agent_session_token,
207
+ },
208
+ body: "{}",
209
+ },
210
+ fetchImpl,
211
+ );
212
+ if (identity.tenant_id !== tenant) refuse("tenant_mismatch");
213
+ if (
214
+ identity.agent?.id !== state.agent_id ||
215
+ identity.credential_kind !== "agent_session"
216
+ )
217
+ refuse("agent_mismatch");
218
+ const bb = (name, args, timeoutMs) =>
219
+ jsonRequest(
220
+ MCP + name,
221
+ {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ "x-agent-api-key": state.agent_session_token,
226
+ },
227
+ body: JSON.stringify(args),
228
+ },
229
+ fetchImpl,
230
+ timeoutMs,
231
+ ).catch(() => null);
232
+ // BOT-3136: what this session registered with BB comes first. A reply for
233
+ // another session or tenant is ignored, and an empty one falls back to the
234
+ // branch key, which the snapshot labels as a guess.
235
+ // BOT-3152 (review on #1616): read even with an explicit selection, because the
236
+ // tenant's staging state comes from it whatever supplies the tickets.
237
+ const own = await bb("get_session_status", {});
238
+ const ownOk =
239
+ own?.session_id === state.session_id && own?.tenant_id === tenant && Array.isArray(own?.tickets);
240
+ const sessionKeys = ownOk && !selected
241
+ ? [...new Set(own.tickets.map((x) => String(x?.key || "").toUpperCase()).filter((k) => keyPattern.test(k)))].slice(0, 5)
242
+ : [];
243
+ const bindingKind = selected ? "selected" : sessionKeys.length ? "session" : "branch";
244
+ const keys = selected ?? (sessionKeys.length ? sessionKeys : branchKeys);
245
+ if (!keys.length) refuse("ticket_missing");
246
+ const key = keys[0];
247
+ const useSession = bindingKind === "session";
248
+ // The Linear ticket read and the BB reads are independent, so they run
249
+ // together; a refused BB read blanks only its line. The epic read is the slow
250
+ // one (~7 s live), so it gets a longer budget, and a relay wake skips it.
251
+ const linearGraph = useLinear
252
+ ? jsonRequest(
253
+ "https://api.linear.app/graphql",
254
+ {
255
+ method: "POST",
256
+ headers: { "Content-Type": "application/json", Authorization: linearKey },
257
+ body: JSON.stringify({ query: QUERY, variables: { id: key } }),
258
+ },
259
+ fetchImpl,
260
+ )
261
+ : null;
262
+ linearGraph?.catch(() => {});
263
+ // A session-bound band needs none of the broad reads (BOT-3136 AC-2).
264
+ const [work, prState, epicHealth] = useSession
265
+ ? [null, null, null]
266
+ : await Promise.all([
267
+ bb("list_work_items", { entity: "ticket", limit: 200 }),
268
+ bb("get_pr_state", { include_closed: true }),
269
+ skipEpic ? null : bb("get_epic_health", { team_key: prefix }, 15000),
270
+ ]);
271
+ let linearTicket = null;
272
+ const blockers = [];
273
+ if (useLinear) {
274
+ const graph = await linearGraph;
275
+ const issue = graph?.data?.issue;
276
+ if (
277
+ graph?.data?.organization?.urlKey !== "botbuddy" ||
278
+ issue?.team?.key !== "BOT" ||
279
+ issue?.identifier !== key
280
+ )
281
+ refuse("ticket_scope_mismatch");
282
+ const relations = issue.inverseRelations;
283
+ if (
284
+ relations?.pageInfo?.hasNextPage !== false ||
285
+ !Array.isArray(relations.nodes)
286
+ )
287
+ refuse("blockers_incomplete");
288
+ for (const relation of relations.nodes) {
289
+ if (relation.type !== "blocks") continue;
290
+ const blocker = relation.issue;
291
+ if (
292
+ !blocker ||
293
+ !TICKET.test(blocker.identifier) ||
294
+ blocker.team?.key !== "BOT" ||
295
+ !blocker.state?.type
296
+ )
297
+ refuse("blockers_incomplete");
298
+ if (["completed", "canceled"].includes(blocker.state.type)) continue;
299
+ blockers.push({
300
+ key: blocker.identifier,
301
+ title: label(blocker.title),
302
+ status: label(blocker.state.name),
303
+ url: issueLink(blocker.url, blocker.identifier),
304
+ });
305
+ }
306
+ linearTicket = {
307
+ key,
308
+ title: label(issue.title),
309
+ status: label(issue.state?.name),
310
+ url: issueLink(issue.url, key),
311
+ };
312
+ }
313
+ const epics = Array.isArray(epicHealth?.epics) ? epicHealth.epics : [];
314
+ const workItems = Array.isArray(work?.items) ? work.items : [];
315
+ const fromBb = (k) => {
316
+ if (useSession) {
317
+ const t = own.tickets.find((x) => String(x?.key || "").toUpperCase() === k);
318
+ return t ? { title: t.title, status: STATUS[t.state_type] || (typeof t.state === "string" ? t.state : null) } : null;
319
+ }
320
+ const item = workItems.find((w) => w?.kind === "ticket" && w.ticket === k);
321
+ if (item) return { title: item.title, status: STATUS[item.stage] };
322
+ for (const e of epics) {
323
+ const c = (e?.children || []).find((x) => x?.external_id === k);
324
+ if (c) return { title: c.title, status: STATUS[c.state_type] };
325
+ }
326
+ return null;
327
+ };
328
+ const tickets = keys.map((k) => {
329
+ if (linearTicket && k === key)
330
+ return { key: k, title: linearTicket.title, status: linearTicket.status };
331
+ const found = fromBb(k);
332
+ return {
333
+ key: k,
334
+ title: found ? safeText(found.title) || "Not in BB yet" : "Not in BB yet",
335
+ status: found?.status || "Unknown",
336
+ };
337
+ });
338
+ // PRs whose head is this worktree's branch, or whose title or branch names
339
+ // one of the session's tickets as a whole key (BOT-77, never BOT-770).
340
+ const keyRes = keys.map((k) => new RegExp(`(?:^|[^A-Za-z0-9])${k}(?![0-9])`, "i"));
341
+ const mentions = (p) =>
342
+ keyRes.some((re) => re.test(String(p?.title || "")) || re.test(String(p?.headRefName || "")));
343
+ // Newest first as BB returns them.
344
+ const prs = useSession
345
+ ? (Array.isArray(own.prs) ? own.prs : [])
346
+ .filter((p) => REPO.test(p?.repo) && Number.isSafeInteger(p?.number) && p.number > 0)
347
+ .slice(0, 5)
348
+ .map((p) => ({
349
+ repo: p.repo,
350
+ number: p.number,
351
+ title: safeText(p.title) || "Untitled PR",
352
+ state: p.state === "draft" || (p.is_draft && p.state === "open") ? "draft" : ["open", "merged", "closed"].includes(p.state) ? p.state : "open",
353
+ conflict: false,
354
+ // BOT-3152 AC-3: pr_state.review_rounds, the column get_pr_state returns as reviewRounds.
355
+ reviews: reviewCount(p.review_rounds),
356
+ }))
357
+ : Array.isArray(prState?.prs)
358
+ ? prState.prs
359
+ .filter((p) => (p?.headRefName === branch || mentions(p)) && REPO.test(p.slug) && Number.isSafeInteger(p.number) && p.number > 0)
360
+ .slice(0, 5)
361
+ .map((p) => ({
362
+ repo: p.slug,
363
+ number: p.number,
364
+ title: safeText(p.title) || "Untitled PR",
365
+ // BB reports a draft either as state "draft" or as an open PR flagged isDraft.
366
+ state: p.state === "draft" || (p.isDraft && p.state === "open") ? "draft" : ["open", "merged", "closed"].includes(p.state) ? p.state : "open",
367
+ conflict: p.mergeable === "CONFLICTING",
368
+ reviews: reviewCount(p.reviewRounds),
369
+ }))
370
+ : null;
371
+ // Always one epic: the project of the first ticket, in session order, that
372
+ // belongs to one.
373
+ const epicOf = (k) =>
374
+ epics.find(
375
+ (e) => UUID_LOWER.test(e?.epic_project_id) && (e.children || []).some((c) => c?.external_id === k),
376
+ );
377
+ const owner = useSession ? null : keys.map(epicOf).find(Boolean);
378
+ let epic = null;
379
+ const se = useSession ? own.epic : null;
380
+ if (se && UUID_LOWER.test(se.linear_project_id)) {
381
+ // As the branch path and v_epics.progress_pct do: canceled children don't count.
382
+ const total = Math.max(0, (se.children_total ?? 0) - (se.children_canceled ?? 0));
383
+ const done = Math.min(total, Math.max(0, se.children_completed ?? 0));
384
+ const slug = String(se.url || "").match(/-([0-9a-f]{8,16})$/);
385
+ epic = {
386
+ id: se.linear_project_id,
387
+ short_id: slug ? slug[1] : se.linear_project_id.slice(0, 8),
388
+ name: safeText(se.name) || "Untitled epic",
389
+ state: PROJECT_STATES.includes(se.state) ? se.state : "planned",
390
+ url: LINEAR_PROJECT_URL.test(se.url || "") ? se.url : null,
391
+ identifier: PROJECT_IDENTIFIER.test(se.identifier || "") ? se.identifier : null,
392
+ done,
393
+ total,
394
+ percent: Number.isFinite(se.progress_pct) ? Math.max(0, Math.min(100, Math.round(se.progress_pct))) : total ? Math.round((100 * done) / total) : 0,
395
+ };
396
+ }
397
+ if (owner) {
398
+ const total = Math.max(0, (owner.children_total ?? 0) - (owner.children_canceled ?? 0));
399
+ const done = Math.min(total, Math.max(0, owner.children_completed ?? 0));
400
+ const slug = String(owner.epic_url || "").match(/-([0-9a-f]{8,16})$/);
401
+ epic = {
402
+ id: owner.epic_project_id,
403
+ short_id: slug ? slug[1] : owner.epic_project_id.slice(0, 8),
404
+ name: safeText(owner.epic_name) || "Untitled epic",
405
+ // Linear's project state drives its icon; percent only fills the wedge.
406
+ state: PROJECT_STATES.includes(owner.project_state) ? owner.project_state : "planned",
407
+ // The project's own Linear page, for the band's direct ↗ link.
408
+ url: LINEAR_PROJECT_URL.test(owner.epic_url || "") ? owner.epic_url : null,
409
+ identifier: null,
410
+ done,
411
+ total,
412
+ percent: total ? Math.round((100 * done) / total) : 0,
413
+ };
414
+ }
415
+ // A bootstrap/session switch during either network request invalidates this snapshot.
416
+ const latest = await readSession(root);
417
+ if (
418
+ latest.agent_id !== state.agent_id ||
419
+ latest.session_id !== state.session_id ||
420
+ latest.harness_session_id !== state.harness_session_id ||
421
+ latest.session_token_generation !== state.session_token_generation ||
422
+ latest.agent_session_token !== state.agent_session_token ||
423
+ latest.tenant !== state.tenant ||
424
+ latest.worktree !== state.worktree ||
425
+ !(Date.parse(latest.expires_at) > now()) ||
426
+ (await branchReader(root)) !== branch
427
+ )
428
+ refuse("session_changed");
429
+ // Linear's project identifier (e.g. P-BOT-39): read directly for BotBuddy,
430
+ // whose Linear key this adapter holds; Supply Guard waits for BB to sync it.
431
+ if (epic && !epic.identifier && useLinear) {
432
+ const found = await jsonRequest(
433
+ "https://api.linear.app/graphql",
434
+ {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json", Authorization: linearKey },
437
+ body: JSON.stringify({
438
+ query: "query BbProjectIdentifier($id: String!) { project(id: $id) { identifier } }",
439
+ variables: { id: epic.id },
440
+ }),
441
+ },
442
+ fetchImpl,
443
+ ).catch(() => null);
444
+ const id = found?.data?.project?.identifier;
445
+ epic.identifier = PROJECT_IDENTIFIER.test(id || "") ? id : null;
446
+ }
447
+ return {
448
+ schema_version: 2,
449
+ tenant,
450
+ harness_session_id: harnessSessionId.toLowerCase(),
451
+ session_id: state.session_id,
452
+ observed_at: new Date(now()).toISOString(),
453
+ branch,
454
+ agent: {
455
+ id: state.agent_id,
456
+ name: label(identity.agent.display_name, 100),
457
+ },
458
+ ticket: linearTicket || { ...tickets[0], url: null },
459
+ tickets,
460
+ prs,
461
+ epic,
462
+ ...(skipEpic && !useSession ? { epic_skipped: true } : {}),
463
+ // BOT-3152 AC-1: the epic source failed, so "no epic" would be a guess.
464
+ epic_unreadable: useSession ? false : !skipEpic && !Array.isArray(epicHealth?.epics),
465
+ staging: ownOk ? stagingOf(own) : null,
466
+ // BOT-3136: where the tickets came from — the registered session, an explicit
467
+ // pick, or (only when the session registered none) a guess from the branch.
468
+ binding: bindingKind,
469
+ blockers: useLinear
470
+ ? [...new Map(blockers.map((b) => [b.key, b])).values()].sort((a, b) =>
471
+ a.key.localeCompare(b.key),
472
+ )
473
+ : null,
474
+ };
475
+ }
476
+ // BOT-3152 AC-5: a repo with no SessionStart bootstrap hook (Supply Guard) leaves
477
+ // the worktree cache missing, unbound (no harness id) or expired, so the band
478
+ // binds this session itself, once per read, through the same bootstrap
479
+ // `bb bootstrap` runs. A cache another harness session holds is never
480
+ // bootstrapped over: that session owns the worktree's identity.
481
+ const BINDABLE = new Set(["session_missing", "session_mismatch", "session_expired"]);
482
+ async function bindable(cwd, harnessSessionId, now) {
483
+ let state;
484
+ try {
485
+ state = JSON.parse(await readFile(join(await realpath(cwd), ".botbuddy/agent-state.json"), "utf8"));
486
+ } catch (error) {
487
+ return error?.code === "ENOENT";
488
+ }
489
+ const holder = typeof state?.harness_session_id === "string" ? state.harness_session_id.toLowerCase() : "";
490
+ if (!holder || holder === harnessSessionId.toLowerCase()) return true;
491
+ return !(Date.parse(state.expires_at) > now());
492
+ }
493
+ async function bootstrapBand({ cwd, harnessSessionId, branch }) {
494
+ const [{ bootstrapHarnessSession }, { getConfig }, { hostname }] = await Promise.all([
495
+ import("./session-bootstrap.mjs"),
496
+ import("./config.mjs"),
497
+ import("node:os"),
498
+ ]);
499
+ await bootstrapHarnessSession({
500
+ name: `${getConfig()?.agent_name || `botbuddy-${hostname()}`}-${harnessSessionId.slice(0, 8)}`,
501
+ type: "claude",
502
+ harnessSessionId,
503
+ cwd,
504
+ branch,
505
+ noTicketReason: "bb-status band bound a Claude Code session its repo did not bootstrap",
506
+ harness: "botbuddy-claude-band",
507
+ });
508
+ }
509
+
510
+ export async function main(args, {
511
+ out = process.stdout,
512
+ cwd = process.cwd(),
513
+ env = process.env,
514
+ read = readStatus,
515
+ bootstrap = bootstrapBand,
516
+ now = () => Date.now(),
517
+ } = {}) {
518
+ try {
519
+ args = [...args];
520
+ const skipEpic = args.at(-1) === "--skip-epic";
521
+ if (skipEpic) args.pop();
522
+ if (args.length !== 2 && args.length !== 4) refuse("invalid_arguments");
523
+ if (
524
+ args[0] !== "--harness-session-id" ||
525
+ (args.length === 4 && args[2] !== "--ticket-id")
526
+ )
527
+ refuse("invalid_arguments");
528
+ const request = { cwd, harnessSessionId: args[1], ticketId: args[3], linearKey: env.LINEAR_BB_API_KEY, skipEpic };
529
+ let snapshot;
530
+ try {
531
+ snapshot = await read(request);
532
+ } catch (error) {
533
+ if (!BINDABLE.has(error?.message) || !UUID.test(args[1]) || !(await bindable(cwd, args[1], now))) throw error;
534
+ let branch = null;
535
+ try {
536
+ branch = (await execFileAsync("git", ["branch", "--show-current"], { cwd })).stdout.trim() || null;
537
+ } catch { /* detached or not a repo: bootstrap without a branch */ }
538
+ try {
539
+ await bootstrap({ cwd, harnessSessionId: args[1].toLowerCase(), branch });
540
+ } catch {
541
+ throw error;
542
+ }
543
+ snapshot = await read(request);
544
+ }
545
+ out.write(JSON.stringify(snapshot) + "\n");
546
+ return 0;
547
+ } catch (error) {
548
+ const known = /^[a-z_]+$/.test(error?.message ?? "") ? error.message : "status_unavailable";
549
+ out.write(JSON.stringify({ error: known }) + "\n");
550
+ return 1;
551
+ }
552
+ }
@@ -0,0 +1,104 @@
1
+ // BOT-3135 / BOT-3152. The band's relay child (`bb status-band relay`): arms ONE
2
+ // `bb wait` on the band's own subjects and reports how it ended. It runs this
3
+ // installed package's own `wait` through the node that runs it — no PATH lookup,
4
+ // no shell, no repo checkout — and never reads BB itself.
5
+ import { execFile as nodeExecFile } from "node:child_process";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
9
+
10
+ // Under the 10-minute ceiling a mod gives a one-shot child, so a stuck
11
+ // relay can never outlive one arm; the mod re-arms from the cursor.
12
+ export const WAIT_TIMEOUT_SECONDS = 570;
13
+ const SHORT_TIMEOUT_REASON =
14
+ "bb-status band relay: re-armed from its cursor every 570 s by the mod";
15
+ const KEY = "(?:BOT|ENT)-[1-9]\\d{0,5}";
16
+ const REPO = "[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}";
17
+ // BOT-3136: the epic row wakes on its Linear project.
18
+ const PROJECT = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
19
+ // BOT-3152 AC-2: any staging incident of the session's tenant opening or closing.
20
+ const STAGING = "event:type=staging_incident";
21
+ const CONDITION = new RegExp(
22
+ `^(?:linear:issue=${KEY}|unblocked:${KEY}|pr-(?:state|review):repo=${REPO},pr=[1-9]\\d{0,6}|event:type=linear_project,subject=${PROJECT}|${STAGING})$`,
23
+ );
24
+ const CURSOR = /^\d{1,16}$/;
25
+ // The band's largest list: 5 tickets, 5 PRs (state + review), blockers, epic, staging.
26
+ const MAX_CONDITIONS = 18;
27
+ export const CLI_ENTRY = join(resolve(dirname(fileURLToPath(import.meta.url)), ".."), "bin", "botbuddy.mjs");
28
+
29
+ function invalid() {
30
+ throw new Error("invalid_arguments");
31
+ }
32
+
33
+ export function relayArgs(argv) {
34
+ const conditions = [];
35
+ let since = null;
36
+ for (let i = 0; i < argv.length; i += 2) {
37
+ const [flag, value] = [argv[i], argv[i + 1]];
38
+ if (typeof value !== "string") invalid();
39
+ if (flag === "--cond" && CONDITION.test(value) && !value.includes("..") && !conditions.includes(value)) conditions.push(value);
40
+ else if (flag === "--since" && since === null && CURSOR.test(value)) since = value;
41
+ else invalid();
42
+ }
43
+ if (conditions.length < 1 || conditions.length > MAX_CONDITIONS) invalid();
44
+ return { conditions, since };
45
+ }
46
+
47
+ // The receipt is bb's last JSON line; only the fields the mod needs leave here.
48
+ export function summarize(exit, stdout) {
49
+ let receipt = null;
50
+ for (const line of String(stdout).trim().split("\n").reverse()) {
51
+ try {
52
+ receipt = JSON.parse(line);
53
+ break;
54
+ } catch {
55
+ /* banner or log line */
56
+ }
57
+ }
58
+ const cursor = receipt?.next_cursor == null ? null : String(receipt.next_cursor);
59
+ const id = receipt?.wait_session_id;
60
+ return {
61
+ exit,
62
+ outcome: typeof receipt?.outcome === "string" && /^[a-z_]{1,40}$/.test(receipt.outcome) ? receipt.outcome : "error",
63
+ wait_id: typeof id === "string" && /^[0-9a-z-]{1,64}$/i.test(id) ? id : null,
64
+ next_cursor: cursor && CURSOR.test(cursor) ? cursor : null,
65
+ };
66
+ }
67
+
68
+ export async function runRelay(
69
+ argv,
70
+ { execPath = process.execPath, cliEntry = CLI_ENTRY, execFile = promisify(nodeExecFile), cwd } = {},
71
+ ) {
72
+ const { conditions, since } = relayArgs(argv);
73
+ const args = [
74
+ cliEntry,
75
+ "wait",
76
+ ...conditions,
77
+ "--timeout",
78
+ String(WAIT_TIMEOUT_SECONDS),
79
+ "--allow-short-timeout",
80
+ SHORT_TIMEOUT_REASON,
81
+ ...(since ? ["--since", since] : []),
82
+ ];
83
+ try {
84
+ const { stdout } = await execFile(execPath, args, {
85
+ cwd,
86
+ maxBuffer: 4 * 1024 * 1024,
87
+ timeout: (WAIT_TIMEOUT_SECONDS + 25) * 1000,
88
+ });
89
+ return summarize(0, stdout);
90
+ } catch (error) {
91
+ return summarize(typeof error?.code === "number" ? error.code : 7, error?.stdout ?? "");
92
+ }
93
+ }
94
+
95
+ export async function main(argv, { out = process.stdout, cwd = process.cwd() } = {}) {
96
+ let res;
97
+ try {
98
+ res = await runRelay(argv, { cwd });
99
+ } catch {
100
+ res = { exit: 4, outcome: "error", error: "invalid_arguments", wait_id: null, next_cursor: null };
101
+ }
102
+ out.write(JSON.stringify(res) + "\n");
103
+ return 0;
104
+ }