@botbuddy/cli 1.64.13 → 1.64.14

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/bin/botbuddy.mjs CHANGED
@@ -1,8 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../src/commands.mjs";
3
3
  import { ensureRunDelivery } from "../src/run-delivery.mjs";
4
+ import { feedbackReminder } from "../src/feedback-reminder.mjs";
5
+ import { writeSync } from "node:fs";
4
6
 
5
7
  const argv = process.argv.slice(2);
8
+ // Some commands exit directly; observe the final exit once so those paths
9
+ // receive the same stderr-only guidance without changing their exit status.
10
+ process.once("exit", (code) => {
11
+ const reminder = feedbackReminder(argv, code);
12
+ if (reminder) {
13
+ try { writeSync(2, `${reminder}\n`); } catch { /* preserve the original exit status */ }
14
+ }
15
+ });
6
16
 
7
17
  // BOT-2600: normal `bb` invocations start the single per-machine delivery process
8
18
  // when finished `bb run` commands still have an unsent terminal update. A stat
@@ -15,8 +25,10 @@ const flags = separator === -1 ? argv : argv.slice(0, separator);
15
25
  const readOnly = flags.some((flag) => flag === "--help" || flag === "-h")
16
26
  || argv[0] === "help"
17
27
  || (argv[0] === "stack" && argv[1] === "help")
18
- || (argv[0] === "run" && (argv[1] === "inspect" || argv[1] === "help"));
19
- const delivery = deliveryProcessArgs || readOnly ? Promise.resolve() : ensureRunDelivery().catch(() => {});
28
+ || (argv[0] === "run" && (argv[1] === "status" || argv[1] === "inspect" || argv[1] === "help"));
29
+ // Feedback must not start unrelated delivery while an agent reports a blocker.
30
+ const feedbackCommand = argv[0] === "feedback";
31
+ const delivery = deliveryProcessArgs || readOnly || feedbackCommand ? Promise.resolve() : ensureRunDelivery().catch(() => {});
20
32
 
