@botbuddy/cli 1.60.6 → 1.60.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.6",
3
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
4
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
2
+ "release_version": "1.60.7",
3
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
4
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
5
5
  "target": "darwin-arm64",
6
- "sha256": "2cd38dbe8411dd7499107065d3c526bd171f9edc37bc820aa79ab2c5cead27ec"
6
+ "sha256": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.6",
3
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
4
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
2
+ "release_version": "1.60.7",
3
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
4
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
5
5
  "target": "linux-x64",
6
- "sha256": "4d422182d63d0ee3ac38bf2823d255fd059ca5195efda355681e8f94137eaf7a"
6
+ "sha256": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.6",
4
- "source_commit": "ae130a31e09d0752d2f395aac02d51fa89e0c93f",
5
- "source_sha256": "7c1fe3ec9d1d28f25c665e4f607690dc512fabf0a9f1eebaf1c86e1611382edf",
3
+ "release_version": "1.60.7",
4
+ "source_commit": "6299d1f0f325871a33f5aa28bff8dc8029e7350c",
5
+ "source_sha256": "ac9f92aeaec3cf0cb18de06f1e16b0ee2c44c413ec7c84866f949909611098f1",
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": "2cd38dbe8411dd7499107065d3c526bd171f9edc37bc820aa79ab2c5cead27ec",
11
+ "sha256": "1e24ee9c3fa9ac6447ff9f0c8096d80c4e2a98348d0e3b0277d406dfe8109e17",
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": "4d422182d63d0ee3ac38bf2823d255fd059ca5195efda355681e8f94137eaf7a",
18
+ "sha256": "68dfb56ddf1b4647dcd00644d88489fe2022b4136d72443e33f83a86ecb270bb",
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.60.6",
3
+ "version": "1.60.7",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/pw/run.mjs CHANGED
@@ -190,7 +190,10 @@ function consumeWrapperFlags(list, deps, start = 0) {
190
190
  }
191
191
 
192
192
  // The non-secret values a staging-auth refusal names in its next command.
193
- const authVars = (plan) => ({ lane: plan.lane, persona: plan.persona });
193
+ const authVars = (plan, deps = {}) => {
194
+ const cwd = deps.cwd ?? process.cwd();
195
+ return { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: stagingAuth.ticketFlagFor(cwd) };
196
+ };
194
197
 
195
198
  // `bb pw auth --help` / `bb pw [flags] <lane> auth --help`: auth's own help, before
196
199
  // any credential, lock or daemon call.
@@ -229,8 +232,8 @@ async function runPwInner(argv, deps = {}) {
229
232
  // local mode — before any credential, lock or issuer call.
230
233
  if (plan.mode === "auth") {
231
234
  const authEnvironment = resolveEnvironment({ flag: deps.environment ?? null, env });
232
- if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan));
233
- if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan));
235
+ if (authEnvironment.error || authEnvironment.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_staging_environment", 2, {}, authVars(plan, deps));
236
+ if (env.BB_PW_NO_LOCK === "1") return stagingAuth.refuse({ stdout, stderr }, "auth_requires_lane_lock", 3, {}, authVars(plan, deps));
234
237
  }
235
238
  const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
236
239
  if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
@@ -278,7 +281,7 @@ async function runPwInner(argv, deps = {}) {
278
281
  // A lane that is provably no longer ours (not a transient verify error):
279
282
  // clear the session we installed and revoke its custody (AC-3).
280
283
  if (auth.reason === "not_held" || auth.reason === "held_by_other") await stagingAuth.onLaneAuthorityLost(authCtx).catch(() => {});
281
- if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan));
284
+ if (plan.mode === "auth" && (auth.reason === "not_held" || auth.reason === "held_by_other")) return stagingAuth.refuse({ stdout, stderr }, "lane_not_claimed", 3, { detail: auth.message }, authVars(plan, deps));
282
285
  stderr.write(`${auth.message}\n`); return 3;
283
286
  }
284
287
  // Report the host the lock service actually stored (alias-collapsed), so the
@@ -305,10 +308,10 @@ async function runPwInner(argv, deps = {}) {
305
308
  const me = await authCtx.resolveCallerId().catch(() => null);
306
309
  if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
307
310
  }
308
- if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan));
311
+ if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
309
312
  if (tracker) {
310
313
  const browserSession = await tracker.status();
311
- if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan));
314
+ if (browserSession.active && browserSession.environment && browserSession.environment !== "staging") return stagingAuth.refuse({ stdout, stderr }, "browser_session_not_staging", 2, {}, authVars(plan, deps));
312
315
  }
313
316
  return stagingAuth.runStagingAuth(authCtx);
314
317
  }
@@ -15,6 +15,7 @@
15
15
  // clear that fails blocks the lane until it is resolved.
16
16
  // * The ledger (~/.botbuddy/pw-staging-auth/<host>/lane-<n>.json, 0600) holds
17
17
  // non-secret install records only.
18
+ import { execFileSync } from "node:child_process";
18
19
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
19
20
  import { homedir } from "node:os";
20
21
  import { dirname, join } from "node:path";
