@botbuddy/cli 1.67.0 → 1.68.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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.67.0",
3
- "source_commit": "268168df0f1ca0c6e45a7650488ecc1e24e06401",
4
- "source_sha256": "28a9fb83409d1d081474ef61a1a8f025837c54284d5809b4d9ebec87c0ee4e7d",
2
+ "release_version": "1.68.0",
3
+ "source_commit": "dacd86b04baacbb89711e0a72c52ee036fd17721",
4
+ "source_sha256": "f857817d22c1d475d9b53f86b0ea73f2e6e0af18f306b1d079b21cc844413260",
5
5
  "target": "darwin-arm64",
6
- "sha256": "f75345368910de3b008e4796e6469c60e9026b9db732baadb14fa994b2982cf1"
6
+ "sha256": "3a9d33c8cfd04bd4844025552464c02b55399fab1c5352fdc86799a62c8bec5e"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.67.0",
3
- "source_commit": "268168df0f1ca0c6e45a7650488ecc1e24e06401",
4
- "source_sha256": "28a9fb83409d1d081474ef61a1a8f025837c54284d5809b4d9ebec87c0ee4e7d",
2
+ "release_version": "1.68.0",
3
+ "source_commit": "dacd86b04baacbb89711e0a72c52ee036fd17721",
4
+ "source_sha256": "f857817d22c1d475d9b53f86b0ea73f2e6e0af18f306b1d079b21cc844413260",
5
5
  "target": "linux-x64",
6
- "sha256": "9c1fb4e157e1b86f84b7efda1a3ab25f9e869a9d09a1c30093c3ef4dc67e928f"
6
+ "sha256": "15fde9d3dd872809e9cc0aead517e7aef9a5398af55b4543269cdd90a6ffc96b"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.67.0",
4
- "source_commit": "268168df0f1ca0c6e45a7650488ecc1e24e06401",
5
- "source_sha256": "28a9fb83409d1d081474ef61a1a8f025837c54284d5809b4d9ebec87c0ee4e7d",
3
+ "release_version": "1.68.0",
4
+ "source_commit": "dacd86b04baacbb89711e0a72c52ee036fd17721",
5
+ "source_sha256": "f857817d22c1d475d9b53f86b0ea73f2e6e0af18f306b1d079b21cc844413260",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "f75345368910de3b008e4796e6469c60e9026b9db732baadb14fa994b2982cf1",
11
+ "sha256": "3a9d33c8cfd04bd4844025552464c02b55399fab1c5352fdc86799a62c8bec5e",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "9c1fb4e157e1b86f84b7efda1a3ab25f9e869a9d09a1c30093c3ef4dc67e928f",
18
+ "sha256": "15fde9d3dd872809e9cc0aead517e7aef9a5398af55b4543269cdd90a6ffc96b",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,12 @@
20
20
  "daemon/",
21
21
  "daemon-release/",
22
22
  "src/",
23
- "!src/**/*.test.mjs"
23
+ "!src/**/*.test.mjs",
24
+ "claude-plugin/.claude-plugin/",
25
+ "claude-plugin/bb-status/.claude-plugin/",
26
+ "claude-plugin/bb-status/hooks/hooks.json",
27
+ "claude-plugin/bb-status/hooks/register.js",
28
+ "claude-plugin/bb-status/README.md"
24
29
  ],
