@botbuddy/cli 1.8.8 → 1.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.8.8",
3
+ "version": "1.9.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- {"schema_version":1,"source_version":"1.8.7","source_identity":"e6b611000678281287808866482334e5a40e265ef3c0328c4e85f53a5bf23cd0"}
1
+ {"schema_version":1,"source_version":"1.9.0","source_identity":"70b96a360d9dd5bd637dc6378a4cae6542cb0a508990f28d26c842946e6fb694"}
package/src/test-lane.mjs CHANGED
@@ -26,12 +26,19 @@ import { callToolJson } from "./api.mjs";
26
26
  export const EXIT_TEST = Object.freeze({ ...EXIT, TIMEOUT: 2 });
27
27
  export const WAITS_URL = "https://app.bot-buddy.ai/waits";
28
28
  const KNOWN_RUNNERS = new Set(["playwright", "generic"]);
29
+ // BOT-1549: mirror of the DB enum public.test_lane_kind (BOT-1547) and
30
+ // supabase/functions/_shared/test-adapters.ts LANE_KINDS. A lane declares its
31
+ // kind in .botbuddy/lanes.json; an unknown value (config or --lane-kind) is a
32
+ // hard, named failure — never a guessed default (gate 9).
33
+ export const LANE_KINDS = Object.freeze(["smoke", "regression", "diagnostics", "e2e", "unit", "integration", "other"]);
34
+ const LANE_KIND_SET = new Set(LANE_KINDS);
29
35
 
30
36
  export function parseTestArgs(argv, { env = process.env } = {}) {
31
37
  const opts = {
32
38
  sessionId: env.BOTBUDDY_SESSION_ID ?? null,
33
39
  environment: "local",
34
40
  ticket: null, pr: null, repo: null,
41
+ laneKind: null,
35
42
  wait: false, json: false,
36
43
  };
37
44
  const errors = [];
@@ -49,6 +56,7 @@ export function parseTestArgs(argv, { env = process.env } = {}) {
49
56
  case "--ticket": opts.ticket = value(flag, i); i++; break;
50
57
  case "--pr": { const raw = value(flag, i); i++; opts.pr = raw == null ? null : Number(raw); if (raw != null && !Number.isInteger(opts.pr)) errors.push("--pr must be an integer"); break; }
51
58
  case "--repo": opts.repo = value(flag, i); i++; break;
59
+ case "--lane-kind": opts.laneKind = value(flag, i); i++; break;
52
60
  case "--wait": opts.wait = true; break;
53
61
  case "--json": opts.json = true; break;
54
62
  default:
@@ -88,7 +96,12 @@ export function resolveLane(laneName, { cwd = process.cwd(), readFileImpl = read
88
96
  const lane = lanes[laneName];
89
97
  if (!Array.isArray(lane.command) || lane.command.length === 0) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" has no command` };
90
98
  if (!KNOWN_RUNNERS.has(lane.runner)) return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" runner must be playwright|generic` };
91
- return { ok: true, appSlug: config.app_slug ?? null, lane, path, root };
99
+ // BOT-1549: an optional per-lane `kind` must be a known lane_kind — a bad
100
+ // value is rejected here, never coerced or defaulted (gate 9).
101
+ if (lane.kind != null && !LANE_KIND_SET.has(lane.kind)) {
102
+ return { ok: false, error: "invalid", path, root, detail: `lane "${laneName}" lane_kind must be one of ${LANE_KINDS.join("|")} (got "${lane.kind}")` };
103
+ }
104
+ return { ok: true, appSlug: config.app_slug ?? null, lane, laneKind: lane.kind ?? null, path, root };
92
105
  }
93
106
 
94
107
  // Best-effort git/gh context. Every field falls back to null — never fabricated
@@ -151,8 +164,18 @@ export async function launchTestLane(argv, {
151
164
  if (resolved.error === "missing") process.stderr.write(`botbuddy test: no lane config — create ${resolved.path}\n`);
152
165
  else if (resolved.error === "unknown") process.stderr.write(`botbuddy test: unknown lane "${laneName}". Available: ${resolved.available.join(", ") || "(none)"}\n`);
153
166
  else process.stderr.write(`botbuddy test: invalid lane config (${resolved.detail}) at ${resolved.path}\n`);
154
- return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path }) };
167
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: resolved.error, path: resolved.path, detail: resolved.detail }) };
168
+ }
169
+
170
+ // BOT-1549: resolve the effective lane kind — a --lane-kind flag overrides
171
+ // the lane's config kind for this run. An invalid flag value is rejected
172
+ // before any run is created (gate 9), mirroring the config-side check.
173
+ if (opts.laneKind != null && !LANE_KIND_SET.has(opts.laneKind)) {
174
+ const detail = `invalid lane_kind: --lane-kind must be one of ${LANE_KINDS.join("|")} (got "${opts.laneKind}")`;
175
+ process.stderr.write(`botbuddy test: ${detail}\n`);
176
+ return { exitCode: EXIT_TEST.INVALID, line: JSON.stringify({ outcome: "rejected", error: "invalid", detail }) };
155
177
  }
