@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.
- package/CHANGELOG.md +6 -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 +16 -1
- 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
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.68.0",
|
|
3
|
+
"source_commit": "dacd86b04baacbb89711e0a72c52ee036fd17721",
|
|
4
|
+
"source_sha256": "f857817d22c1d475d9b53f86b0ea73f2e6e0af18f306b1d079b21cc844413260",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "3a9d33c8cfd04bd4844025552464c02b55399fab1c5352fdc86799a62c8bec5e"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.68.0",
|
|
3
|
+
"source_commit": "dacd86b04baacbb89711e0a72c52ee036fd17721",
|
|
4
|
+
"source_sha256": "f857817d22c1d475d9b53f86b0ea73f2e6e0af18f306b1d079b21cc844413260",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "15fde9d3dd872809e9cc0aead517e7aef9a5398af55b4543269cdd90a6ffc96b"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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
|
+
}
|