@botbuddy/cli 1.35.0 → 1.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/wait-core.mjs CHANGED
@@ -132,8 +132,8 @@ const VALIDATORS = {
132
132
  claim,
133
133
  };
134
134
  },
135
- "pr-state": (p) => parsePrTarget(p, "pr-state"),
136
- "pr-review": (p) => parsePrTarget(p, "pr-review"),
135
+ "pr-state": (p) => parsePrCondition(p, "pr-state"),
136
+ "pr-review": (p) => parsePrCondition(p, "pr-review"),
137
137
  // BOT-1063 — test-run: park on a test_run reaching a terminal status. The
138
138
  // spine subject_key is `run:<id>` (mirrors the run:<id> convention noted on
139
139
  // agent_signal_events.subject_key), so a condition just names the run id.
@@ -354,6 +354,46 @@ function parsePrTarget(p, label) {
354
354
  return { repo, number: number != null && number !== "" ? number : null };
355
355
  }
356
356
 
357
+ // BOT-1975 / BOT-1972 — the pr-state / pr-review key allowlist. Before this an
358
+ // unknown key (a typo'd `shaa=`, or `sha=` / `reviewer=` on a client that did not
359
+ // implement them) was silently discarded and the wait armed WIDER than asked —
360
+ // it then woke on another head's or another reviewer's signal. Now an unknown key
361
+ // refuses at arm (invalid_conditions, exit 4). `reviewer=` is pr-review only.
362
+ const PR_CONDITION_KEYS = Object.freeze({
363
+ "pr-state": Object.freeze(["repo", "pr", "number", "sha"]),
364
+ "pr-review": Object.freeze(["repo", "pr", "number", "sha", "reviewer"]),
365
+ });
366
+
367
+ // A GitHub login compared case-insensitively, with the App suffix `[bot]` stripped
368
+ // (the webhook reports `chatgpt-codex-connector[bot]`; humans type it either way).
369
+ function normalizeReviewerLogin(login) {
370
+ return String(login).trim().toLowerCase().replace(/\[bot\]$/, "");
371
+ }
372
+
373
+ function parsePrCondition(p, label) {
374
+ const allowed = PR_CONDITION_KEYS[label];
375
+ const unknown = Object.keys(p).filter((key) => !allowed.includes(key));
376
+ if (unknown.length > 0) {
377
+ throw new Error(`${label} unknown key '${unknown[0]}' (allowed: ${allowed.join(", ")})`);
378
+ }
379
+ const out = parsePrTarget(p, label);
380
+ // Optional keys are added ONLY when given, so a plain wait's params (sent to the
381
+ // relay and saved in the local checkpoint) are byte-identical to before.
382
+ if (p.sha !== undefined) {
383
+ const sha = typeof p.sha === "string" ? p.sha.trim().toLowerCase() : "";
384
+ if (!/^[0-9a-f]{7,40}$/.test(sha)) {
385
+ throw new Error(`${label} sha= must be 7–40 hex characters (a commit SHA or its prefix)`);
386
+ }
387
+ out.sha = sha;
388
+ }
389
+ if (p.reviewer !== undefined) {
390
+ const reviewer = typeof p.reviewer === "string" ? normalizeReviewerLogin(p.reviewer) : "";
391
+ if (reviewer === "") throw new Error(`${label} reviewer= must be a GitHub login`);
392
+ out.reviewer = reviewer;
393
+ }
394
+ return out;
395
+ }
396
+
357
397
  // Does a spine subject_key (`owner/repo#number`) satisfy a pr target?