25
30
  "keywords": [
26
31
  "mcp",
package/src/commands.mjs CHANGED
@@ -20,6 +20,7 @@ import { cmdTest } from "./test-lane.mjs";
20
20
  import { cmdEvidence } from "./evidence.mjs";
21
21
  import { cmdFeedback } from "./feedback.mjs";
22
22
  import { cmdReleaseNotes } from "./release-notes.mjs";
23
+ import { cmdStatusBand, installStatusBand, statusBandLine } from "./status-band.mjs";
23
24
  import { runWait } from "./wait.mjs";
24
25
  import { SETUP_BLOCK } from "./setup-block.mjs";
25
26
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
@@ -64,7 +65,7 @@ export function shouldCheckForUpdates(argv) {
64
65
  export const LOCAL_HELP_COMMANDS = Object.freeze([
65
66
  "wait", "stack", "pw", "docker", "run", "mcp", "login", "setup", "doctor", "host",
66
67
  "staging-pool", "daemon", "evidence", "feedback", "usage", "telemetry", "allocation",
67
- "release-notes",
68
+ "release-notes", "status-band",
68
69
  ]);
69
70
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
70
71
 
@@ -147,6 +148,8 @@ export async function run(argv, {
147
148
  case "feedback": return cmdFeedback(args);
148
149
  // BOT-3156: governed release notes (submit one note through submit_release_note).
149
150
  case "release-notes": return cmdReleaseNotes(args);
151
+ // BOT-3152: the bb-status band's host side (read/relay) and its Claude Code install.
152
+ case "status-band": return cmdStatusBand(args);
150
153
  case "test-ingest": return cmdTestIngest(args);
151
154
  case "wait": return runWait(args);
152
155
  case "pw": return runPw(args);
@@ -335,6 +338,8 @@ ${bold("EVIDENCE")} (${cyan("bb help evidence")})
335
338
  Prepare or send a BotBuddy issue report
336
339
  release-notes submit <note.json>
337
340
  Submit one release note for your tenant (stored as draft)
341
+ status-band install Install the bb-status band into Claude Code (bb setup does it)
342
+ status-band read|relay The band's own reads and live wake (run by the mod)
338
343
  evidence require <file> Record an exact-head evidence disposition → botbuddy/ready
339
344
  evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
340
345
 
@@ -845,6 +850,8 @@ export async function cmdSetup(args, {
845
850
  selfHeal = selfHealAgentSession,
846
851
  callerResolver = resolveCaller,
847
852
  usageAgent = installUsageAgent,
853
+ // BOT-3152: setup also installs the bb-status band into Claude Code, once.
854
+ statusBand = installStatusBand,
848
855
  // BOT-2599: setup also gives this clone's SSH push transport a keepalive.
849
856
  gitTransport = async (opts) => checkGitTransport(opts),
850
857
  cwd = process.cwd(),
@@ -959,6 +966,7 @@ export async function cmdSetup(args, {
959
966
  }
960
967
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
961
968
  await reportGitTransport(gitTransport, { cwd, env, log });
969
+ await reportStatusBand(statusBand, { log });
962
970
  await reportUsageAgent(usageAgent, { cwd, log });
963
971
  return 0;
964
972
  } catch (error) {
@@ -967,6 +975,13 @@ export async function cmdSetup(args, {
967
975
  }
968
976
  }
969
977
 
978
+ // BOT-3152: never fails setup — the worktree session is ready either way.
979
+ async function reportStatusBand(statusBand, { log }) {
980
+ let result;
981
+ try { result = await statusBand(); } catch (error) { result = { status: "failed", reason: error?.code ?? "install_failed" }; }
982
+ log(`bb setup: ${statusBandLine(result)}`);
983
+ }
984
+
970
985
  // BOT-1021: setup also (re)installs the daily usage sweep. Never fails setup —
971
986
  // the worktree session is ready either way; the line says what happened.
972
987
  async function reportUsageAgent(usageAgent, { cwd, log }) {
@@ -0,0 +1,501 @@
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
+ export async function main(args, { out = process.stdout, cwd = process.cwd(), env = process.env } = {}) {
477
+ try {
478
+ args = [...args];
479
+ const skipEpic = args.at(-1) === "--skip-epic";
480
+ if (skipEpic) args.pop();
481
+ if (args.length !== 2 && args.length !== 4) refuse("invalid_arguments");
482
+ if (
483
+ args[0] !== "--harness-session-id" ||
484
+ (args.length === 4 && args[2] !== "--ticket-id")
485
+ )
486
+ refuse("invalid_arguments");
487
+ const snapshot = await readStatus({
488
+ cwd,
489
+ harnessSessionId: args[1],
490
+ ticketId: args[3],
491
+ linearKey: env.LINEAR_BB_API_KEY,
492
+ skipEpic,
493
+ });
494
+ out.write(JSON.stringify(snapshot) + "\n");
495
+ return 0;
496
+ } catch (error) {
497
+ const known = /^[a-z_]+$/.test(error?.message ?? "") ? error.message : "status_unavailable";
498
+ out.write(JSON.stringify({ error: known }) + "\n");
499
+ return 1;
500
+ }
501
+ }