178
+ const laneKind = opts.laneKind ?? resolved.laneKind ?? null;
156
179
 
157
180
  const git = gitInfo({ cwd, ticket: opts.ticket, pr: opts.pr, repo: opts.repo });
158
181
  const sha7 = (git.sha ?? "").slice(0, 7) || "nosha";
@@ -167,6 +190,8 @@ export async function launchTestLane(argv, {
167
190
  commit_sha: git.sha ?? undefined, branch_name: git.branch ?? undefined,
168
191
  pr_number: git.prNumber ?? undefined, pr_url: git.prUrl ?? undefined,
169
192
  source_kind: "cli-lane", source_id: laneRunId,
193
+ // BOT-1549: stamp the resolved lane kind on the run; absent ⇒ omitted (NULL).
194
+ lane_kind: laneKind ?? undefined,
170
195
  };
171
196
  const created = await call("create_test_run", createArgs);
172
197
  const testRunId = created?.ok && !created.isError ? created.data?.id ?? null : null;
@@ -183,6 +208,15 @@ export async function launchTestLane(argv, {
183
208
 
184
209
  // Step 3: launch the lane under the durable worker, telemetry env in the child.
185
210
  const childEnv = { BOTBUDDY_LANE: laneName };
211
+ // BOT-1549: carry the lane kind to the child so uploaded executions
212
+ // (test-ingest / playwright-upload) can stamp lane_kind too. ALWAYS set the
213
+ // key: runWorker spawns the child with `{ ...process.env, ...child_env }`
214
+ // (run.mjs), so a bare `if (laneKind)` would let a stale BOTBUDDY_LANE_KIND
215
+ // already in the environment leak into the child even though this run's
216
+ // lane_kind is NULL — making child-uploaded executions disagree with their
217
+ // run (Codex P2). Setting "" when there is no effective kind deterministically
218
+ // clears any inherited value; empty ⇒ unset by parseLaneKind's contract.
219
+ childEnv.BOTBUDDY_LANE_KIND = laneKind ?? "";
186
220
  if (testRunId) { childEnv.BOTBUDDY_TEST_RUN_ID = testRunId; childEnv.BOTBUDDY_TEST_RUN_EVENTS = eventsPath; }
187
221
  const testRun = testRunId
188
222
  ? { test_run_id: testRunId, events_path: eventsPath, lane: laneName, runner: resolved.lane.runner, git_sha: git.sha ?? null, branch: git.branch ?? null }
@@ -248,7 +282,7 @@ function laneList(cwd) {
248
282
  }
249
283
 
250
284
  function testHelp() {
251
- console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--wait] [--json] [-- <extra args>]
285
+ console.log(`botbuddy test <lane> [--session-id <uuid>] [--environment local] [--ticket <KEY>] [--pr <n>] [--repo <owner/repo>] [--lane-kind <${LANE_KINDS.join("|")}>] [--wait] [--json] [-- <extra args>]
252
286
  botbuddy test list List the lanes configured in .botbuddy/lanes.json
253
287
  botbuddy test help Show this help
254
288
 
package/src/wait-core.mjs CHANGED
@@ -611,6 +611,63 @@ export function foldFeedLag(degraded, lagging) {
611
611
  return degraded;
612
612
  }
613
613
 
614
+ // ---------------------------------------------------------------------------
615
+ // BOT-1539 — arm-time outstanding-review notice.
616
+ //
617
+ // `pr-review` / `pr-state` are edge-triggered: they wake only on a review event
618
+ // delivered AFTER arm. When the relay reports (via `pr_review_snapshot` on the
619
+ // register receipt) that a PR ALREADY has an outstanding review, warn the
620
+ // operator on stderr so a silent park isn't mistaken for "no review yet". The
621
+ // existing review will NOT re-wake this wait.
622
+ // ---------------------------------------------------------------------------
623
+
624
+ // Compact, human relative age (e.g. "27m ago", "3h ago", "2d ago"). Returns
625
+ // null when the timestamp is absent or unparseable, so callers can omit the
626
+ // phrase entirely.
627
+ function relativeAge(iso, now) {
628
+ if (!iso) return null;
629
+ const then = Date.parse(iso);
630
+ if (!Number.isFinite(then)) return null;
631
+ const secs = Math.max(0, Math.round((now - then) / 1000));
632
+ if (secs < 60) return `${secs}s ago`;
633
+ const mins = Math.round(secs / 60);
634
+ if (mins < 60) return `${mins}m ago`;
635
+ const hours = Math.round(mins / 60);
636
+ if (hours < 48) return `${hours}h ago`;
637
+ return `${Math.round(hours / 24)}d ago`;
638
+ }
639
+
640
+ /**
641
+ * Build one stderr warning line per OUTSTANDING pr-review snapshot entry.
642
+ * Clean (not-outstanding) entries and empty/absent input yield no lines.
643
+ *
644
+ * @param {Array<{repo:string,pr:number,unresolved_threads:number,review_decision:string,pr_updated_at:string|null,existing_review_outstanding:boolean}>|null|undefined} snapshot
645
+ * @param {number} [now] epoch ms, for deterministic age formatting
646
+ * @returns {string[]}
647
+ */
648
+ export function formatPrReviewSnapshotWarnings(snapshot, now = Date.now()) {
649
+ if (!Array.isArray(snapshot)) return [];
650
+ const lines = [];
651
+ for (const e of snapshot) {
652
+ if (!e || e.existing_review_outstanding !== true) continue;
653
+ const threads = typeof e.unresolved_threads === "number" ? e.unresolved_threads : 0;
654
+ const parts = [`pr-review armed: ${e.repo}#${e.pr} already has a review outstanding`];
655
+ if (threads > 0) {
656
+ parts.push(`${threads} unresolved review thread${threads === 1 ? "" : "s"}`);
657
+ }
658
+ if (e.review_decision && e.review_decision !== "unknown") {
659
+ parts.push(`review_decision=${e.review_decision}`);
660
+ }
661
+ const age = relativeAge(e.pr_updated_at, now);
662
+ if (age) parts.push(`last update ${age}`);
663
+ lines.push(
664
+ `${parts.join(" · ")}. This wait fires only on the NEXT review event; ` +
665
+ `the existing review will not re-wake it.`,
666
+ );
667
+ }
668
+ return lines;
669
+ }
670
+
614
671
  // ---------------------------------------------------------------------------
615
672
  // Matching — does a spine signal frame satisfy a condition?
616
673
  // ---------------------------------------------------------------------------
package/src/wait.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // Usage: botbuddy wait [--any] <condition>... [options]
14
14
  // Run botbuddy wait --help for the condition grammar.
15
15
 
16
- import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt } from "./wait-core.mjs";
16
+ import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings } from "./wait-core.mjs";
17
17
  import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