@@ -229,8 +230,24 @@ const GUIDED_FIELDS = ["message", "cause", "actor", "next", "doc", "help", "agen
229
230
  * `extra` fields already rendered by the pool (an empty pool's health reason) win.
230
231
  * `vars` are non-secret only: lane, persona alias, allowed aliases, checkout.
231
232
  */
233
+ /**
234
+ * ` --ticket BOT-123` from the checkout's branch name (`…/bot-123-…`), or "" so a
235
+ * lane-claim command never prints a <ticket> placeholder. Best effort, refusal-only.
236
+ */
237
+ export function ticketFlagFor(cwd, { exec = execFileSync } = {}) {
238
+ try {
239
+ // symbolic-ref also names an unborn branch; a detached HEAD has no ticket.
240
+ const branch = String(exec("git", ["-C", cwd, "symbolic-ref", "--short", "-q", "HEAD"], { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] })).trim();
241
+ const match = /(?:^|[/_-])((?:bot|ent)-\d+)(?=$|[/_-])/i.exec(branch);
242
+ return match ? ` --ticket ${match[1].toUpperCase()}` : "";
243
+ } catch {
244
+ return "";
245
+ }
246
+ }
247
+
232
248
  export function refuse({ stdout, stderr }, code, exitCode = 3, extra = {}, vars = {}) {
233
- const g = guidance(isKnownCode(code) ? code : "pool_take_failed", vars);
249
+ // A real refusal: never a <placeholder> (AC-8 follow-up).
250
+ const g = guidance(isKnownCode(code) ? code : "pool_take_failed", { consequence: "bb pw auth has no pool to take a session from", ...vars }, { real: true });
234
251
  const guided = { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help, agentAction: g.agent_action, remedy: remedyLine(g) };
235
252
  for (const field of GUIDED_FIELDS) if (extra[field] !== undefined) guided[field] = extra[field];
236
253
  const text = {
@@ -256,15 +273,20 @@ export async function runStagingAuth({ plan, host, env, deps, socketRun, resolve
256
273
  const poolModule = deps.stagingPoolModule ?? await loadPool();
257
274
  const poolOptions = deps.stagingPool ?? {};
258
275
  const cwd = deps.cwd ?? process.cwd();
259
- const vars = { lane: plan.lane, persona: plan.persona, checkout: cwd };
276
+ const vars = { lane: plan.lane, persona: plan.persona, cwd, ticketFlag: ticketFlagFor(cwd) };
260
277
  let found;
261
278
  try {
262
279
  found = await (await loadBinding()).readRepoPoolBinding(cwd);
263
280
  } catch (error) {
264
281
  return refuse(io, error?.code === "pool_binding_invalid" ? "pool_binding_invalid" : "pool_not_configured", 3, { detail: String(error?.message ?? error) }, vars);
265
282
  }
266
- if (!found) return refuse(io, "pool_not_configured", 3, {}, vars);
267
- Object.assign(vars, { checkout: found.repoRoot ?? cwd, allowed: Object.keys(found.binding.personas ?? {}) });
283
+ if (!found) {
284
+ // Name a stable checkout that declares a pool, if this Mac has one on record.
285
+ const { findKnownCheckouts } = await import("../staging-pool/checkouts.mjs");
286
+ const [candidate] = await findKnownCheckouts({ ...(poolOptions.home ? { home: poolOptions.home } : {}), ...(poolOptions.isStableCheckout ? { isStable: poolOptions.isStableCheckout } : {}) }).catch(() => []);
287
+ return refuse(io, "pool_not_configured", 3, {}, { ...vars, checkout: candidate?.path });
288
+ }
289
+ Object.assign(vars, { allowed: Object.keys(found.binding.personas ?? {}) });
268
290
  const { findPersona } = await loadBinding();
269
291
  const persona = findPersona(found.binding, plan.persona);
270
292
  if (!persona) return refuse(io, "persona_not_allowlisted", 2, {}, vars);
@@ -0,0 +1,120 @@
1
+ // BOT-2084 (AC-8 follow-up): which repository checkouts on this Mac declare a
2
+ // staging_session_pool, so a refusal run from the wrong directory (e.g. `~`) can
3
+ // name a concrete `cd <path>` instead of a placeholder.
4
+ //
5
+ // Sources, both non-secret paths only:
6
+ // * every installed pool's config.json `checkout` (written by install);
7
+ // * ~/.botbuddy/staging-session-pool/known-checkouts.json (0600), updated each
8
+ // time a bb staging-pool / bb pw auth command resolves a binding from a cwd.
9
+ // A candidate counts only if it still exists and still declares a pool binding.
10
+ // Node-stdlib-only and self-contained (bundled with the installed job).
11
+ import { chmodSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join, sep } from "node:path";
14
+
15
+ import { readRepoPoolBinding } from "./binding.mjs";
16
+
17
+ const MAX_KNOWN = 20;
18
+
19
+ const linkedWorktree = (path) => {
20
+ try {
21
+ return statSync(join(path, ".git")).isFile();
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+ const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
27
+ // Path segments that mark agent, scratch or disposable checkouts.
28
+ const DISPOSABLE_SEGMENTS = new Set(["worktrees", "scratch-workspaces"]);
29
+
30
+ /**
31
+ * A checkout a human can be told to `cd` into: not under a temp dir, not an agent
32
+ * or scratch worktree (a `worktrees` / `scratch-workspaces` path segment), and not
33
+ * a linked git worktree (`.git` is a file). Remedies addressed to a human never
34
+ * embed any other path (AC-8 follow-up, found on staging acceptance).
35
+ */
36
+ export function isStableCheckout(path, { tmp = tmpdir(), gitFileIsWorktree = linkedWorktree } = {}) {
37
+ if (typeof path !== "string" || !path.startsWith("/")) return false;
38
+ const roots = [...TEMP_ROOTS, tmp];
39
+ try {
40
+ roots.push(realpathSync(tmp));
41
+ } catch {
42
+ // tmp may not resolve in a test sandbox
43
+ }
44
+ for (const root of roots) {
45
+ const base = String(root).replace(/\/+$/, "");
46
+ if (base && (path === base || path.startsWith(`${base}/`))) return false;
47
+ }
48
+ if (path.split(sep).some((segment) => DISPOSABLE_SEGMENTS.has(segment))) return false;
49
+ return !gitFileIsWorktree(path);
50
+ }
51
+ const poolsRoot = (home) => join(home, ".botbuddy", "staging-session-pool");
52
+ export const knownCheckoutsPath = (home = homedir()) => join(poolsRoot(home), "known-checkouts.json");
53
+
54
+ function readKnown(home) {
55
+ try {
56
+ const data = JSON.parse(readFileSync(knownCheckoutsPath(home), "utf8"));
57
+ return Array.isArray(data?.checkouts) ? data.checkouts.filter((entry) => typeof entry?.path === "string" && entry.path.startsWith("/")) : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ /** Best effort: remember a checkout that resolved a binding. Never throws. */
64
+ export function recordCheckout({ home = homedir(), path, poolId, now = Date.now() } = {}) {
65
+ try {
66
+ if (typeof path !== "string" || !path.startsWith("/")) return;
67
+ const entries = [{ path, pool_id: poolId ?? null, seen_at: new Date(now).toISOString() }, ...readKnown(home).filter((entry) => entry.path !== path)].slice(0, MAX_KNOWN);
68
+ mkdirSync(poolsRoot(home), { recursive: true, mode: 0o700 });
69
+ const file = knownCheckoutsPath(home);
70
+ const tmp = `${file}.${process.pid}.tmp`;
71
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, checkouts: entries }, null, 2)}\n`, { mode: 0o600 });
72
+ chmodSync(tmp, 0o600);
73
+ renameSync(tmp, file);
74
+ } catch {
75
+ // A refusal's hint must never fail the command that produced it.
76
+ }
77
+ }
78
+
79
+ function installedCheckouts(home) {
80
+ const found = [];
81
+ let names = [];
82
+ try {
83
+ names = readdirSync(poolsRoot(home));
84
+ } catch {
85
+ return found;
86
+ }
87
+ for (const name of names) {
88
+ try {
89
+ const config = JSON.parse(readFileSync(join(poolsRoot(home), name, "config.json"), "utf8"));
90
+ if (typeof config?.checkout === "string" && config.checkout.startsWith("/")) found.push({ path: config.checkout, pool_id: name, seen_at: config.installedAt ?? null });
91
+ } catch {
92
+ // not installed, or unreadable: not a candidate
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ /**
99
+ * Checkouts that still declare a staging_session_pool, most relevant first:
100
+ * installed pools' checkouts, then the most recently seen. `poolId` narrows to
101
+ * one pool when it is known.
102
+ */
103
+ export async function findKnownCheckouts({ home = homedir(), poolId = null, readBinding = readRepoPoolBinding, isStable = isStableCheckout } = {}) {
104
+ const seen = new Set();
105
+ const result = [];
106
+ for (const entry of [...installedCheckouts(home), ...readKnown(home)]) {
107
+ if (seen.has(entry.path)) continue;
108
+ seen.add(entry.path);
109
+ if (!isStable(entry.path)) continue;
110
+ try {
111
+ const found = await readBinding(entry.path);
112
+ if (!found || found.repoRoot !== entry.path) continue;
113
+ if (poolId && found.binding.poolId !== poolId) continue;
114
+ result.push({ path: entry.path, poolId: found.binding.poolId });
115
+ } catch {
116
+ // gone, or no longer a valid binding
117
+ }
118
+ }
119
+ return result;
120
+ }
@@ -34,11 +34,13 @@ import { PoolEmptyError, createSessionPool } from "./pool.mjs";
34
34
  import { createGoTrueTotpSignIn } from "./signin-gotrue-totp.mjs";
35
35
  import { createRedactor } from "./state-file.mjs";
36
36
  import { createBotBuddyTotpProvider, probeBotBuddy } from "./totp-botbuddy.mjs";
37
- import { formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
37
+ import { HUMAN_ONLY_COMMANDS, formatGuidance, guidance, isKnownCode, remedyLine } from "./errors.mjs";
38
+ import { findKnownCheckouts, isStableCheckout as defaultIsStableCheckout, recordCheckout } from "./checkouts.mjs";
38
39
  import { STAGING_POOL_HELP } from "./help.mjs";
39
40
 
40
41
  export const BUNDLE_FILES = Object.freeze([
41
42
  "binding.mjs",
43
+ "checkouts.mjs",
42
44
  "cli.mjs",
43
45
  "config.mjs",
44
46
  "errors.mjs",
@@ -86,29 +88,50 @@ export function exitCodeFor(error) {
86
88
 
87
89
  // Where this invocation's pool lives: the job passes --home; everyone else
88
90
  // resolves it from the repo binding (cwd).
89
- async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding }) {
91
+ // The checkout a human-facing remedy may name: this repo root or the one install
92
+ // recorded, if stable; else a stable checkout seen earlier for this pool; else none.
93
+ async function stableCheckoutFor({ repoRoot, installed, poolId, home, readBinding, isStable }) {
94
+ if (repoRoot && isStable(repoRoot)) return repoRoot;
95
+ // The install-recorded checkout may since have moved or been deleted: name it only
96
+ // while it still declares this pool's binding.
97
+ const recorded = installed?.checkout;
98
+ if (recorded && isStable(recorded)) {
99
+ const found = await readBinding(recorded).catch(() => null);
100
+ if (found && found.repoRoot === recorded && found.binding.poolId === poolId) return recorded;
101
+ }
102
+ const [candidate] = await findKnownCheckouts({ home, poolId, readBinding, isStable }).catch(() => []);
103
+ return candidate?.path;
104
+ }
105
+
106
+ async function resolveContext({ options, cwd, home, readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
90
107
  if (options.home) {
91
108
  const paths = poolHomePaths({ home, root: options.home });
92
109
  const config = readInstalledConfig(paths.config);
93
- return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding };
110
+ const stableCheckout = await stableCheckoutFor({ installed: config, poolId: config.poolBinding.poolId, home, readBinding, isStable: isStableCheckout });
111
+ return { paths, poolBinding: config.poolBinding, installed: config, rawBinding: config.binding, stableCheckout };
94
112
  }
95
113
  const found = await readBinding(cwd);
96
114
  if (!found) {
97
- throw new ConfigError("This repository declares no staging_session_pool in .botbuddy-agent.json, so it has no staging session pool.", "pool_not_configured");
115
+ throw new ConfigError(`${cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool.`, "pool_not_configured");
98
116
  }
99
117
  const paths = poolHomePaths({ home, poolId: found.binding.poolId });
118
+ // Remembered so a later refusal run from the wrong directory can name this checkout.
119
+ recordCheckout({ home, path: found.repoRoot, poolId: found.binding.poolId });
100
120
  let installed = null;
101
121
  try {
102
122
  installed = readInstalledConfig(paths.config);
103
123
  } catch (error) {
104
124
  if (error?.code !== "config_missing") installed = { error };
105
125
  }
106
- return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot };
126
+ const stableCheckout = await stableCheckoutFor({ repoRoot: found.repoRoot, installed: installed?.error ? null : installed, poolId: found.binding.poolId, home, readBinding, isStable: isStableCheckout });
127
+ return { paths, poolBinding: found.binding, installed, repoRoot: found.repoRoot, stableCheckout };
107
128
  }
108
129
 
109
130
  const shellWord = (value) => (/^[A-Za-z0-9_./:@,=~+-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
110
- export const installCommandFor = (repoRoot, fallback) =>
111
- repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : fallback;
131
+ // No checkout known → undefined, so a real refusal says where to run it instead of
132
+ // printing the binding's `<your … checkout>` placeholder.
133
+ export const installCommandFor = (repoRoot) =>
134
+ repoRoot ? `cd ${shellWord(repoRoot)} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"` : undefined;
112
135
 
113
136
  // The non-secret values every guidance template may name.
114
137
  export function guidanceVars(ctx, extra = {}) {
@@ -116,12 +139,12 @@ export function guidanceVars(ctx, extra = {}) {
116
139
  return {
117
140
  label: ctx.poolBinding.launchdLabel,
118
141
  log: ctx.paths.log,
119
- installCommand: installCommandFor(ctx.repoRoot, ctx.poolBinding.installCommand),
142
+ installCommand: installCommandFor(ctx.stableCheckout),
120
143
  docs: ctx.poolBinding.docs,
121
144
  configPath: ctx.paths.config,
122
145
  poolHome: ctx.paths.root,
123
146
  tickCommand: `bb staging-pool tick --home ${shellWord(ctx.paths.root)}`,
124
- checkout: ctx.repoRoot,
147
+ checkout: ctx.stableCheckout,
125
148
  envFile: ctx.poolBinding.passwordEnvFile,
126
149
  allowed: Object.keys(ctx.poolBinding.personas ?? {}),
127
150
  ...extra,
@@ -196,8 +219,9 @@ export async function takeForLane({
196
219
  uid = userInfo().uid,
197
220
  logger = () => {},
198
221
  readBinding = readRepoPoolBinding,
222
+ isStableCheckout = defaultIsStableCheckout,
199
223
  } = {}) {
200
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
224
+ const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
201
225
  const entry = findPersona(ctx.poolBinding, persona);
202
226
  const target = stagingTargetFromBinding(ctx.poolBinding);
203
227
  if (!entry) assertPersonaAllowed(target, persona);
@@ -228,6 +252,68 @@ function humanCommandLine(argv) {
228
252
  return argv.map((token) => (/^[A-Za-z0-9_./:=@-]+$/.test(token) ? token : `'${token.replace(/'/g, "'\\''")}'`)).join(" ");
229
253
  }
230
254
 
255
+ // What a missing pool binding means for each subcommand (AC-8 follow-up: an
256
+ // install refusal must not talk about taking a session).
257
+ const NO_POOL_CONSEQUENCE = Object.freeze({
258
+ install: "there is no pool definition to install",
259
+ uninstall: "there is no pool to remove",
260
+ tick: "there is no pool to refill",
261
+ "mint-one": "there is no pool to sign a persona in for",
262
+ status: "there is no pool to report on",
263
+ probe: "there are no pool personas to probe",
264
+ "print-plist": "there is no pool job to describe",
265
+ });
266
+
267
+ /**
268
+ * Build the refusal for a failed subcommand: the JSON record and the text block.
269
+ * A human-only subcommand always names Human (Jono) and prints no Agent line. The
270
+ * checkout comes from this directory, else from a checkout an install recorded or
271
+ * one seen earlier; with none, the next step says where to run the command.
272
+ */
273
+ export async function refusalFor({ argv, command, error, options = {}, cwd = process.cwd(), home = homedir(), readBinding = readRepoPoolBinding, isStableCheckout = defaultIsStableCheckout }) {
274
+ const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
275
+ const humanOnly = HUMAN_ONLY_COMMANDS.has(command);
276
+ let ctx = null;
277
+ try {
278
+ ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
279
+ } catch {
280
+ ctx = null;
281
+ }
282
+ if (!ctx) {
283
+ const [candidate] = await findKnownCheckouts({ home, readBinding, isStable: isStableCheckout }).catch(() => []);
284
+ if (candidate) {
285
+ try {
286
+ ctx = await resolveContext({ options: {}, cwd: candidate.path, home, readBinding, isStableCheckout });
287
+ } catch {
288
+ ctx = null;
289
+ }
290
+ }
291
+ }
292
+ const line = humanCommandLine(argv ?? [command]);
293
+ const vars = guidanceVars(ctx, {
294
+ command: line,
295
+ retry: `bb staging-pool ${line}`,
296
+ cwd,
297
+ consequence: NO_POOL_CONSEQUENCE[command] ?? "there is no pool to act on",
298
+ markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
299
+ });
300
+ vars.persona = options.persona ?? error?.persona ?? vars.allowed?.[0];
301
+ const actor = humanOnly ? "human" : null;
302
+ const g = guidance(code, vars, { real: true, actor });
303
+ const record = {
304
+ ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
305
+ agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
306
+ };
307
+ // A pool refusal (pool_empty / unhealthy) already carries its health guidance;
308
+ // an ordinary error's own message stays in `detail`.
309
+ if (typeof error?.reason === "string") for (const field of GUIDANCE_FIELDS) if (error[field] !== undefined) record[field] = error[field];
310
+ record.remedy ??= remedyLine(g);
311
+ if (humanOnly) Object.assign(record, { actor: g.actor_label, agentAction: "report_blocker" });
312
+ // An empty pool is explained by its health reason (e.g. job_not_loaded).
313
+ const textG = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars, { real: true, actor }) : g;
314
+ return { record, text: formatGuidance(textG, { agentLine: !humanOnly }), exitCode: exitCodeFor(error) };
315
+ }
316
+
231
317
  export async function runStagingPool(argv, deps = {}) {
232
318
  const {
233
319
  env = process.env,
@@ -246,6 +332,7 @@ export async function runStagingPool(argv, deps = {}) {
246
332
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
247
333
  readBinding = readRepoPoolBinding,
248
334
  readSecretFile = readFileSync,
335
+ isStableCheckout = defaultIsStableCheckout,
249
336
  } = deps;
250
337
 
251
338
  const redactor = createRedactor();
@@ -304,7 +391,7 @@ export async function runStagingPool(argv, deps = {}) {
304
391
 
305
392
  const commands = {
306
393
  async install(options) {
307
- const ctx = await resolveContext({ options: {}, cwd, home, readBinding });
394
+ const ctx = await resolveContext({ options: {}, cwd, home, readBinding, isStableCheckout });
308
395
  const target = stagingTargetFromBinding(ctx.poolBinding);
309
396
  assertMintAllowed({ target, env, listAncestors });
310
397
  const botbuddyBin = requireBotbuddyBin(options);
@@ -327,6 +414,8 @@ export async function runStagingPool(argv, deps = {}) {
327
414
  botbuddyBin,
328
415
  intervalSeconds: explicitInterval ?? existing?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS,
329
416
  existingPersonas: existing?.personas,
417
+ checkout: ctx.repoRoot,
418
+ installedAt: new Date(now()).toISOString(),
330
419
  });
331
420
  await fs.writeFile(ctx.paths.config, serialiseInstalledConfig(config), { mode: 0o600 });
332
421
  await fs.chmod(ctx.paths.config, 0o600);
@@ -351,7 +440,7 @@ export async function runStagingPool(argv, deps = {}) {
351
440
  },
352
441
 
353
442
  async uninstall(options) {
354
- const ctx = await resolveContext({ options, cwd, home, readBinding });
443
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
355
444
  const target = stagingTargetFromBinding(ctx.poolBinding);
356
445
  // The job and its sessions are the human's: an agent never stops or empties them.
357
446
  assertMintAllowed({ target, env, listAncestors });
@@ -365,7 +454,7 @@ export async function runStagingPool(argv, deps = {}) {
365
454
  },
366
455
 
367
456
  async tick(options) {
368
- const ctx = await resolveContext({ options, cwd, home, readBinding });
457
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
369
458
  const target = stagingTargetFromBinding(ctx.poolBinding);
370
459
  assertMintAllowed({ target, env, listAncestors });
371
460
  if (!ctx.installed || ctx.installed.error) throw ctx.installed?.error ?? new ConfigError("The pool job is not installed; the human runs the install first.", "pool_not_installed");
@@ -375,7 +464,7 @@ export async function runStagingPool(argv, deps = {}) {
375
464
  },
376
465
 
377
466
  async "mint-one"(options) {
378
- const ctx = await resolveContext({ options, cwd, home, readBinding });
467
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
379
468
  const target = stagingTargetFromBinding(ctx.poolBinding);
380
469
  assertMintAllowed({ target, env, listAncestors });
381
470
  const entry = findPersona(ctx.poolBinding, options.persona);
@@ -388,7 +477,7 @@ export async function runStagingPool(argv, deps = {}) {
388
477
  },
389
478
 
390
479
  async status(options) {
391
- const ctx = await resolveContext({ options, cwd, home, readBinding });
480
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
392
481
  const target = stagingTargetFromBinding(ctx.poolBinding);
393
482
  const pool = buildPool({ ctx, target, now, logger: emit, fetchImpl, sleep });
394
483
  if (options.check) {
@@ -403,7 +492,7 @@ export async function runStagingPool(argv, deps = {}) {
403
492
  },
404
493
 
405
494
  async probe(options) {
406
- const ctx = await resolveContext({ options, cwd, home, readBinding });
495
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
407
496
  const botbuddyBin = options["botbuddy-bin"] ?? ctx.installed?.botbuddyBin;
408
497
  if (!botbuddyBin) throw new ConfigError('probe needs --botbuddy-bin "$(command -v botbuddy)" (or an installed job).', "pool_not_installed");
409
498
  const receipt = await probeBotBuddy({
@@ -420,42 +509,19 @@ export async function runStagingPool(argv, deps = {}) {
420
509
  },
421
510
 
422
511
  async "print-plist"(options) {
423
- const ctx = await resolveContext({ options, cwd, home, readBinding });
512
+ const ctx = await resolveContext({ options, cwd, home, readBinding, isStableCheckout });
424
513
  stdout(jobPlist(ctx, Number(options.interval ?? ctx.installed?.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS)));
425
514
  return 0;
426
515
  },
427
516
  };
428
517
 
518
+ // One JSON line (machine-readable) then the same guidance as text, on stderr.
429
519
  // One JSON line (machine-readable) then the same guidance as text, on stderr.
430
520
  async function fail(command, error, options = {}) {
431
- const code = typeof error?.code === "string" && isKnownCode(error.code) ? error.code : "unexpected_error";
432
- let ctx = null;
433
- try {
434
- ctx = await resolveContext({ options, cwd, home, readBinding });
435
- } catch {
436
- ctx = null;
437
- }
438
- const persona = options.persona ?? error?.persona;
439
- const vars = guidanceVars(ctx, {
440
- persona,
441
- command: humanCommandLine(argv),
442
- markers: Array.isArray(error?.markers) ? error.markers.join(", ") : undefined,
443
- checkout: ctx?.repoRoot ?? cwd,
444
- retry: `bb staging-pool ${humanCommandLine(argv)}`,
445
- });
446
- const g = guidance(code, vars);
447
- const record = {
448
- ok: false, command, code, message: g.message, cause: g.cause, actor: g.actor_label, next: g.next,
449
- agentAction: g.agent_action, doc: g.doc, help: g.help, detail: String(error?.message ?? error ?? "").slice(0, 500),
450
- };
451
- // A pool refusal already carries its health guidance (pool_empty / unhealthy).
452
- for (const field of GUIDANCE_FIELDS) if (error?.[field] !== undefined) record[field] = error[field];
453
- record.remedy ??= remedyLine(g);
521
+ const { record, text, exitCode } = await refusalFor({ argv, command, error, options, cwd, home, readBinding, isStableCheckout });
454
522
  stderr(JSON.stringify(redactor.redact({ ts: new Date(now()).toISOString(), ...record })));
455
- // An empty pool is explained by its health reason (e.g. job_not_loaded).
456
- const text = typeof error?.reason === "string" && error.reason !== code && isKnownCode(error.reason) ? guidance(error.reason, vars) : g;
457
- stderr(redactor.redact(formatGuidance(text)));
458
- return exitCodeFor(error);
523
+ stderr(redactor.redact(text));
524
+ return exitCode;
459
525
  }
460
526
 
461
527
  let parsed;
@@ -87,7 +87,7 @@ export function readInstalledConfig(path, { readFile = readFileSync } = {}) {
87
87
  }
88
88
 
89
89
  // rawBinding: { tenant, staging_session_pool } exactly as committed.
90
- export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas }) {
90
+ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, intervalSeconds = DEFAULT_INTERVAL_SECONDS, existingPersonas, checkout, installedAt }) {
91
91
  const personas = {};
92
92
  for (const persona of Object.values(poolBinding.personas)) {
93
93
  personas[persona.email] = Number.isInteger(existingPersonas?.[persona.email]) ? existingPersonas[persona.email] : persona.defaultCount;
@@ -98,6 +98,9 @@ export function buildInstalledConfig({ rawBinding, poolBinding, botbuddyBin, int
98
98
  personas,
99
99
  botbuddyBin,
100
100
  intervalSeconds,
101
+ // The checkout install ran from (non-secret), so a refusal elsewhere can name it.
102
+ ...(typeof checkout === "string" && checkout.startsWith("/") ? { checkout } : {}),
103
+ ...(installedAt ? { installedAt } : {}),
101
104
  });
102
105
  }
103
106
 
@@ -3,8 +3,11 @@
3
3
  // command and the doc section. `bb help symptom <code>` prints the same entry.
4
4
  //
5
5
  // Node-stdlib-only and self-contained (the installed job copies this directory).
6
- // Templates take only non-secret values (lane, persona alias, paths, labels);
7
- // a missing value renders as a <placeholder>, never as an empty string.
6
+ // Templates take only non-secret values (lane, persona alias, paths, labels).
7
+ // A real refusal (`{ real: true }`) never prints a <placeholder>: a missing value
8
+ // in prose becomes plain words, and a `next` command that needs a value it does
9
+ // not have falls back to "run this from inside a repository checkout …". Only
10
+ // `bb help symptom` (the reference view) shows <placeholders>.
8
11
 
9
12
  export const DOC_URL = "https://github.com/bot-buddy/botbuddy-webapp/blob/main/docs/agent-staging-acceptance-mfa.md";
10
13
 
@@ -39,15 +42,15 @@ export const ERROR_CODES = Object.freeze({
39
42
  auth_requires_lane_lock: entry("agent", "take",
40
43
  "bb pw auth needs a lock-gated lane, and this run is unlocked.",
41
44
  "BB_PW_NO_LOCK=1 is set in this shell.",
42
- `bb locks -m --lane {lane} --ticket <ticket> && env -u BB_PW_NO_LOCK ${AUTH}`),
45
+ `bb locks -m --lane {lane}{ticketFlag} && env -u BB_PW_NO_LOCK ${AUTH}`),
43
46
  lane_not_claimed: entry("agent", "take",
44
47
  "The lane lock is not held by this agent session.",
45
48
  "The lane was never claimed, its lock expired, or another agent holds it.",
46
- `bb locks -m --lane {lane} --ticket <ticket> && ${AUTH}`),
49
+ `bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
47
50
  exact_lane_holder_required: entry("agent", "take",
48
51
  "The lane lock is held by another agent session of your owner, not by this session.",
49
52
  "The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
50
- `bb locks -m --lane {lane} --ticket <ticket> # from THIS session, then: ${AUTH}`),
53
+ `bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
51
54
  persona_not_allowlisted: entry("agent", "take",
52
55
  "Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
53
56
  "A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
@@ -65,9 +68,9 @@ export const ERROR_CODES = Object.freeze({
65
68
  "The lane was opened with --persistent or --profile.",
66
69
  `${REOPEN} && ${AUTH} # no --persistent / --profile`),
67
70
  pool_not_configured: entry("agent", "configure",
68
- "This directory's repository declares no staging_session_pool, so there is no pool to take from.",
69
- "The command ran outside the repository checkout, or the repository has not opted in.",
70
- "cd <repository checkout> && {retry}"),
71
+ "{cwd} is not inside a repository whose .botbuddy-agent.json declares staging_session_pool, so {consequence}.",
72
+ "The command ran outside the repository checkout (for example from your home directory), or that repository has not opted in.",
73
+ "cd {checkout} && {retry}"),
71
74
 
72
75
  // ── bb pw … auth: install into the lane (agent retries once, then reports)
73
76
  session_install_failed: entry("agent", "take",
@@ -139,15 +142,15 @@ export const ERROR_CODES = Object.freeze({
139
142
  mint_backing_off: entry("human", "repair",
140
143
  "Staging sign-in for {persona} keeps failing, so the job is backing off.",
141
144
  "The last mint_failed line in {log} names it: password_rejected or no_verified_totp_factor = seed drift; password_source_* = the password env file; totp_source_failed = the botbuddy CLI login.",
142
- "grep mint_failed {log} | tail -1 # then the matching code: bb help symptom <code>"),
145
+ "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
143
146
  last_tick_failed: entry("human", "repair",
144
- "The pool job's last tick could not sign {persona} in.",
147
+ "The pool job's last tick had sign-in (mint) failures.",
145
148
  "The last mint_failed line in {log} names the cause.",
146
149
  "grep mint_failed {log} | tail -1 # repair that code, then: {tickCommand}"),
147
150
  ticks_failing: entry("human", "repair",
148
151
  "Pool ticks run but none has succeeded for 3+ intervals.",
149
152
  "A persistent mint failure; {log} names it.",
150
- "grep mint_failed {log} | tail -1 # then: bb help symptom <code>"),
153
+ "grep mint_failed {log} | tail -1 # then run bb help symptom with the code that line names"),
151
154
  persona_below_target: entry("human", "repair",
152
155
  "The pool has been below its configured size for {persona} for 3+ ticks.",
153
156
  "Agents take sessions faster than the job refills them, or mints are partly failing ({log}).",
@@ -180,12 +183,12 @@ export const ERROR_CODES = Object.freeze({
180
183
  // ── who may run what
181
184
  agent_context: entry("human", "install",
182
185
  "This bb staging-pool command is human-only and this shell is inside an agent session ({markers}).",
183
- `It ran from a Claude or Codex terminal. ${HUMAN_ONLY}`,
184
- "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
186
+ `It ran from a Claude or Codex terminal; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
187
+ "cd {checkout} && bb staging-pool {command}"),
185
188
  agent_context_unknown: entry("human", "install",
186
189
  "bb staging-pool could not establish that this is not an agent session (ps failed), so it refused.",
187
- `/bin/ps is unavailable or restricted in this shell. ${HUMAN_ONLY}`,
188
- "From Terminal.app or iTerm: cd {checkout} && bb staging-pool {command}"),
190
+ `/bin/ps is unavailable or restricted in this shell; run it from Terminal.app or iTerm instead. ${HUMAN_ONLY}`,
191
+ "cd {checkout} && bb staging-pool {command}"),
189
192
 
190
193
  // ── configuration (repository binding and installed config)
191
194
  config_missing: entry("human", "configure",
@@ -285,27 +288,62 @@ export const ERROR_CODES = Object.freeze({
285
288
  "bb staging-pool status # once; report this code, the detail and the CLI version (bb version)", "report_blocker"),
286
289
  });
287
290
 
291
+ // Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
292
+ export const AUTH_ONLY_CODES = Object.freeze(new Set([
293
+ "auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
294
+ "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
295
+ "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
296
+ "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
297
+ "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
298
+ ]));
299
+
300
+ // `bb staging-pool` subcommands only a human runs. A refusal from one names
301
+ // Human (Jono) whatever the code, and prints no Agent guidance.
302
+ export const HUMAN_ONLY_COMMANDS = Object.freeze(new Set(["install", "uninstall", "tick", "mint-one"]));
303
+
304
+ export const CHECKOUT_SENTENCE = "From inside a repository checkout whose .botbuddy-agent.json declares staging_session_pool (for BotBuddy: your botbuddy-webapp clone on main), run";
305
+ export const BARE_INSTALL = 'bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"';
306
+
288
307
  // Vars that are already whole command lines; every other var is a single shell word.
289
- const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry"]);
308
+ const COMMAND_VARS = new Set(["installCommand", "tickCommand", "command", "retry", "ticketFlag"]);
309
+ // Vars where an empty string is a real value (no flag), not a missing one.
310
+ const OPTIONAL_VARS = new Set(["ticketFlag"]);
290
311
  const SHELL_SAFE = /^[A-Za-z0-9_./:@,=~+-]+$/;
291
312
  // A <placeholder> stays as written: the reader replaces it.
292
313
  const shellWord = (value) => (SHELL_SAFE.test(value) || /^<[^<>]+>$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`);
293
314
 
315
+ // What a missing value reads as in prose (real refusals only).
316
+ const DESCRIBED = Object.freeze({
317
+ persona: "the persona", lane: "the lane", log: "the pool log", configPath: "the installed pool config",
318
+ label: "the launchd job", envFile: "the persona password file", markers: "agent markers",
319
+ cwd: "This directory", consequence: "there is no pool to act on", command: "this command",
320
+ checkout: "the repository checkout", allowed: "the binding's personas", firstAllowed: "a listed persona",
321
+ });
322
+
323
+ const isMissing = (key, value) => value === undefined || value === null || (value === "" && !OPTIONAL_VARS.has(key)) || (Array.isArray(value) && value.length === 0);
324
+
294
325
  // In a `next` command a caller-supplied value (a typed persona, a checkout path
295
326
  // with spaces) is shell-quoted, so the line stays one safe copy-paste.
296
- function fill(template, vars, { shell = false } = {}) {
327
+ function fill(template, vars, { shell = false, missing = null, describe = false } = {}) {
297
328
  return String(template).replace(/\{([A-Za-z]+)\}/g, (_, key) => {
298
329
  const value = vars?.[key];
299
- if (Array.isArray(value)) return value.length ? value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ") : `<${key}>`;
300
- if (value === undefined || value === null || value === "") return `<${key}>`;
330
+ if (isMissing(key, value)) {
331
+ missing?.add(key);
332
+ return describe ? (DESCRIBED[key] ?? "it") : `<${key}>`;
333
+ }
334
+ if (Array.isArray(value)) return value.map((item) => (shell ? shellWord(String(item)) : String(item))).join(", ");
301
335
  return shell && !COMMAND_VARS.has(key) ? shellWord(String(value)) : String(value);
302
336
  });
303
337
  }
304
338
 
305
339
  export const isKnownCode = (code) => Object.hasOwn(ERROR_CODES, String(code ?? ""));
306
340
 
307
- /** The guidance for one code, interpolated with non-secret vars. */
308
- export function guidance(code, vars = {}) {
341
+ /**
342
+ * The guidance for one code, interpolated with non-secret vars.
343
+ * real a refusal someone will act on: no <placeholder> is ever printed.
344
+ * actor "human" for a human-only subcommand, whatever the code's default.
345
+ */
346
+ export function guidance(code, vars = {}, { real = false, actor = null } = {}) {
309
347
  const known = isKnownCode(code);
310
348
  const spec = known ? ERROR_CODES[code] : ERROR_CODES.unexpected_error;
311
349
  const allowed = Array.isArray(vars.allowed) ? vars.allowed : vars.allowed ? [vars.allowed] : [];
@@ -313,20 +351,36 @@ export function guidance(code, vars = {}) {
313
351
  ...vars,
314
352
  allowed,
315
353
  firstAllowed: vars.firstAllowed ?? allowed[0],
354
+ ticketFlag: vars.ticketFlag ?? (real ? "" : " --ticket <ticket>"),
316
355
  // Without a resolved pool these still read as runnable commands from the checkout.
317
356
  tickCommand: vars.tickCommand ?? "bb staging-pool tick",
318
- installCommand: vars.installCommand ?? `cd ${vars.checkout ? shellWord(String(vars.checkout)) : "<checkout>"} && bb staging-pool install --botbuddy-bin "$(command -v botbuddy)"`,
357
+ installCommand: vars.installCommand ?? (vars.checkout ? `cd ${shellWord(String(vars.checkout))} && ${BARE_INSTALL}` : (real ? undefined : `cd <checkout> && ${BARE_INSTALL}`)),
319
358
  };
320
359
  // What a "go to the right checkout" step re-runs: auth by default.
321
360
  filled.retry = vars.retry ?? fill(AUTH, filled, { shell: true });
361
+ let next;
362
+ if (real) {
363
+ const missing = new Set();
364
+ next = fill(spec.next, filled, { shell: true, missing });
365
+ if (missing.size) {
366
+ // No concrete checkout (or a value only a resolved pool knows): say where to
367
+ // run it, and give the command that works from there.
368
+ const bareMissing = new Set();
369
+ const bare = fill(spec.next.replace("cd {checkout} && ", ""), { ...filled, installCommand: BARE_INSTALL }, { shell: true, missing: bareMissing });
370
+ next = `${CHECKOUT_SENTENCE}: ${bareMissing.size ? filled.retry : bare}`;
371
+ }
372
+ } else {
373
+ next = fill(spec.next, filled, { shell: true });
374
+ }
375
+ const finalActor = actor ?? spec.actor;
322
376
  return {
323
377
  code: known ? code : String(code ?? "unexpected_error"),
324
- message: fill(spec.message, filled),
325
- cause: fill(spec.cause, filled),
326
- actor: spec.actor,
327
- actor_label: ACTOR_LABELS[spec.actor],
328
- next: fill(spec.next, filled, { shell: true }),
329
- agent_action: spec.agentAction,
378
+ message: fill(spec.message, filled, { describe: real }),
379
+ cause: fill(spec.cause, filled, { describe: real }),
380
+ actor: finalActor,
381
+ actor_label: ACTOR_LABELS[finalActor],
382
+ next,
383
+ agent_action: finalActor === "human" ? "report_blocker" : spec.agentAction,
330
384
  doc: `${DOC_URL}#${DOC_SECTIONS[spec.doc]}`,
331
385
  help: `bb help symptom ${known ? code : "unexpected_error"}`,
332
386
  };
@@ -339,13 +393,13 @@ export function remedyLine(g) {
339
393
  }
340
394
 
341
395
  /** Plain-text block for stderr. */
342
- export function formatGuidance(g, { prefix = "bb staging-pool" } = {}) {
396
+ export function formatGuidance(g, { prefix = "bb staging-pool", agentLine = true } = {}) {
343
397
  const lines = [
344
398
  `${prefix}: ${g.message} [${g.code}]`,
345
399
  ` Likely cause: ${g.cause}`,
346
400
  ` Next (${g.actor_label}${g.actor === "human" ? ", from Terminal.app or iTerm" : ""}): ${g.next}`,
347
401
  ];
348
- if (g.actor === "human") lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
402
+ if (g.actor === "human" && agentLine) lines.push(" Agent: do not run that. Report this code and the next step to the human once, then stop.");
349
403
  lines.push(` Docs: ${g.doc}`, ` Explain: ${g.help}`);
350
404
  return lines.join("\n");
351
405
  }
@@ -15,7 +15,7 @@ export const HEALTH_REASON_CODES = Object.freeze([
15
15
  export function healthReasons({ label, log, installCommand, docs, configPath, tickCommand, checkout, persona }) {
16
16
  const vars = { label, log, installCommand, docs, configPath, tickCommand, checkout, persona };
17
17
  return Object.freeze(Object.fromEntries(HEALTH_REASON_CODES.map((code) => {
18
- const g = guidance(code, vars);
18
+ const g = guidance(code, vars, { real: true });
19
19
  return [code, Object.freeze({ agentAction: g.agent_action, remedy: remedyLine(g), vars })];
20
20
  })));
21
21
  }
@@ -35,7 +35,7 @@ export function assessPoolHealth({ status, personas, intervalSeconds, nowMs, job
35
35
  const record = { ok: false, reason, agentAction, ...extra };
36
36
  if (vars) {
37
37
  // Re-rendered with the persona this assessment is about.
38
- const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona });
38
+ const g = guidance(reason, { ...vars, persona: extra.persona ?? requestedPersona ?? vars.persona }, { real: true });
39
39
  remedy = remedyLine(g);
40
40
  Object.assign(record, { message: g.message, cause: g.cause, actor: g.actor_label, next: g.next, doc: g.doc, help: g.help });
41
41
  }