@botbuddy/cli 1.58.1 → 1.59.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.58.1",
3
+ "version": "1.59.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  // recorded. Typed/filled values, @ENV secrets, query strings, fragments, raw
14
14
  // Playwright output and error text never reach state, outbox or the server.
15
15
  import { createHash, randomUUID } from "node:crypto";
16
+ import { stackLeaseIdFromEnv } from "../stack-lease-correlation.mjs";
16
17
  import { chmod, link, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, join } from "node:path";
@@ -330,7 +331,7 @@ function actionView(action) {
330
331
 
331
332
  // ── Lane tracker ─────────────────────────────────────────────────────────────
332
333
 
333
- function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null }) {
334
+ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended = null, onStateWrite = null, env = process.env }) {
334
335
  const statePath = sessionStatePath({ root, tenant, repository, host, lane });
335
336
  const stateLock = `${statePath}.lock`;
336
337
  const execLock = `${statePath}.exec.lock`;
@@ -446,6 +447,9 @@ function createLaneTracker({ root, tenant, repository, host, lane, getOutbox, de
446
447
  if (git.branch && git.branch !== "HEAD") context.branch = String(git.branch);
447
448
  if (git.prNumber !== null && git.prNumber !== undefined && git.prNumber !== "") context.pr = String(git.prNumber);
448
449
  if (git.sha) context.sha = String(git.sha);
450
+ // BOT-2467: a browser session inside `bb stack run` is linked to that lease.
451
+ const leaseId = stackLeaseIdFromEnv(env);
452
+ if (leaseId) context.correlations = { stack_lease_id: leaseId };
449
453
  const state = {
450
454
  schema_version: 1, tenant, repository, host, lane: String(lane), producer_kind: PRODUCER_KIND, attempt: 1,
451
455
  source_run_id: randomUUID(), status: "active", environment, context, started_at: startedAt, next_sequence: 0,
@@ -966,10 +970,11 @@ export function createBrowserTelemetryResolver({
966
970
  now = () => new Date(), monotonic = () => performance.now(), gitInfo = () => ({}), spawnHeartbeat = null,
967
971
  onAppended = null, // test seam: observe each durable append (e.g. to simulate a crash right after it)
968
972
  onStateWrite = null, // test seam: inject a state-write failure
973
+ env = process.env, // BOT-2467: where BOTBUDDY_STACK_LEASE_ID is read from
969
974
  } = {}) {
970
975
  let outboxPromise = null;
971
976
  const getOutbox = () => (outboxPromise ??= createTelemetryOutbox({ root: join(root, "telemetry"), tenant, repository, producerVersion, now }));
972
- const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite });
977
+ const trackerFor = ({ host, lane }) => createLaneTracker({ root, tenant, repository, host, lane, getOutbox, deliver, now, monotonic, gitInfo, spawnHeartbeat, onAppended, onStateWrite, env });
973
978
  return {
974
979
  async resolve({ host, lane }) { return { tracked: true, tracker: trackerFor({ host, lane }) }; },
975
980
  async forHeartbeat(info) { return trackerFor({ host: info.host, lane: info.lane }); },
package/src/run.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // mechanism, never the owner of a child process.
8
8
 
9
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import { stackLeaseIdFromEnv, withStackLeaseCorrelation } from "./stack-lease-correlation.mjs";
10
11
  import { mkdir, readFile, writeFile, chmod, unlink, stat, rename } from "node:fs/promises";
11
12
  import { dirname, join } from "node:path";
12
13
  import { homedir } from "node:os";
@@ -105,7 +106,7 @@ function signalExitCode(signal) {
105
106
  // runner. It has exactly one durable pre-spawn boundary (the outbox start
106
107
  // event), then returns the child verdict even when terminal delivery degrades.
107
108
  export async function launchForegroundRun(argv, {
108
- cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process,
109
+ cwd = process.cwd(), spawnImpl = spawn, identity = null, outbox = null, signalEmitter = process, env = process.env,
109
110
  } = {}) {
110
111
  const { opts, command, errors } = parseRunArgs(argv);
111
112
  if (errors.length) return { exitCode: EXIT.INVALID, receipt: { schema_version: RUN_SCHEMA_VERSION, outcome: "rejected", errors, exit_code: EXIT.INVALID } };
@@ -138,13 +139,14 @@ export async function launchForegroundRun(argv, {
138
139
  ).catch(() => {});
139
140
  }
140
141
  const runId = randomUUID();
141
- const context = {
142
+ // BOT-2467: a run inside `bb stack run` is linked to that lease (uuid-validated).
143
+ const context = withStackLeaseCorrelation({
142
144
  repo: identity.repository,
143
145
  environment: opts.environment,
144
146
  runner: identity.appSlug,
145
147
  phase: opts.kind === "wait" ? "waiting" : "testing",
146
148
  health: "uninstrumented",
147
- };
149
+ }, stackLeaseIdFromEnv(env));
148
150
  const base = {
149
151
  schema_version: 1,
150
152
  producer_kind: opts.kind,
@@ -0,0 +1,31 @@
1
+ // BOT-2467 — link an execution to the `bb stack run` lease it ran under.
2
+ //
3
+ // `bb stack run` exports BOTBUDDY_STACK_LEASE_ID to its child. Every execution
4
+ // producer (lefthook lane telemetry, `bb run`, `bb pw`) stamps it on the lifecycle
5
+ // context as `correlations.stack_lease_id`, which execution-ingest stores on
6
+ // `execution_runs.stack_lease_id`. Only a UUID is forwarded; anything else is
7
+ // dropped (never sent for the server to reject the whole event). Dependency-free.
8
+
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+
11
+ /** The lease id when it is a UUID (lower-cased), else null. */
12
+ export function validStackLeaseId(value) {
13
+ return typeof value === "string" && UUID_RE.test(value) ? value.toLowerCase() : null;
14
+ }
15
+
16
+ /** The lease this process runs under, from `$BOTBUDDY_STACK_LEASE_ID`, or null. */
17
+ export function stackLeaseIdFromEnv(env = process.env) {
18
+ return validStackLeaseId(env?.BOTBUDDY_STACK_LEASE_ID);
19
+ }
20
+
21
+ /**
22
+ * `context` with `correlations.stack_lease_id` set to `leaseId` (other correlations
23
+ * kept). Returns `context` unchanged when the id is not a UUID.
24
+ */
25
+ export function withStackLeaseCorrelation(context, leaseId) {
26
+ const id = validStackLeaseId(leaseId);
27
+ if (!id) return context;
28
+ const correlations = context?.correlations && typeof context.correlations === "object" && !Array.isArray(context.correlations)
29
+ ? context.correlations : {};
30
+ return { ...context, correlations: { ...correlations, stack_lease_id: id } };
31
+ }
@@ -0,0 +1,164 @@
1
+ // BOT-2467 — one secret-free outcome record per `bb stack run`.
2
+ //
3
+ // When a push goes red under `bb stack run` the stdout receipt already says whether
4
+ // the stack broke (a lease/transport/auth failure) or the wrapped command failed
5
+ // (`child_exit_code` decided the exit). Nothing persisted that split. After the
6
+ // receipt is on stdout, `cmdRun` hands the receipt fields to `reportStackRunOutcome`,
7
+ // which makes ONE best-effort `record_stack_run_outcome` MCP call with the same
8
+ // credential the lease calls used. It is capped at 2 s and every failure (timeout,
9
+ // 4xx/5xx, unknown tool on an older server, a thrown error) is a silent drop: the
10
+ // receipt and the exit code are never touched.
11
+ //
12
+ // The payload is an allowlist built here — never the receipt itself — so no token,
13
+ // path, hostname, env value, argv or command output can reach the server. The
14
+ // server re-validates the same allowlist (supabase/functions/_shared/lib/stackRunOutcome.ts).
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { VERSION } from "./version.mjs";
18
+ import { validStackLeaseId } from "./stack-lease-correlation.mjs";
19
+
20
+ export { validStackLeaseId };
21
+
22
+ export const STACK_RUN_OUTCOME_TOOL = "record_stack_run_outcome";
23
+ export const STACK_RUN_OUTCOME_TIMEOUT_MS = 2000;
24
+ /** `=off` disables the record (the CLI test suites set it; nothing else should). */
25
+ export const STACK_RUN_OUTCOME_OPT_OUT_ENV = "BOTBUDDY_STACK_RUN_OUTCOME";
26
+ export const STACK_RUN_OUTCOME_CATEGORIES = Object.freeze(["platform", "script", "interrupted", "invocation"]);
27
+
28
+ const CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
29
+ const TOKEN_RE = /^[a-z0-9_-]{1,64}$/;
30
+ const LANE_RE = /^[A-Za-z0-9:_.-]{1,128}$/;
31
+ const TICKET_RE = /^[A-Za-z][A-Za-z0-9]*-\d{1,9}$/;
32
+ const REPO_RE = /^[A-Za-z0-9._/-]{1,128}$/;
33
+ const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
34
+
35
+ // Mirrors stack.mjs EXIT (kept local so this module stays dependency-free for tests).
36
+ const EXIT_TIMEOUT = 2;
37
+ const EXIT_AUTH = 3;
38
+ const EXIT_INVALID = 4;
39
+ const EXIT_BACKEND = 5;
40
+ const EXIT_LEASE_FAILED = 6;
41
+ const EXIT_INTERNAL = 7;
42
+ const EXIT_CLEANUP_FAILED = 8;
43
+ const SIGNAL_EXITS = new Set([129, 130, 143]);
44
+ // Typed codes that are the platform's fault whatever the exit code says.
45
+ const PLATFORM_CODES = new Set(["TUNNEL_LOST", "LEASE_PREEMPTED", "HOST_CAPACITY_SOURCE_STALE"]);
46
+
47
+ function boundedCode(value) {
48
+ return typeof value === "string" && CODE_RE.test(value) ? value : null;
49
+ }
50
+
51
+ /**
52
+ * Classify one `bb stack run` terminal from its receipt fields (`runReceiptFields`
53
+ * output, or the argument-error receipt) and its exit code. Pure.
54
+ *
55
+ * script — the lease was active and the child's exit decided the result
56
+ * (`--hard-ttl` stop → code HARD_TTL)
57
+ * platform — the stack, its transport, the credential or the backend failed
58
+ * interrupted — a signal ended the run (operator Ctrl-C / parent kill)
59
+ * invocation — exit 4: bad arguments or a refused request before any lease
60
+ */
61
+ export function classifyStackRunOutcome(fields = {}, exitCode) {
62
+ const code = boundedCode(fields.code);
63
+ const child = Number.isInteger(fields.child_exit_code) ? fields.child_exit_code : null;
64
+ const outcome = typeof fields.outcome === "string" ? fields.outcome : null;
65
+ if (outcome === "interrupted") return { category: "interrupted", code };
66
+ if (code && PLATFORM_CODES.has(code)) return { category: "platform", code };
67
+ if (fields.fenced === true) return { category: "platform", code: code ?? "LEASE_LOST" };
68
+ if (outcome === "completed" && exitCode === 0) return { category: "script", code: null };
69
+ if (outcome === "failed" && code == null && child != null) {
70
+ if (SIGNAL_EXITS.has(exitCode)) return { category: "interrupted", code: null };
71
+ // The child's exit decided the push; an unproven teardown on top of it is still
72
+ // named (CLEANUP_FAILED) so the platform half is never silently lost.
73
+ if (exitCode === child) return { category: "script", code: fields.cleanup === false ? "CLEANUP_FAILED" : null };
74
+ if (exitCode === EXIT_TIMEOUT) return { category: "script", code: "HARD_TTL" };
75
+ if (exitCode === EXIT_CLEANUP_FAILED) return { category: "platform", code: "CLEANUP_FAILED" };
76
+ }
77
+ if (exitCode === EXIT_INVALID) return { category: "invocation", code: code ?? "INVALID_ARGUMENTS" };
78
+ if (SIGNAL_EXITS.has(exitCode) && code == null) return { category: "interrupted", code: null };
79
+ switch (exitCode) {
80
+ // An admitted lease whose stack never reached active is a provisioning failure, not
81
+ // a capacity shortage (`timeout_phase` comes from stackRunOutcomeFields).
82
+ case EXIT_TIMEOUT: return { category: "platform", code: code ?? (fields.timeout_phase === "provision" ? "PROVISION_TIMEOUT" : "CAPACITY_TIMEOUT") };
83
+ case EXIT_AUTH: return { category: "platform", code: code ?? "AUTH" };
84
+ case EXIT_BACKEND: return { category: "platform", code: code ?? "BACKEND_UNREACHABLE" };
85
+ case EXIT_LEASE_FAILED: return { category: "platform", code: code ?? "LEASE_FAILED" };
86
+ case EXIT_CLEANUP_FAILED: return { category: "platform", code: "CLEANUP_FAILED" };
87
+ case EXIT_INTERNAL: return { category: "platform", code: code ?? "INTERNAL" };
88
+ default: return { category: "platform", code: code ?? "UNKNOWN" };
89
+ }
90
+ }
91
+
92
+ /** The lane id from `$BOTBUDDY_LANE_SOURCE_RUN_ID` (`lefthook:<invocation>:<lane>`), or null. */
93
+ export function laneFromSourceRunId(value) {
94
+ if (typeof value !== "string" || !value.startsWith("lefthook:")) return null;
95
+ const parts = value.split(":");
96
+ if (parts.length < 3) return null;
97
+ const lane = parts.slice(2).join(":");
98
+ return LANE_RE.test(lane) ? lane : null;
99
+ }
100
+
101
+ const pick = (value, re) => (typeof value === "string" && re.test(value) ? value : null);
102
+ const exitInt = (value) => (Number.isInteger(value) && value >= 0 && value <= 255 ? value : null);
103
+
104
+ /**
105
+ * The allowlisted payload for one run. Every field is bounded and shape-checked here
106
+ * so the call carries nothing the server would refuse — and nothing secret.
107
+ */
108
+ export function buildStackRunOutcomePayload({ fields = {}, exitCode, runId = randomUUID(), opts = {}, env = process.env, durationMs = null }) {
109
+ const { category, code } = classifyStackRunOutcome(fields, exitCode);
110
+ return {
111
+ run_id: runId,
112
+ lease_id: validStackLeaseId(fields.lease_id),
113
+ outcome: pick(fields.outcome, TOKEN_RE) ?? "unknown",
114
+ code,
115
+ category,
116
+ exit_code: exitInt(exitCode) ?? 255,
117
+ child_exit_code: exitInt(fields.child_exit_code),
118
+ park_reason: pick(fields.park_reason, TOKEN_RE),
119
+ execution_mode: pick(fields.execution_mode, TOKEN_RE),
120
+ transport: pick(fields.transport, TOKEN_RE),
121
+ fenced: fields.fenced === true,
122
+ lane: laneFromSourceRunId(env.BOTBUDDY_LANE_SOURCE_RUN_ID),
123
+ ticket: pick(opts.ticket, TICKET_RE)?.toUpperCase() ?? null,
124
+ repo: pick(opts.repo, REPO_RE),
125
+ duration_ms: Number.isFinite(durationMs) && durationMs >= 0 ? Math.round(durationMs) : null,
126
+ cli_version: pick(VERSION, VERSION_RE) ?? "unknown",
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Best-effort record. `channel` is `{ auth, callTool }` from the lifecycle (the
132
+ * credential the lease calls used); without one there is nothing to authenticate
133
+ * with and nothing is sent. Never throws; resolves within `timeoutMs`.
134
+ */
135
+ export async function reportStackRunOutcome({ fields, exitCode, opts, env = process.env, durationMs = null, channel, timeoutMs = STACK_RUN_OUTCOME_TIMEOUT_MS }) {
136
+ // Test suites opt out so a spawned real CLI never records a fixture run.
137
+ if (env?.[STACK_RUN_OUTCOME_OPT_OUT_ENV] === "off") return { sent: false, reason: "disabled" };
138
+ if (!channel?.auth || typeof channel.callTool !== "function") return { sent: false, reason: "no_credential" };
139
+ let payload;
140
+ try {
141
+ payload = buildStackRunOutcomePayload({ fields, exitCode, opts, env, durationMs });
142
+ } catch {
143
+ return { sent: false, reason: "payload_error" };
144
+ }
145
+ const ac = new AbortController();
146
+ let timer;
147
+ const TIMED_OUT = Symbol("stack-run-outcome-timeout");
148
+ const timeout = new Promise((resolve) => {
149
+ // Referenced on purpose: a never-settling call holds no handle of its own, and an
150
+ // unref'd timer would let the event loop end before the cap resolves the race.
151
+ // `finally` clears it, so a fast answer never waits on it.
152
+ timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, timeoutMs);
153
+ });
154
+ try {
155
+ const call = Promise.resolve().then(() => channel.callTool(STACK_RUN_OUTCOME_TOOL, payload, { auth: channel.auth, signal: ac.signal }));
156
+ const result = await Promise.race([call.catch(() => null), timeout]);
157
+ if (result === TIMED_OUT) return { sent: false, reason: "timeout", payload };
158
+ return { sent: Boolean(result?.ok && !result?.isError), reason: result?.ok ? null : "refused", payload };
159
+ } catch {
160
+ return { sent: false, reason: "error", payload };
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
package/src/stack.mjs CHANGED
@@ -51,6 +51,7 @@ import { DEFAULT_BUNDLE_MAX_BYTES, buildStackBundle, uploadStackBundle, validate
51
51
  import { assertSshTarget, establishSshTunnel } from "./stack-tunnel.mjs";
52
52
  import { ENGINE_PUBLISHES_BEYOND_LOOPBACK, engineSocketIsLocal, formatPublishIp, isLoopbackIp, publishIpFix, resolvePublishIp } from "./docker-publish-ip.mjs";
53
53
  import { bold, dim, yellow } from "./utils.mjs";
54
+ import { reportStackRunOutcome } from "./stack-run-outcome.mjs";
54
55
 
55
56
  export const STACK_SCHEMA_VERSION = 1;
56
57
 
@@ -3061,9 +3062,17 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3061
3062
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3062
3063
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3063
3064
  const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3064
- return incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3065
+ const final = incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3066
+ // BOT-2467: non-enumerable, so the receipt fields and result comparisons never see it.
3067
+ if (final && typeof final === "object" && incidentBox.outcomeChannel) {
3068
+ Object.defineProperty(final, OUTCOME_CHANNEL, { value: incidentBox.outcomeChannel, enumerable: false });
3069
+ }
3070
+ return final;
3065
3071
  }
3066
3072
 
3073
+ /** BOT-2467: where a lifecycle result carries its outcome-record channel. */
3074
+ export const OUTCOME_CHANNEL = Symbol.for("botbuddy.stackRunOutcomeChannel");
3075
+
3067
3076
  async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3068
3077
  // BOT-1750 (Codex R9/R11 P2): `stack run` is a lease-creating entry point AND a
3069
3078
  // long-lived one (queue → provision → child → heartbeat → cleanup). A cached token
@@ -3083,6 +3092,8 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3083
3092
  refresh: refreshAuth,
3084
3093
  auth: initialAuth,
3085
3094
  });
3095
+ // BOT-2467: cmdRun records the run's outcome with the credential this run ended on.
3096
+ incidentBox.outcomeChannel = { get auth() { return rpc.auth; }, callTool: adapters.callTool ?? callToolJson };
3086
3097
  const api = adapters.api ?? {
3087
3098
  request: (args) => rpc.call("request_stack_lease", args),
3088
3099
  get: (leaseId) => rpc.call("get_stack_lease", { lease_id: leaseId }),
@@ -3713,7 +3724,9 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3713
3724
  }
3714
3725
  if (!(recheck?.ok && recheck.data?.success && recheck.data.state === "active")) {
3715
3726
  const cleanup = await requestCleanup();
3716
- return { exitCode: EXIT.TIMEOUT, outcome: "timeout", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
3727
+ // BOT-2467: `timeoutPhase` is for the outcome record only (never on the receipt):
3728
+ // an admitted lease whose stack never started is not a capacity shortage.
3729
+ return { exitCode: EXIT.TIMEOUT, outcome: "timeout", timeoutPhase: "provision", leaseId, error: `physical provision did not reach active in ${provisionTimeout}s`, cleanup, ...(parkIncident ? { incident: parkIncident } : {}) };
3717
3730
  }
3718
3731
  }
3719
3732
  if (active?.failed || active?.error || active?.auth) {
@@ -3969,13 +3982,54 @@ export function runReceiptFields(result) {
3969
3982
  };
3970
3983
  }
3971
3984
 
3972
- async function cmdRun(opts, childArgv) {
3973
- const result = await runStackLifecycle(opts, childArgv);
3974
- return emit(buildReceipt(runReceiptFields(result)), opts, result.exitCode);
3985
+ /**
3986
+ * `stack run`: the lifecycle, its one receipt, then (BOT-2467) one best-effort
3987
+ * `record_stack_run_outcome` call carrying the platform-vs-script classification.
3988
+ * The record runs strictly AFTER the receipt is on stdout and can never change it
3989
+ * or the returned exit code.
3990
+ */
3991
+ export async function cmdRun(opts, childArgv, {
3992
+ lifecycle = runStackLifecycle, report = reportStackRunOutcome, now = Date.now, env = process.env,
3993
+ } = {}) {
3994
+ const startedAt = now();
3995
+ const result = await lifecycle(opts, childArgv);
3996
+ const fields = runReceiptFields(result);
3997
+ const code = emit(buildReceipt(fields), opts, result.exitCode);
3998
+ try {
3999
+ await report({ fields: stackRunOutcomeFields(result), exitCode: code, opts, env, durationMs: now() - startedAt, channel: result?.[OUTCOME_CHANNEL] ?? null });
4000
+ } catch { /* best-effort: the receipt and exit code are already final */ }
4001
+ return code;
4002
+ }
4003
+
4004
+ /**
4005
+ * BOT-2467: the credential an argument-refused `stack run` can record its outcome
4006
+ * with — env/flag/cached session only. Never self-heals (no mint, no Keychain read)
4007
+ * on a fail-fast path.
4008
+ */
4009
+ /**
4010
+ * BOT-2467: the fields the outcome classifier reads — the receipt's, plus facts the
4011
+ * receipt deliberately does not carry (which wait a timeout ended). Never emitted.
4012
+ */
4013
+ export function stackRunOutcomeFields(result) {
4014
+ return { ...runReceiptFields(result), ...(result?.timeoutPhase ? { timeout_phase: result.timeoutPhase } : {}) };
4015
+ }
4016
+
4017
+ async function parseErrorOutcomeChannel(argv, { resolveCredential = resolveAgentSessionCredential, callTool = callToolJson } = {}) {
4018
+ try {
4019
+ // Only this command's own options: a credential-looking flag in the child argv
4020
+ // (after `--`) is the child's, never ours.
4021
+ const end = argv.indexOf("--");
4022
+ const ownArgv = end === -1 ? argv : argv.slice(0, end);
4023
+ const credential = await resolveCredential({ argv: ownArgv, env: process.env, cwd: process.cwd(), allowSelfHeal: false });
4024
+ if (typeof credential?.token !== "string" || !AGENT_KEY_RE.test(credential.token)) return null;
4025
+ return { auth: { "x-agent-api-key": credential.token }, callTool };
4026
+ } catch {
4027
+ return null;
4028
+ }
3975
4029
  }
3976
4030
 
3977
4031
  /** Entry point wired from commands.mjs (`botbuddy stack ...`). Returns/sets the exit code. */
3978
- export async function cmdStack(argv) {
4032
+ export async function cmdStack(argv, { report = reportStackRunOutcome, outcomeChannel = parseErrorOutcomeChannel } = {}) {
3979
4033
  if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
3980
4034
  process.stdout.write(STACK_HELP + "\n");
3981
4035
  return EXIT.OK;
@@ -3983,7 +4037,12 @@ export async function cmdStack(argv) {
3983
4037
  const { command, leaseId, opts, errors, childArgv } = parseStackArgs(argv);
3984
4038
  if (errors.length) {
3985
4039
  for (const e of errors) process.stderr.write(`${yellow("⚠")} stack: ${e}\n`);
3986
- const code = emit(buildReceipt({ command: command || "?", outcome: "error", error: errors[0] }), opts, EXIT.INVALID);
4040
+ const fields = { command: command || "?", outcome: "error", error: errors[0] };
4041
+ const code = emit(buildReceipt(fields), opts, EXIT.INVALID);
4042
+ // BOT-2467: a refused `stack run` is recorded as an `invocation` outcome (no lease).
4043
+ if (command === "run") {
4044
+ try { await report({ fields, exitCode: code, opts, env: process.env, durationMs: 0, channel: await outcomeChannel(argv) }); } catch { /* best-effort */ }
4045
+ }
3987
4046
  process.exitCode = code;
3988
4047
  return code;
3989
4048
  }
@@ -4001,7 +4060,7 @@ export async function cmdStack(argv) {
4001
4060
  case "status": code = await cmdStatus(leaseId, opts); break;
4002
4061
  case "touch": code = await cmdTouch(leaseId, opts); break;
4003
4062
  case "done": code = await cmdDone(leaseId, opts); break;
4004
- case "run": code = await cmdRun(opts, childArgv); break;
4063
+ case "run": code = await cmdRun(opts, childArgv, { report }); break;
4005
4064
  default:
4006
4065
  process.stderr.write(`${yellow("⚠")} stack: unknown subcommand "${command}". Try ${bold("botbuddy stack help")}.\n`);
4007
4066
  code = emit(buildReceipt({ command: command || "?", outcome: "error", error: `unknown subcommand: ${command}` }), opts, EXIT.INVALID);
package/src/wait-core.mjs CHANGED
@@ -568,6 +568,72 @@ export function parseConditions(specs) {
568
568
  return { conditions, errors };
569
569
  }
570
570
 
571
+ // ---------------------------------------------------------------------------
572
+ // BOT-2467: a secret-free description of grammar failures, for the
573
+ // `wait_setup_error` sensor event. Per failed spec: the attempted condition TYPE
574
+ // (only when it is a known type or within edit distance 2 of one — else
575
+ // "unknown") and a parse_error category derived from the validator's message.
576
+ // Never the raw spec, a param name or a param value.
577
+ // ---------------------------------------------------------------------------
578
+
579
+ export const CONDITION_PARSE_ERRORS = Object.freeze([
580
+ "unknown_type", "missing_param", "bad_param_value", "unknown_param", "param_conflict",
581
+ "condition_conflict", "empty", "syntax",
582
+ ]);
583
+ const MAX_DESCRIBED_CONDITION_ERRORS = 16;
584
+ const TYPE_TOKEN_RE = /^[a-z0-9_-]{1,32}$/;
585
+
586
+ function editDistanceAtMost(a, b, limit) {
587
+ if (Math.abs(a.length - b.length) > limit) return false;
588
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
589
+ for (let i = 1; i <= a.length; i++) {
590
+ const row = [i];
591
+ let best = i;
592
+ for (let j = 1; j <= b.length; j++) {
593
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
594
+ best = Math.min(best, row[j]);
595
+ }
596
+ if (best > limit) return false;
597
+ prev = row;
598
+ }
599
+ return prev[b.length] <= limit;
600
+ }
601
+
602
+ function attemptedConditionType(spec) {
603
+ const raw = String(spec ?? "");
604
+ const colon = raw.indexOf(":");
605
+ const token = (colon === -1 ? raw : raw.slice(0, colon)).trim();
606
+ if (Object.prototype.hasOwnProperty.call(VALIDATORS, token)) return token;
607
+ const lower = token.toLowerCase();
608
+ if (!TYPE_TOKEN_RE.test(lower)) return "unknown";
609
+ return Object.keys(VALIDATORS).some((known) => editDistanceAtMost(lower, known, 2)) ? lower : "unknown";
610
+ }
611
+
612
+ function conditionParseError(error) {
613
+ const spec = String(error?.spec ?? "");
614
+ const message = String(error?.message ?? "");
615
+ if (spec.trim() === "" || /at least one condition is required/.test(message)) return "empty";
616
+ if (/^unknown condition type/.test(message)) return "unknown_type";
617
+ if (/must be the only condition/.test(message)) return "condition_conflict";
618
+ if (/exactly one of|only valid with|takes no /.test(message)) return "param_conflict";
619
+ if (/unknown key|does not accept|accepts only/.test(message)) return "unknown_param";
620
+ if (/\bneeds\b/.test(message)) return "missing_param";
621
+ if (/\bmust\b/.test(message)) return "bad_param_value";
622
+ return "syntax";
623
+ }
624
+
625
+ /**
626
+ * `parseConditions(...).errors` → `[{ attempted_type, parse_error }]`, one per failed
627
+ * spec in order, at most 16. Pure; safe to send (no spec text, names or values).
628
+ */
629
+ export function describeConditionErrors(errors) {
630
+ if (!Array.isArray(errors)) return [];
631
+ return errors.slice(0, MAX_DESCRIBED_CONDITION_ERRORS).map((error) => {
632
+ const parseError = conditionParseError(error);
633
+ return { attempted_type: parseError === "empty" ? "unknown" : attemptedConditionType(error?.spec), parse_error: parseError };
634
+ });
635
+ }
636
+
571
637
  // ---------------------------------------------------------------------------
572
638
  // SSE frame parsing — split-safe, keepalive-aware, multi-line data.
573
639
  // ---------------------------------------------------------------------------
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, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
16
+ import { EXIT, describeConditionErrors, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt, formatPrReviewSnapshotWarnings, formatCiLatestRunNotice, formatUnknownWorkflowGuidance } from "./wait-core.mjs";
17
17
  import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
@@ -915,7 +915,7 @@ export const RECOVERY = Object.freeze({
915
915
  // only error code, exit code, condition TYPES, auth path, profile name, cli version.
916
916
  const SENSOR_TELEMETRY_TIMEOUT_MS = 2000;
917
917
 
918
- async function reportSetupError(opts, { error, exitCode, conditions }) {
918
+ async function reportSetupError(opts, { error, exitCode, conditions, conditionErrors = null }) {
919
919
  // Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
920
920
  // authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
921
921
  // would only 401. Discriminate by the credential's SOURCE, not its shape: a
@@ -939,7 +939,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
939
939
  timer = setTimeout(() => { ac.abort(); resolve(TIMED_OUT); }, SENSOR_TELEMETRY_TIMEOUT_MS);
940
940
  });
941
941
  try {
942
- await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions }, ac.signal), timeout]);
942
+ await Promise.race([sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, ac.signal), timeout]);
943
943
  } catch {
944
944
  // Best-effort: an AbortError (2 s timeout) or any transport/lookup error drops.
945
945
  } finally {
@@ -947,7 +947,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
947
947
  }
948
948
  }
949
949
 
950
- async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal) {
950
+ async function sendSetupErrorEvent(opts, { error, exitCode, conditions, conditionErrors }, signal) {
951
951
  // Resolve a bearer WITHOUT ever touching the Keychain. This telemetry is
952
952
  // best-effort and runs on fail-fast paths right before process.exit; a Keychain
953
953
  // read (readProfileCredential → `security find-generic-password`) can block/prompt
@@ -994,6 +994,7 @@ async function sendSetupErrorEvent(opts, { error, exitCode, conditions }, signal
994
994
  error,
995
995
  exit_code: exitCode,
996
996
  condition_types: conditionTypes,
997
+ ...(Array.isArray(conditionErrors) && conditionErrors.length ? { condition_errors: conditionErrors } : {}),
997
998
  auth_path: opts.token ? "explicit_token" : "binding",
998
999
  profile: null,
999
1000
  cli_version: VERSION,
@@ -1577,8 +1578,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1577
1578
  process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
1578
1579
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
1579
1580
  // BOT-1593: condition_types is [] on a grammar failure (the parsed set is
1580
- // partial/empty and is caller data regardless).
1581
- await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [] });
1581
+ // partial/empty and is caller data regardless). BOT-2467: instead, each failed
1582
+ // spec is described by its attempted TYPE and a parse_error category — never
1583
+ // the spec text, a param name or a value.
1584
+ await reportSetupError(opts, { error: "invalid_conditions", exitCode: EXIT.INVALID, conditions: [], conditionErrors: describeConditionErrors(errors) });
1582
1585
  process.exit(EXIT.INVALID);
1583
1586
  }
1584
1587