@botbuddy/cli 1.30.1 → 1.31.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.30.1",
3
+ "version": "1.31.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -323,8 +323,16 @@ function gitHint(cwd, args) {
323
323
  catch { return null; }
324
324
  }
325
325
 
326
- /** Best-effort display-only context. It is never an authorization input. */
327
- export function deriveCallerHint({ env = process.env, cwd = process.cwd() } = {}) {
326
+ /**
327
+ * Best-effort display-only context. It is never an authorization input.
328
+ *
329
+ * `git` is injectable so a test can exercise the branch/remote parsing without
330
+ * depending on the ambient checkout: a detached-HEAD checkout (the npm publish
331
+ * lane checks out the promoted SHA, not a branch) has no branch name, which used
332
+ * to make the branch-recognition assertion fail only in CI (BOT-1649 publish run
333
+ * 35328434334). Production still defaults to the real git resolver.
334
+ */
335
+ export function deriveCallerHint({ env = process.env, cwd = process.cwd(), git = gitHint } = {}) {
328
336
  let cachedAgentId = null;
329
337
  try {
330
338
  const path = join(cwd, ".botbuddy", "agent-state.json");
@@ -333,9 +341,9 @@ export function deriveCallerHint({ env = process.env, cwd = process.cwd() } = {}
333
341
  cachedAgentId = typeof parsed?.agent_id === "string" && parsed.agent_id ? parsed.agent_id : null;
334
342
  }
335
343
  } catch { /* a malformed cache is not a login failure */ }
336
- const branch = gitHint(cwd, ["branch", "--show-current"]);
344
+ const branch = git(cwd, ["branch", "--show-current"]);
337
345
  const ticket = branch?.match(/(?:^|\/)((?:bot|ent)-\d+)(?:-|$)/i)?.[1]?.toUpperCase() ?? null;
338
- const remote = gitHint(cwd, ["remote", "get-url", "origin"]);
346
+ const remote = git(cwd, ["remote", "get-url", "origin"]);
339
347
  const repo = remote?.match(/(?:github\.com[:/])([^/]+\/[^/.]+)(?:\.git)?$/i)?.[1] ?? null;
340
348
  const harness = env.BOTBUDDY_CALLER_HARNESS
341
349
  || (env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : null);
package/src/wait-core.mjs CHANGED
@@ -196,16 +196,26 @@ const VALIDATORS = {
196
196
  // BOT-1066 — ci: wake when a PR's CI reaches a terminal conclusion. Exactly one
197
197
  // selector: scope=latest (follows the PR's latest run — needs pr=), run_id=<id>
198
198
  // (pinned to one run), or sha=<sha> (pinned to a commit). repo= is always required.
199
- // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run, sha,
200
- // or PR (they must all already exist at arm time) and wakes on the first terminal
201
- // CI run for the repo delivered AFTER the wait was armed — the post-merge / cron
202
- // train case. Optional branch=/workflow= filter that first run; both are valid
203
- // ONLY with scope=next (a run_id/sha/latest wait already names its run).
199
+ // BOT-1507 — scope=next adds a fourth, LIVE-ONLY selector: it names no run or PR
200
+ // (they must already exist at arm time) and wakes on the first terminal CI run for
201
+ // the repo delivered AFTER the wait was armed — the post-merge / cron train case.
202
+ // Optional branch=/workflow= filter that first run; both are valid ONLY with
203
+ // scope=next (a run_id/sha/latest wait already names its run).
204
+ // BOT-1685 — scope=next also accepts an optional sha= FILTER (distinct from the
205
+ // level-triggered sha= selector): a wait armed BEFORE re-dispatching a run for a
206
+ // known commit then wakes on the NEXT terminal run for THAT commit, instead of
207
+ // replaying an earlier successful run for the same sha.
204
208
  ci(p) {
205
209
  const repo = p.repo != null ? String(p.repo).trim() : null;
206
210
  if (!repo || !repo.includes("/")) throw new Error("ci needs repo=<owner/repo>");
207
211
  const selectors = ["scope", "run_id", "sha"].filter((k) => p[k] !== undefined);
208
- if (selectors.length !== 1) {
212
+ // BOT-1685: scope=next accepts sha= as a live FILTER (like branch=/workflow=),
213
+ // not as a second selector — a wait armed BEFORE re-dispatching a run for a
214
+ // known commit then wakes on the NEXT terminal run for that commit rather than
215
+ // replaying an earlier terminal run (which the level-triggered `sha=` selector
216
+ // does by design). Exclude that filter sha from the single-selector count.
217
+ const shaIsNextFilter = p.scope === "next" && p.sha !== undefined;
218
+ if ((shaIsNextFilter ? selectors.length - 1 : selectors.length) !== 1) {
209
219
  throw new Error("ci needs exactly one of scope=latest|next | run_id=<id> | sha=<sha>");
210
220
  }
211
221
  const hasFilter = p.branch !== undefined || p.workflow !== undefined;
@@ -229,7 +239,7 @@ const VALIDATORS = {
229
239
  if (v === "") throw new Error(`ci ${key}= must be non-empty`);
230
240
  return v;
231
241
  };
232
- return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), pr: null };
242
+ return { repo, scope: "next", branch: filter("branch"), workflow: filter("workflow"), sha: filter("sha"), pr: null };
233
243
  }
234
244
  throw new Error("ci scope must be 'latest' or 'next' (or use run_id=/sha=)");
235
245
  }
@@ -804,6 +814,9 @@ function conditionMatchesSignal(condition, signal, waitSessionId) {
804
814
  // branch is exact, workflow is the GitHub display name (case/space-insensitive).
805
815
  const conclusion = signal.payload?.conclusion;
806
816
  if (typeof conclusion !== "string" || conclusion.trim() === "") return false;
817
+ // BOT-1685: an optional sha filter pins scope=next to a commit, so a
818
+ // re-dispatch wait wakes only on the terminal run for that commit.
819
+ if (params.sha != null && signal.payload?.head_sha !== params.sha) return false;
807
820
  if (params.branch != null && signal.payload?.head_branch !== params.branch) return false;
808
821
  if (params.workflow != null) {
809
822
  const wf = signal.payload?.workflow_name;
package/src/wait.mjs CHANGED
@@ -106,15 +106,18 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
106
106
  the others keep parking. Older servers broadcast without
107
107
  the ranking (first-come). Needs cli >= 1.20.0.
108
108
  ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>
109
- | scope=next[,branch=<name>][,workflow=<name>]}
109
+ | scope=next[,sha=<sha>][,branch=<name>][,workflow=<name>]}
110
110
  a PR's CI reaching a terminal conclusion (owner-scoped);
111
111
  GitHub Actions or an external check_suite provider.
112
112
  A repo with no CI feed is rejected (no_signal_source).
113
113
  scope=next is LIVE-ONLY: it wakes on the first terminal
114
114
  run delivered AFTER arm (post-merge / cron / scheduled),
115
- never replays a finished run, and needs no run_id/sha/pr.
116
- Optional branch= (exact) / workflow= (GitHub workflow
117
- DISPLAY name, case-insensitive) narrow that first run.
115
+ never replays a finished run, and needs no run_id/pr.
116
+ Optional sha= pins it to a commit (BOT-1685: arm before
117
+ re-dispatching a run for a known SHA so it wakes on the
118
+ NEW run, not an earlier terminal run for the same SHA);
119
+ branch= (exact) / workflow= (GitHub workflow DISPLAY
120
+ name, case-insensitive) narrow that first run too.
118
121
  staging-green:repo=<owner/repo> verified automatic recovery of an enabled staging gate.
119
122
  Wakes only after a complete non-vacuous workflow success;
120
123
  manual resolutions, overrides, skipped, and neutral runs do not match.