@botbuddy/cli 1.66.1 → 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.
- package/CHANGELOG.md +10 -0
- package/claude-plugin/.claude-plugin/marketplace.json +17 -0
- package/claude-plugin/bb-status/.claude-plugin/plugin.json +9 -0
- package/claude-plugin/bb-status/README.md +156 -0
- package/claude-plugin/bb-status/hooks/hooks.json +1 -0
- package/claude-plugin/bb-status/hooks/register.js +992 -0
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +7 -2
- package/src/commands.mjs +21 -0
- package/src/release-notes.mjs +102 -0
- package/src/status-band-read.mjs +501 -0
- package/src/status-band-relay.mjs +104 -0
- package/src/status-band.mjs +126 -0
- package/src/update-check.mjs +15 -1
- package/src/wait-core.mjs +3 -0
- package/src/wait.mjs +3 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|