358
398
  function prSubjectMatches(subjectKey, params) {
359
399
  if (typeof subjectKey !== "string") return false;
@@ -361,6 +401,35 @@ function prSubjectMatches(subjectKey, params) {
361
401
  return subjectKey.startsWith(`${params.repo}#`);
362
402
  }
363
403
 
404
+ // Does a commit field on a signal payload start with the condition's sha prefix?
405
+ // A missing / non-string field never matches a sha-scoped condition.
406
+ function shaPrefixMatches(value, sha) {
407
+ return typeof value === "string" && value.trim().toLowerCase().startsWith(sha);
408
+ }
409
+
410
+ // BOT-1975: optional head scoping for pr-state. pr_state signals carry the PR
411
+ // head as payload.head_ref_oid; a signal for a previous head does not wake.
412
+ function prStateFiltersMatch(signal, params) {
413
+ if (params.sha == null) return true;
414
+ return shaPrefixMatches(signal.payload?.head_ref_oid, params.sha);
415
+ }
416
+
417
+ // BOT-1975 / BOT-1972: optional commit + reviewer scoping for pr-review. A
418
+ // `review_unavailable` signal (the Codex usage-limit decline) names no commit, so
419
+ // it always passes the sha filter — the waiter must learn no review is coming.
420
+ function prReviewFiltersMatch(signal, params) {
421
+ const payload = signal.payload ?? {};
422
+ if (params.sha != null && payload.transition !== "review_unavailable" &&
423
+ !shaPrefixMatches(payload.commit_sha, params.sha)) {
424
+ return false;
425
+ }
426
+ if (params.reviewer != null) {
427
+ if (typeof payload.reviewer_login !== "string") return false;
428
+ if (normalizeReviewerLogin(payload.reviewer_login) !== params.reviewer) return false;
429
+ }
430
+ return true;
431
+ }
432
+
364
433
  /**
365
434
  * Parse and strictly validate a list of `TYPE:params` condition specs.
366
435
  *
@@ -777,9 +846,11 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
777
846
  return signal.subject_key.startsWith(`${params.subtype}:${params.host}:`);
778
847
  }
779
848
  case "pr-state":
780
- return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params);
849
+ return signal.signal_type === "pr_state" && prSubjectMatches(signal.subject_key, params) &&
850
+ prStateFiltersMatch(signal, params);
781
851
  case "pr-review":
782
- return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params);
852
+ return signal.signal_type === "pr_review" && prSubjectMatches(signal.subject_key, params) &&
853
+ prReviewFiltersMatch(signal, params);
783
854
  case "test-run":
784
855
  return signal.signal_type === "test_run" && signal.subject_key === `run:${params.runId}`;
785
856
  case "guidance":
package/src/wait.mjs CHANGED
@@ -71,19 +71,35 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
71
71
  Must be the ONLY condition (use --timeout to bound it); can't be
72
72
  raced against other conditions. Omit claim to just wait for
73
73
  availability and acquire it yourself.
74
- pr-state:repo=<owner/repo>[,pr=<n>] a PR state transition (open/draft/merged/closed,
75
- checks, review decision, unresolved threads, mergeable).
76
- Omit pr= to wake on ANY of your PRs in the repo;
77
- or pass pr=<owner/repo#n> in one value. A conflict/clean
78
- flip sets payload.mergeable_changed (no new condition).
79
- pr-review:repo=<owner/repo>[,pr=<n>]
80
- a new review comment, or a thread resolved/re-opened
81
- (payload carries the unresolved-thread count).
74
+ pr-state:repo=<owner/repo>[,pr=<n>][,sha=<head>]
75
+ a PR lifecycle or mergeable flip (draft<->ready, merged,
76
+ closed, conflict/clean flip) plus check-rollup, review-
77
+ decision and unresolved-thread-count changes, as projected
78
+ by the PR read model. Omit pr= to wake on ANY of your PRs
79
+ in the repo; or pass pr=<owner/repo#n> in one value. A
80
+ conflict/clean flip sets payload.mergeable_changed. The
81
+ payload carries head_ref_oid; sha=<head> (7-40 hex, a
82
+ prefix is fine) wakes only on signals for that head, so a
83
+ previous head's check result can't wake you. A push alone
84
+ emits nothing — the head rides on the next transition.
85
+ pr-review:repo=<owner/repo>[,pr=<n>][,reviewer=<login>][,sha=<commit>]
86
+ a submitted review, a new review comment, a thread
87
+ resolved/re-opened (payload carries the unresolved-thread
88
+ count), a Codex clean pass (payload.transition=clean_pass)
89
+ or the Codex usage-limit decline (transition=
90
+ review_unavailable — no review is coming).
82
91
  Edge-triggered: it wakes only on review activity AFTER
83
- arm. If the PR ALREADY has an outstanding review at arm,
84
- bb-wait prints a one-line stderr notice (unresolved count,
85
- review_decision, age) so a silent park isn't mistaken for
86
- "no review yet" — the existing review will not re-wake it.
92
+ arm, so arm BEFORE requesting the review. If the PR ALREADY
93
+ has an outstanding review at arm, bb-wait prints a one-line
94
+ stderr notice (unresolved count, review_decision, age) so a
95
+ silent park isn't mistaken for "no review yet" — the
96
+ existing review will not re-wake it.
97
+ reviewer=<login> wakes only on that reviewer (case-
98
+ insensitive; a trailing [bot] is ignored). sha=<commit>
99
+ wakes only on verdicts for that commit (submitted reviews
100
+ and clean passes carry commit_sha; thread comments do not,
101
+ so they never match sha=); the usage-limit decline always
102
+ passes sha=. Any other key is refused (exit 4).
87
103
  test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
88
104
  lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
89
105
  becoming active — the zero-poll wake for a parked
@@ -113,7 +129,8 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
113
129
  the ranking (first-come). Needs cli >= 1.20.0.
114
130
  ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
115
131
  | scope=next[,sha=<sha>][,branch=<name>][,workflow=<name>]}
116
- a PR's CI reaching a terminal conclusion (owner-scoped);
132
+ a run's terminal conclusion (owner-scoped) — not a
133
+ PR state or review change (use pr-state / pr-review);
117
134
  GitHub Actions or an external check_suite provider.
118
135
  A repo with no CI feed is rejected (no_signal_source).
119
136
  scope=next is LIVE-ONLY: it wakes on the first terminal
@@ -154,7 +171,9 @@ AUTH
154
171
 
155
172
  OUTPUT
156
173
  Exactly one JSON receipt line on stdout at exit, with client semver and
157
- protocol identity. Diagnostics go to stderr.
174
+ protocol identity. Diagnostics go to stderr — including the arm-time notices
175
+ (outstanding review, latest CI run). Do not pipe bb wait through tail: the
176
+ notice is on stderr and you will not see it until the wait exits.
158
177
  Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
159
178
  5 backend unavailable · 6 cursor expired · 7 internal ·
160
179
  8 superseded (wait replaced server-side). If the receipt has