21
33
  delivery.then(() => run(argv)).then((code) => {
22
34
  if (typeof code === "number") process.exitCode = code;
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.13",
3
- "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
4
- "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
2
+ "release_version": "1.64.14",
3
+ "source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
4
+ "source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
5
5
  "target": "darwin-arm64",
6
- "sha256": "8c2dd7c45a7e1c697fa47e5044a1b1cd6703efbb7fc648e1042a104c784094bb"
6
+ "sha256": "2ee00d7621d20c09863a0dc96f862a99ed10a3557ba15df595e512f433020a7c"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.13",
3
- "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
4
- "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
2
+ "release_version": "1.64.14",
3
+ "source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
4
+ "source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
5
5
  "target": "linux-x64",
6
- "sha256": "81345fbe3fde99c1b41f7d1738f00ef2bf4be9de12fe39e0755f23546ed4ed3f"
6
+ "sha256": "af268c0f266b387fadc180451ef738760eb95c409c588cc7de62c9ee2b18c8f9"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.13",
4
- "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
5
- "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
3
+ "release_version": "1.64.14",
4
+ "source_commit": "2bea99f6e692232d6694356e0fc474daebba622e",
5
+ "source_sha256": "abb141ad30727d4a9f32819a3e26be122ab750a919dfc21f7813d6dbd3bcaaa3",
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": "8c2dd7c45a7e1c697fa47e5044a1b1cd6703efbb7fc648e1042a104c784094bb",
11
+ "sha256": "2ee00d7621d20c09863a0dc96f862a99ed10a3557ba15df595e512f433020a7c",
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": "81345fbe3fde99c1b41f7d1738f00ef2bf4be9de12fe39e0755f23546ed4ed3f",
18
+ "sha256": "af268c0f266b387fadc180451ef738760eb95c409c588cc7de62c9ee2b18c8f9",
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.64.13",
3
+ "version": "1.64.14",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -18,6 +18,7 @@ import { cmdDocker } from "./docker-hygiene.mjs";
18
18
  import { cmdRun } from "./run.mjs";
19
19
  import { cmdTest } from "./test-lane.mjs";
20
20
  import { cmdEvidence } from "./evidence.mjs";
21
+ import { cmdFeedback } from "./feedback.mjs";
21
22
  import { runWait } from "./wait.mjs";
22
23
  import { SETUP_BLOCK } from "./setup-block.mjs";
23
24
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
@@ -48,7 +49,7 @@ import { cmdDaemon } from "./daemon/commands.mjs";
48
49
  export function shouldCheckForUpdates(argv) {
49
50
  const [command] = argv;
50
51
  // BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
51
- if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
52
+ if (command === "wait" || command === "feedback" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
52
53
  if (argv.includes("--json")) return false;
53
54
  // BOT-2599: `bb doctor --only <check>` runs inside a git pre-push hook; it must
54
55
  // stay local and sub-second, so it never consults the npm registry.
@@ -61,7 +62,7 @@ export function shouldCheckForUpdates(argv) {
61
62
  // of the same name. MCP tools are snake_case, so no name here can shadow one.
62
63
  export const LOCAL_HELP_COMMANDS = Object.freeze([
63
64
  "wait", "stack", "pw", "docker", "run", "mcp", "login", "setup", "doctor", "host",
64
- "staging-pool", "daemon", "evidence", "usage", "telemetry", "allocation",
65
+ "staging-pool", "daemon", "evidence", "feedback", "usage", "telemetry", "allocation",
65
66
  ]);
66
67
  const HELP_WORDS = new Set(["help", "--help", "-h"]);
67
68
 
@@ -99,6 +100,9 @@ export async function run(argv, {
99
100
  if (command === "bootstrap" && args[0] === "close" && parseBootstrapCloseArgs(args).action !== "close") {
100
101
  return bootstrap(args);
101
102
  }
103
+ // Creating or reading local feedback guidance must work even when the
104
+ // credential store or network is the reason the agent is blocked.
105
+ if (command === "feedback" && (args[0] === "init" || HELP_WORDS.has(args[0]))) return cmdFeedback(args);
102
106
 
103
107
  await load();
104
108
  if (shouldCheckForUpdates([command, ...args])) await warnStale({ version: VERSION });
@@ -138,6 +142,7 @@ export async function run(argv, {
138
142
  }
139
143
  case "test": return cmdTest(args);
140
144
  case "evidence": return cmdEvidence(args);
145
+ case "feedback": return cmdFeedback(args);
141
146
  case "test-ingest": return cmdTestIngest(args);
142
147
  case "wait": return runWait(args);
143
148
  case "pw": return runPw(args);
@@ -316,11 +321,14 @@ ${bold("DURABLE WORKLOADS")} (${cyan("bb help run")})
316
321
  Launch a receipt-bearing command under a detached owner
317
322
  run --foreground --kind <kind> --environment <env> -- <command>
318
323
  Run locally with a durable lifecycle outbox
324
+ run status <run_id> Read status and exact available log or receipt commands
319
325
  run inspect|cancel <run_id> Read or stop one command run
320
326
  telemetry <status|replay|doctor>
321
327
  Inspect, replay, or gate durable lifecycle delivery
322
328
 
323
329
  ${bold("EVIDENCE")} (${cyan("bb help evidence")})
330
+ feedback init|submit --file <report.json>
331
+ Prepare or send a BotBuddy issue report
324
332
  evidence require <file> Record an exact-head evidence disposition → botbuddy/ready
325
333
  evidence publish <file> Publish a local evidence statement → botbuddy/ready check-run
326
334
 
@@ -10,6 +10,7 @@ import { callToolJson } from "./api.mjs";
10
10
  import { acquireStackLock, lockPathForProject, projectIdFromConfig } from "./stack-file-lock.mjs";
11
11
  import { VERSION } from "./version.mjs";
12
12
  import { machineUuid } from "./machine-id.mjs";
13
+ import { FEEDBACK_GUIDANCE } from "./feedback-reminder.mjs";
13
14
 
14
15
  export const SCHEMA_VERSION = 1;
15
16
  // BOT-2206: preflight applies policy v3's network term (BOT-2202). Docker's built-in
@@ -1311,7 +1312,7 @@ export function cmdDocker(argv) {
1311
1312
  return;
1312
1313
  }
1313
1314
  return runDockerCommand(argv).then((result) => {
1314
- if (result.json) console.log(JSON.stringify(result.receipt));
1315
+ if (result.json) console.log(JSON.stringify(result.exitCode === EXIT.OK ? result.receipt : { ...result.receipt, feedback_guidance: FEEDBACK_GUIDANCE }));
1315
1316
  else console.log(formatHumanReceipt(result.receipt));
1316
1317
  if (result.exitCode !== EXIT.OK) process.exitCode = result.exitCode;
1317
1318
  });
@@ -0,0 +1,119 @@
1
+ // Private, bounded retry storage. Never stores credentials; a cached agent and
2
+ // tenant are only a replay fence and are rechecked against whoami at delivery.
3
+ import { chmod, link, lstat, mkdir, open, readdir, readFile, unlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import { acquireStackLock } from "./stack-file-lock.mjs";
8
+ import { restrictWindowsAcl, restrictWindowsAclDir } from "./agent-state-fs.mjs";
9
+ import { containsFeedbackSecret } from "./feedback-safety.mjs";
10
+
11
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const TENANT = /^[a-z0-9][a-z0-9-]{0,63}$/;
13
+ const MAX_ITEMS = 100;
14
+ const MAX_BYTES = 32 * 1024;
15
+ const sensitive = containsFeedbackSecret;
16
+
17
+ async function directory(root) {
18
+ const path = join(root, ".botbuddy", "feedback-outbox");
19
+ await mkdir(path, { recursive: true, mode: 0o700 });
20
+ const info = await lstat(path);
21
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("outbox_directory_invalid");
22
+ if (process.platform === "win32") await restrictWindowsAclDir(path);
23
+ else {
24
+ await chmod(path, 0o700);
25
+ if (((await lstat(path)).mode & 0o077) !== 0) throw new Error("outbox_directory_not_private");
26
+ }
27
+ return path;
28
+ }
29
+
30
+ function validRecord(record) {
31
+ return record && record.schema_version === 1 && typeof record.agent_id === "string" && UUID.test(record.agent_id) && typeof record.tenant === "string" && TENANT.test(record.tenant) &&
32
+ record.report && typeof record.report === "object" && !Array.isArray(record.report) && typeof record.report.idempotency_key === "string" && UUID.test(record.report.idempotency_key);
33
+ }
34
+
35
+ async function syncDir(path) {
36
+ if (process.platform === "win32") return;
37
+ const handle = await open(path, "r");
38
+ try { await handle.sync(); } finally { await handle.close(); }
39
+ }
40
+
41
+ export async function queueFeedback({ root, agentId, tenant, report }) {
42
+ const record = { schema_version: 1, agent_id: agentId, tenant, created_at: new Date().toISOString(), report };
43
+ if (!validRecord(record)) throw new Error("outbox_identity_unavailable");
44
+ if (sensitive(report)) throw new Error("outbox_sensitive_value");
45
+ const text = `${JSON.stringify(record)}\n`;
46
+ if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("outbox_report_too_large");
47
+ const path = await directory(root);
48
+ const lock = await acquireStackLock(join(path, ".lock"), { timeoutMs: 2000 });
49
+ try {
50
+ const final = join(path, `${report.idempotency_key.toLowerCase()}.json`);
51
+ const names = (await readdir(path)).filter((name) => /^[0-9a-f-]{36}\.json$/i.test(name));
52
+ if (names.length >= MAX_ITEMS && !names.includes(`${report.idempotency_key.toLowerCase()}.json`)) throw new Error("outbox_full");
53
+ const temp = join(path, `.${randomUUID()}.tmp`);
54
+ try {
55
+ const file = await open(temp, "wx", 0o600);
56
+ try { await file.writeFile(text); await file.sync(); } finally { await file.close(); }
57
+ if (process.platform === "win32") await restrictWindowsAcl(temp);
58
+ else await chmod(temp, 0o600);
59
+ try { await link(temp, final); }
60
+ catch (error) {
61
+ if (error?.code !== "EEXIST") throw error;
62
+ const existingInfo = await lstat(final);
63
+ if (!existingInfo.isFile() || existingInfo.isSymbolicLink() || existingInfo.size > MAX_BYTES || (process.platform !== "win32" && (existingInfo.mode & 0o077) !== 0)) throw new Error("outbox_record_invalid");
64
+ const existing = JSON.parse(await readFile(final, "utf8"));
65
+ if (!validRecord(existing) || existing.agent_id !== agentId || existing.tenant !== tenant || !isDeepStrictEqual(existing.report, report)) throw new Error("outbox_idempotency_conflict");
66
+ }
67
+ await syncDir(path);
68
+ return { queued: true, idempotency_key: report.idempotency_key, flush_command: "bb feedback flush" };
69
+ } finally { await unlink(temp).catch(() => {}); }
70
+ } finally { lock.release(); }
71
+ }
72
+
73
+ // Caller owns the exclusive lock for the whole bounded replay. Corrupt files
74
+ // are retained, and a successful operation is the only authority to delete.
75
+ export async function withFeedbackOutbox(root, operation) {
76
+ const path = await directory(root);
77
+ const lock = await acquireStackLock(join(path, ".lock"), { timeoutMs: 2000 });
78
+ try {
79
+ const names = (await readdir(path)).filter((name) => /^[0-9a-f-]{36}\.json$/i.test(name)).sort();
80
+ return await operation(names.slice(0, 10).map((name) => ({ name, path: join(path, name) })), {
81
+ more: names.length > 10,
82
+ read: async (item) => {
83
+ const info = await lstat(item.path);
84
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) throw new Error("outbox_record_invalid");
85
+ const record = JSON.parse(await readFile(item.path, "utf8"));
86
+ if (!validRecord(record) || sensitive(record.report) || item.name.toLowerCase() !== `${record.report.idempotency_key.toLowerCase()}.json`) throw new Error("outbox_record_invalid");
87
+ return record;
88
+ },
89
+ exportDraft: async (record) => {
90
+ const final = join(path, `draft-${record.report.idempotency_key.toLowerCase()}.json`);
91
+ const text = `${JSON.stringify(record.report, null, 2)}\n`;
92
+ if (Buffer.byteLength(text) > MAX_BYTES) throw new Error("outbox_report_too_large");
93
+ const temp = join(path, `.${randomUUID()}.tmp`);
94
+ try {
95
+ const file = await open(temp, "wx", 0o600);
96
+ try { await file.writeFile(text); await file.sync(); } finally { await file.close(); }
97
+ if (process.platform === "win32") await restrictWindowsAcl(temp); else await chmod(temp, 0o600);
98
+ try { await link(temp, final); } catch (error) {
99
+ if (error?.code !== "EEXIST") throw error;
100
+ const info = await lstat(final);
101
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_BYTES || (process.platform !== "win32" && (info.mode & 0o077) !== 0) || await readFile(final, "utf8") !== text) throw new Error("outbox_draft_conflict");
102
+ }
103
+ await syncDir(path);
104
+ return final;
105
+ } finally { await unlink(temp).catch(() => {}); }
106
+ },
107
+ remove: async (item) => { await unlink(item.path); await syncDir(path); },
108
+ });
109
+ } finally { lock.release(); }
110
+ }
111
+
112
+ export async function hasPendingFeedback(root) {
113
+ const path = join(root, ".botbuddy", "feedback-outbox");
114
+ try {
115
+ const info = await lstat(path);
116
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("outbox_directory_invalid");
117
+ return (await readdir(path)).some((name) => /^[0-9a-f-]{36}\.json$/i.test(name));
118
+ } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
119
+ }
@@ -0,0 +1,16 @@
1
+ export const FEEDBACK_GUIDANCE = Object.freeze({
2
+ when: "If BotBuddy is blocking your work",
3
+ init_command: "bb feedback init --file bb-feedback.json",
4
+ submit_command: "bb feedback submit --file bb-feedback.json",
5
+ context: "Add your goal, expected and actual outcomes, evidence and why you are blocked. Preserve the original run and the report's idempotency key.",
6
+ });
7
+ let guidanceAlreadyShown = false;
8
+ export function markFeedbackGuidanceShown() { guidanceAlreadyShown = true; }
9
+
10
+ /** Print only guidance, not an automatic incident classification. A failed
11
+ * child test, invalid input or ordinary refusal does not prove a BB defect. */
12
+ export function feedbackReminder(argv, code) {
13
+ if (guidanceAlreadyShown || !Number.isInteger(code) || code === 0 || argv[0] === "feedback" || argv.includes("--json") ||
14
+ (argv[0] === "run" && ["--worker", "--deliver"].includes(argv[1]))) return null;
15
+ return `${FEEDBACK_GUIDANCE.when}, run \`${FEEDBACK_GUIDANCE.init_command}\`, add your goal, expected and actual outcomes, evidence and why you are blocked, then run \`${FEEDBACK_GUIDANCE.submit_command}\`. Preserve the original run and the report's idempotency key.`;
16
+ }
@@ -0,0 +1,31 @@
1
+ import { containsSensitive } from "./telemetry-outbox.mjs";
2
+
3
+ // Only non-secret BB receipt queries are shareable; arbitrary query strings
4
+ // and fragments may contain signed credentials and remain forbidden.
5
+ export function isSafeBotBuddyReceiptUrl(value) {
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.origin !== "https://app.bot-buddy.ai" || url.username || url.password || url.hash) return false;
9
+ const key = url.pathname === "/waits" ? "wait" : url.pathname === "/docker" ? "lease" : null;
10
+ if (!key || [...url.searchParams.keys()].length !== 1) return false;
11
+ const id = url.searchParams.get(key);
12
+ return typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
13
+ } catch { return false; }
14
+ }
15
+
16
+ export function containsFeedbackSecret(value) {
17
+ // This public numeric setting resembles a bb_ credential. Exempt its exact
18
+ // case-sensitive name only; still scan assigned values and surrounding text.
19
+ if (typeof value === "string") {
20
+ value = value.replace(/\\u([0-9a-f]{4})/gi, (_match, hex) => String.fromCharCode(parseInt(hex, 16)));
21
+ return containsSensitive(value.replace(/\bBB_HERMETIC_INTEGRATION_TIMEOUT_SEC\b/g, "PUBLIC_TIMEOUT_SETTING")) ||
22
+ /-----begin[a-z0-9 ]*private key-----/i.test(value) ||
23
+ /\\?["'][a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*\\?["']\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,}\]]+)/i.test(value) ||
24
+ /\b[a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,}\]]+)/i.test(value) ||
25
+ (!isSafeBotBuddyReceiptUrl(value) && /https?:\/\/[^\s<>"']+[?#][^\s<>"']+/i.test(value)) ||
26
+ /\b(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|apikey)\s*:\s*\S+/i.test(value) ||
27
+ /\blin_api_[a-z0-9_-]{12,}|\bsk-(?:proj-|svcacct-)?[a-z0-9_-]{20,}|\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/i.test(value);
28
+ }
29
+ if (Array.isArray(value)) return value.some(containsFeedbackSecret);
30
+ return value !== null && typeof value === "object" && Object.entries(value).some(([key, item]) => (/^[a-z0-9_-]*(?:password|passwd|token|api[_-]?key|private[_-]?key|secret|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie)[a-z0-9_-]*$/i.test(key) && item != null && item !== "") || containsFeedbackSecret(key) || containsFeedbackSecret(item));
31
+ }
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdtemp, readdir } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { cmdFeedback } from "./feedback.mjs";
7
+ import { cmdRun, OUTCOME_CHANNEL } from "./stack.mjs";
8
+ import { harnessSessionIdFromEnv } from "./agent-state.mjs";
9
+
10
+ const endpoint = process.argv[2];
11
+ const out = [], errors = [];
12
+ const evidenceLink = "https://app.bot-buddy.ai/waits?wait=485c66f8-c804-4a0f-a49e-2ceaf06344f8";
13
+ const deps = (foreign = false) => ({
14
+ read: async () => JSON.stringify({ idempotency_key: randomUUID(), kind: "bug", surface: "cli", title: "CLI feedback integration", impact: "slowed", evidence: [{ type: "url", value: evidenceLink }] }),
15
+ resolveAuth: async () => ({ auth: {} }),
16
+ call: async (tool, args) => (await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tool, args, foreign }) })).json(),
17
+ log: (line) => out.push(line), errorLog: (line) => errors.push(line),
18
+ });
19
+ assert.equal(await cmdFeedback(["submit", "--file", "report.json"], deps()), 0);
20
+ const receipt = JSON.parse(out.pop()), id = receipt.feedback_id;
21
+ assert.equal(receipt.status_command, `bb feedback status --feedback-id ${id}`);
22
+ assert.equal(await cmdFeedback(["status", "--feedback-id", id], deps()), 0);
23
+ const status = JSON.parse(out.pop());
24
+ assert.equal(status.feedback_id, id);
25
+ assert.equal(status.report.evidence[0].value, evidenceLink);
26
+ assert.equal(await cmdFeedback(["updates", "--feedback-id", id], deps()), 0);
27
+ const updates = JSON.parse(out.pop());
28
+ assert.ok(updates.items.length > 0);
29
+ const revision = updates.items[0].revision;
30
+ assert.equal(updates.items[0].acknowledge_command, `bb feedback acknowledge --feedback-id ${id} --revision ${revision}`);
31
+ assert.equal(await cmdFeedback(["acknowledge", "--feedback-id", id, "--revision", String(revision)], deps()), 0);
32
+ assert.equal(JSON.parse(out.pop()).revision, revision);
33
+ assert.equal(await cmdFeedback(["updates", "--feedback-id", id], deps()), 0);
34
+ assert.ok(JSON.parse(out.pop()).items[0].acknowledged_at);
35
+ assert.deepEqual(errors, []);
36
+ assert.equal(await cmdFeedback(["status", "--feedback-id", id], deps(true)), 1);
37
+ assert.match(errors[0], /unavailable/);
38
+ const sourceRun = randomUUID();
39
+ const failure = { exitCode: 6, outcome: "failed", code: "TUNNEL_LOST", parkReason: "tunnel_lost", leaseId: randomUUID(), childExitCode: 143, cleanup: { ok: true }, fenced: false };
40
+ Object.defineProperty(failure, OUTCOME_CHANNEL, { value: { auth: {}, callTool: deps().call }, enumerable: false });
41
+ const originalWrite = process.stdout.write;
42
+ let stackReceipt = "", feedbackMessage = "";
43
+ try {
44
+ process.stdout.write = (chunk) => { stackReceipt += chunk; return true; };
45
+ const code = await cmdRun({ repo: "botbuddy-web", ticket: "BOT-1540", runId: sourceRun }, ["true"], { lifecycle: async () => failure, env: { BOTBUDDY_STACK_RUN_OUTCOME: "on" }, feedbackLog: (message) => { feedbackMessage += message; }, markFeedback: () => {} });
46
+ assert.equal(code, 6);
47
+ } finally { process.stdout.write = originalWrite; }
48
+ assert.equal(JSON.parse(stackReceipt).exit_code, 6);
49
+ assert.equal(JSON.parse(stackReceipt).feedback_recovery.recover_command, "bb feedback recover");
50
+ const capturedId = /bb feedback status --feedback-id ([0-9a-f-]+)/i.exec(feedbackMessage)?.[1];
51
+ assert.ok(capturedId, "stack failure must expose the captured feedback ID");
52
+ assert.ok(feedbackMessage.includes(`bb feedback enrich --feedback-id ${capturedId}`));
53
+ assert.equal(await cmdFeedback(["status", "--feedback-id", capturedId], deps()), 0);
54
+ const auto = JSON.parse(out.pop());
55
+ assert.equal(auto.report.correlation_id, sourceRun);
56
+ assert.equal(auto.capture_state, "needs_context");
57
+ assert.equal(auto.failure_attribution, "exact");
58
+ const duplicateDeps = deps();
59
+ duplicateDeps.read = async () => JSON.stringify({ idempotency_key: randomUUID(), kind: "bug", surface: "stack", title: "A separately drafted report", impact: "blocked", correlation_id: sourceRun });
60
+ const duplicateExit = await cmdFeedback(["submit", "--file", "report.json"], duplicateDeps);
61
+ assert.equal(duplicateExit, 1, `duplicate-source receipt: ${out.at(-1)}`);
62
+ const duplicate = JSON.parse(out.pop());
63
+ assert.equal(duplicate.error, "source_already_reported");
64
+ assert.equal(duplicate.feedback_id, capturedId);
65
+ assert.equal(duplicate.submitted_context_saved, false);
66
+ assert.deepEqual(duplicate.enrich_argv, ["bb", "feedback", "enrich", "--feedback-id", capturedId, "--file", "report.json"]);
67
+ assert.equal(await cmdFeedback(["recover"], deps()), 0);
68
+ assert.equal(JSON.parse(out.pop()).complete, true);
69
+ // Lose the first reply after the real MCP handler commits; the private outbox
70
+ // must replay the same key only after real session whoami confirms its owner.
71
+ const root = await mkdtemp(join(tmpdir(), "bb-feedback-live-replay-"));
72
+ const queueKey = randomUUID(), fixtureToken = "fixture-session-token";
73
+ let dropResponse = true;
74
+ const outboxDeps = {
75
+ ...deps(), cwd: root, rootFor: async () => root,
76
+ loadState: async () => ({ agent_id: process.argv[3], tenant: "botbuddy", agent_session_token: fixtureToken, harness_session_id: harnessSessionIdFromEnv() }),
77
+ resolveAuth: async () => ({ auth: { "x-agent-api-key": fixtureToken }, sessionToken: true }),
78
+ read: async () => JSON.stringify({ idempotency_key: queueKey, kind: "bug", surface: "cli", title: "Offline replay fixture", impact: "slowed", evidence: [{ type: "url", value: evidenceLink }] }),
79
+ call: async (tool, args) => {
80
+ const response = await (await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tool, args, session: true }) })).json();
81
+ if (tool === "submit_feedback" && dropResponse) { dropResponse = false; return { ok: false, status: null, error: "lost_reply" }; }
82
+ return response;
83
+ },
84
+ };
85
+ assert.equal(await cmdFeedback(["submit", "--file", "report.json"], outboxDeps), 5);
86
+ assert.equal(JSON.parse(out.pop()).queued_locally, true);
87
+ assert.equal(await cmdFeedback(["flush"], outboxDeps), 0);
88
+ assert.equal(JSON.parse(out.pop()).delivered, 1);
89
+ assert.deepEqual(await readdir(join(root, ".botbuddy", "feedback-outbox")), []);
90
+ dropResponse = true;
91
+ const duplicateKey = randomUUID();
92
+ outboxDeps.read = async () => JSON.stringify({ idempotency_key: duplicateKey, kind: "bug", surface: "stack", title: "Queued tunnel report", impact: "blocked", correlation_id: sourceRun });
93
+ assert.equal(await cmdFeedback(["submit", "--file", "report.json"], outboxDeps), 5);
94
+ assert.equal(JSON.parse(out.pop()).queued_locally, true);
95
+ assert.equal(await cmdFeedback(["flush"], outboxDeps), 1);
96
+ const queuedDuplicate = JSON.parse(out.pop());
97
+ assert.equal(queuedDuplicate.error, "source_already_reported");
98
+ assert.equal(queuedDuplicate.feedback_id, capturedId);
99
+ assert.equal(await cmdFeedback(["enrich", "--feedback-id", capturedId, "--file", queuedDuplicate.enrich_argv[6]], outboxDeps), 0);
100
+ out.pop();
101
+ assert.equal(await cmdFeedback(["flush"], outboxDeps), 0);
102
+ assert.equal(JSON.parse(out.pop()).delivered, 1);
103
+ process.stdout.write("feedback CLI round-trip passed\n");