@@ -52,7 +52,12 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
52
52
  flip sets payload.mergeable_changed (no new condition).
53
53
  pr-review:repo=<owner/repo>[,pr=<n>]
54
54
  a new review comment, or a thread resolved/re-opened
55
- (payload carries the unresolved-thread count)
55
+ (payload carries the unresolved-thread count).
56
+ Edge-triggered: it wakes only on review activity AFTER
57
+ arm. If the PR ALREADY has an outstanding review at arm,
58
+ bb-wait prints a one-line stderr notice (unresolved count,
59
+ review_decision, age) so a silent park isn't mistaken for
60
+ "no review yet" — the existing review will not re-wake it.
56
61
  test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
57
62
  lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
58
63
  becoming active — the zero-poll wake for a parked
@@ -334,6 +339,10 @@ async function registerWait(opts, conditions, deadlineIso) {
334
339
  // echoes session_agent_id only when it overrode the profile agent).
335
340
  sessionId: body.session_id ?? null,
336
341
  sessionAgentId: body.session_agent_id ?? null,
342
+ // BOT-1539: arm-time snapshot of already-outstanding code review for any
343
+ // pr-review/pr-state target. Report-only — surfaced on stderr so a silent
344
+ // edge-triggered park isn't mistaken for "no review yet".
345
+ prReviewSnapshot: Array.isArray(body.pr_review_snapshot) ? body.pr_review_snapshot : null,
337
346
  };
338
347
  }
339
348
 
@@ -618,6 +627,12 @@ export async function runWait(argv) {
618
627
  registeredAgentId = reg.agentId;
619
628
  registeredSessionAgentId = reg.sessionAgentId;
620
629
  registeredSessionId = reg.sessionId;
630
+ // BOT-1539: if any pr-review/pr-state target already has an outstanding
631
+ // review, tell the operator now (stderr, one line per PR). Purely
632
+ // informational — it does not change arming or exit semantics.
633
+ for (const line of formatPrReviewSnapshotWarnings(reg.prReviewSnapshot)) {
634
+ process.stderr.write(`bb-wait: ${line}\n`);
635
+ }
621
636
  // BOT-1184: adopt the server's canonical host for each lock condition so the
622
637
  // local matcher builds the same subject_key the availability/claim-grant
623
638
  // signals carry (armed under an alias like 'jono-mac', the signal uses the