diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af1842..5e814d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/); versions are [SemVer](https://semver.org/). +## [2.3.1] — 2026-08-14 — Bob 2.0.3: mode preflight + +Verified against the 2.0.3 bundle and a live store: `startTask`, the tasks/messages/`task_pending_approvals` +schema (newest migration is still 2.0.2's `010_pending_approvals`), the active→running→active lifecycle, and +the `settings.json` auto-approve keys are all unchanged, so 2.0.3 needs no driver changes. The `costs` JSON +gained a `contextTokens` field, which the existing parser ignores. The fix below is a pre-existing gap 2.0.3 +did not cause. + +### Fixed + +- **A mode the workspace can't load no longer hangs the dispatch.** `review`/`refactor`/`devsecops` are not + Bob built-ins — 2.0.3 ships `agent`/`plan`/`ask` — they come from the workspace's `.bob/custom_modes.yaml` + that `init-project-board.mjs` installs. In a project without that file the slug doesn't resolve, and Bob + reports it by doing nothing: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview + and returns, after `openTask` has already created the task row. The driver correlated a row that never ran, + so `updated_at` never passed `created_at`, the completion watch never settled, and the dispatch burned its + full wall clock to report a bare `timeout`. It now resolves the slug against the workspace's modes first, + names the file to add, and runs the turn in a fallback mode rather than stalling. The fallback preserves + the mode's safety profile: a read-only slug lands on `ask` — the only built-in with no `edit` group — not + on write-capable `agent`, so a review that lost its custom mode still cannot rewrite the code it was sent + to inspect. + ## [2.3.0] — 2026-08-07 — Bob 2.0.2: trust preflight + approval-wedge fast-abort Verified against the 2.0.2 bundle: every contract the driver relies on (startTask, tasks/messages schema, diff --git a/claude-plugin/.claude-plugin/plugin.json b/claude-plugin/.claude-plugin/plugin.json index 8dcffbe..d36b6d2 100644 --- a/claude-plugin/.claude-plugin/plugin.json +++ b/claude-plugin/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "bob-companion", "displayName": "Bob Companion", "description": "Use Claude Code from any repo as the foreman and worker for the IBM Bob task board: provision, route, triage, and drain tasks Bob shares. Ships a self-contained MCP server.", - "version": "2.3.0", + "version": "2.3.1", "author": { "name": "Joshua Gilbert" }, diff --git a/extension/package.json b/extension/package.json index d142a3d..0eca0c1 100644 --- a/extension/package.json +++ b/extension/package.json @@ -2,7 +2,7 @@ "name": "bob-tasks", "displayName": "Bob Tasks", "description": "Auto-dispatch queued tasks to IBM Bob, with mode routing, a safety gate, defer-while-chatting, and native notifications.", - "version": "2.3.0", + "version": "2.3.1", "publisher": "local", "license": "Apache-2.0", "engines": { diff --git a/lhm.plugin.json b/lhm.plugin.json index 0a50a9a..4b1fdb2 100644 --- a/lhm.plugin.json +++ b/lhm.plugin.json @@ -1,7 +1,7 @@ { "identifier": "pounceai-bob-control", "name": "Bob Control", - "version": "2.3.0", + "version": "2.3.1", "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board. The board path can be configured via environment variables such as BOB_TASKS_DB, BOB_TASKS_PORTABLE, or BOB_TASKS_WORKTREE_SHARED.", "author": "PounceAI", "authorUrl": "https://github.com/PounceAI", diff --git a/package-lock.json b/package-lock.json index ab64295..00f90e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bob-control", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bob-control", - "version": "2.3.0", + "version": "2.3.1", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index a59036e..bb8ce60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pounceai/bob-control", - "version": "2.3.0", + "version": "2.3.1", "description": "Bob Control: MCP server + CLI + worker that runs IBM Bob (and any MCP-capable agent) unattended against a SQLite task board.", "author": "Joshua Gilbert", "license": "Apache-2.0", diff --git a/src/bob2-driver.test.ts b/src/bob2-driver.test.ts index 2cb1671..fbc552d 100644 --- a/src/bob2-driver.test.ts +++ b/src/bob2-driver.test.ts @@ -353,10 +353,11 @@ test("dispatch surfaces a post-start store-open fault (cold start, then the db w test("toBob2Mode remaps removed 1.x built-ins to agent; passes through 2.0 built-ins and custom modes", () => { // removed 1.x built-ins our router still emits → agent (Bob 2.0 throws "Mode not found" on these) for (const m of ["code", "advanced", "orchestrator"]) assert.equal(toBob2Mode(m), "agent"); - // 2.0 built-ins pass through - for (const m of ["agent", "ask", "plan", "review"]) assert.equal(toBob2Mode(m), m); - // CUSTOM modes pass through unchanged — Bob 2.0 loads custom_modes.yaml and resolves them - for (const m of ["refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m); + // 2.0 built-ins pass through (agent/plan/ask are Bob 2.0.3's DEFAULT_MODES) + for (const m of ["agent", "ask", "plan"]) assert.equal(toBob2Mode(m), m); + // CUSTOM modes pass through unchanged — incl. review, which is OURS (workspace .bob/custom_modes.yaml), + // not a Bob built-in. Whether the workspace actually loads them is the preflight's job, not this map's. + for (const m of ["review", "refactor", "devsecops", "my-custom-mode"]) assert.equal(toBob2Mode(m), m); // no mode → Bob's default coding mode assert.equal(toBob2Mode(undefined), "agent"); assert.equal(toBob2Mode(null), "agent"); @@ -384,6 +385,47 @@ test("dispatch forwards the WorkspaceFolder OBJECT (not the string) and the tran assert.equal(seenMode, "agent"); // board "code" → Bob 2.0 "agent" }); +test("a mode the workspace can't load warns and downgrades — the dispatch still runs, it does not fail", async () => { + const { store, seedRoot, bump } = makeStore(); + let seenMode: unknown = "UNSET"; + let id = ""; + const warnings: string[] = []; + const host = makeHost({ + folder: DIR, // no .bob/custom_modes.yaml under it, so `review` is not loaded + startTask: (o) => { + seenMode = o.mode; + id = seedRoot("running"); + }, + }); + const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast }); + setTimeout(() => bump(id, "active"), 15); + const res = await driver.dispatch({ text: "review the diff", mode: "review" }); + assert.equal(res.status, "completed"); // the point: a missing mode must not fail the dispatch + assert.equal(seenMode, "ask"); // read-only stays read-only — never the write-capable agent + assert.equal(warnings.length, 1); + assert.match(warnings[0], /mode 'review' is not loaded/); + assert.match(warnings[0], /custom_modes\.yaml/); +}); + +test("a mode Bob can load dispatches as itself and prints nothing", async () => { + const { store, seedRoot, bump } = makeStore(); + let seenMode: unknown = "UNSET"; + let id = ""; + const warnings: string[] = []; + const host = makeHost({ + folder: DIR, + startTask: (o) => { + seenMode = o.mode; + id = seedRoot("running"); + }, + }); + const driver = new InProcessDriver(host, { openStore: () => store, warn: (m) => warnings.push(m), ...fast }); + setTimeout(() => bump(id, "active"), 15); + await driver.dispatch({ text: "explain this", mode: "ask" }); // a 2.0 built-in — always loadable + assert.equal(seenMode, "ask"); + assert.deepEqual(warnings, []); +}); + // ── defer-while-chatting: externalActivity (driver wiring over foreignActivity) ───────────────────── test("externalActivity is true while a foreign chat is running in our workspace", async () => { diff --git a/src/bob2-driver.ts b/src/bob2-driver.ts index 23df100..666693c 100644 --- a/src/bob2-driver.ts +++ b/src/bob2-driver.ts @@ -14,6 +14,7 @@ import { type Bob2TaskRow, } from "./bob2-taskstore.js"; import { writeAutoApprove } from "./bob2-config.js"; +import { resolveAvailableMode } from "./bob2-modes.js"; import { producesReviewFindings } from "./modes.js"; import { parseReviewFindings, type ReviewIssue } from "./review-findings.js"; @@ -79,6 +80,8 @@ export interface InProcessDriverOptions { /** How old a persisted pending approval must be before the watch reads it as a wedge (ms). The margin * keeps a just-raised request that Bob is still resolving from aborting a healthy turn. */ approvalWedgeMs?: number; + /** Where the mode-preflight notice goes. Default: `console.warn`. Injected by tests to assert on it. */ + warn?: (message: string) => void; } /** @@ -110,14 +113,16 @@ export function mapOutcome( return { ...base, status: "timeout" }; } -// Removed 1.x built-ins our auto-router still emits; Bob 2.0 throws `Mode with id "" not found` on them -// (built-ins are agent/ask/plan/review, coding = agent). +// Removed 1.x built-ins our auto-router still emits. Bob 2.0's built-ins are agent/plan/ask (DEFAULT_MODES), +// coding = agent, so these three have nothing to resolve to. const BOB2_REMOVED_BUILTIN_MODES: Record = { code: "agent", advanced: "agent", orchestrator: "agent" }; /** - * Board slug → a mode Bob 2.0 resolves. Only the removed 1.x built-ins are rewritten; the 2.0 built-ins - * and every custom mode pass through, since Bob 2.0 loads custom_modes.yaml (so review/refactor/devsecops - * dispatch as themselves; an unregistered slug then surfaces Bob's clean "Mode not found"). No mode → agent. + * Board slug → the slug we ask Bob for. Only the removed 1.x built-ins are rewritten; everything else passes + * through, including review/refactor/devsecops — those are OURS, defined in the workspace's + * .bob/custom_modes.yaml, not Bob built-ins. Whether the open workspace actually loads them is a separate + * question, answered by resolveAvailableMode at dispatch (an unloaded slug does NOT error — Bob just never + * runs the turn), so this map stays a pure rename. No mode → agent. */ export function toBob2Mode(mode: string | undefined | null): string { if (!mode) return "agent"; @@ -268,12 +273,15 @@ export class InProcessDriver implements BobDriver { return fail(`task store: ${(e as Error).message}`); } const snapshot = store ? store.snapshotRoots() : { ids: new Set(), sinceMs: 0 }; + // Mode preflight (see bob2-modes): an unloaded slug would hang the dispatch, so warn and downgrade. + const picked = resolveAvailableMode(toBob2Mode(opts.mode), dir); + if (picked.warning) (this.opts.warn ?? console.warn)(picked.warning); try { try { // workspaceFolder = the WorkspaceFolder object; mode a slug Bob resolves (see Bob2StartTask / toBob2Mode). await this.handle!.startTask({ content: opts.text, - mode: toBob2Mode(opts.mode), + mode: picked.mode, workspaceFolder: this.host.workspaceFolderObject() ?? undefined, }); } catch (e) { diff --git a/src/bob2-modes.test.ts b/src/bob2-modes.test.ts new file mode 100644 index 0000000..f3eb37d Binary files /dev/null and b/src/bob2-modes.test.ts differ diff --git a/src/bob2-modes.ts b/src/bob2-modes.ts new file mode 100644 index 0000000..a7852f2 --- /dev/null +++ b/src/bob2-modes.ts @@ -0,0 +1,70 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { bob2HomeDir } from "./bob2-config.js"; +import { isReadOnlyMode } from "./modes.js"; + +// Will Bob 2.0 resolve this mode slug in this workspace? Worth asking up front because an unresolvable slug +// does NOT error: `startTask` → `handleInputMessage` posts "Invalid mode used." to the webview and returns, +// but `openTask` already created the task row — so the driver correlates a row that never runs, updated_at +// never passes created_at, and the dispatch burns its full wall clock to report a bare 'timeout'. +// Verified against Bob 2.0.3, 2026-08-14. + +/** Bob 2.0.3's DEFAULT_MODES — all that resolves without a custom_modes.yaml. */ +export const BOB2_BUILTIN_MODES: readonly string[] = ["agent", "plan", "ask"]; + +/** + * Custom-mode slugs available in `workspaceDir`, read the way Bob reads them: the workspace's + * `.bob/custom_modes.yaml` (where init-project-board.mjs puts our review/refactor/devsecops) plus the global + * `~/.bob/settings/custom_modes.yaml`. Scraped, not parsed — only slugs matter and the repo carries no YAML + * dep (init-project-board.mjs makes the same call). Over-reporting is the safe direction: a slug we wrongly + * accept just restores the hang above, while one we wrongly miss would downgrade a working mode. + */ +export function customModeSlugs(workspaceDir: string | null): Set { + const files = [ + workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : null, + join(bob2HomeDir(), "settings", "custom_modes.yaml"), + ]; + const slugs = new Set(); + for (const f of files) { + if (!f || !existsSync(f)) continue; + let raw: string; + try { + raw = readFileSync(f, "utf8"); + } catch { + continue; // unreadable config is not a dispatch failure — fall through to the built-ins + } + for (const m of raw.matchAll(/^\s*-?\s*slug:\s*["']?([A-Za-z0-9_-]+)["']?/gm)) slugs.add(m[1]); + } + return slugs; +} + +/** + * Where an unresolvable mode lands. Keyed on the safety profile rather than always `agent`: `ask` is the only + * built-in with no `edit` group, so a read-only slug that lost its custom mode still can't rewrite the code it + * was sent to inspect. It has no `execute` either — a degraded review can't run `git diff`, which is weaker + * but weak in the safe direction. + */ +export function fallbackMode(mode: string): string { + return isReadOnlyMode(mode) ? "ask" : "agent"; +} + +export interface ModeAvailability { + /** The slug to dispatch — `mode` itself, or its fallback. */ + mode: string; + /** Set only when a fallback was substituted. */ + warning?: string; +} + +/** Resolve `mode` for `workspaceDir`. Reports and downgrades, never rejects — a project missing its + * custom_modes.yaml still gets a usable turn. */ +export function resolveAvailableMode(mode: string, workspaceDir: string | null): ModeAvailability { + if (BOB2_BUILTIN_MODES.includes(mode) || customModeSlugs(workspaceDir).has(mode)) return { mode }; + const to = fallbackMode(mode); + const where = workspaceDir ? join(workspaceDir, ".bob", "custom_modes.yaml") : "/.bob/custom_modes.yaml"; + return { + mode: to, + warning: + `bob2: mode '${mode}' is not loaded in this workspace — running as '${to}'. ` + + `Add it to ${where} (e.g. \`node tools/init-project-board.mjs \`).`, + }; +}