Skip to content
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
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.1.0] — 2026-07-03 — npm distribution + untracked-aware verifier

### Added

- **Published to npm as `@pounceai/bob-control`.** `npx -y @pounceai/bob-control` runs the MCP server
standalone via a new `bob-control` bin. The `files` allowlist ships only runtime `dist` (no tests or
fixtures), and a `check:shebang` publish gate guards both bin shebangs.

### Fixed

- **The verifier sees edits to files that stay untracked.** The completion check and LLM judge diffed with
`git status`/`git diff HEAD`, blind to an edit to a file already untracked when the task started — so real
work read as "no changes" and was aborted. Both now diff two untracked-aware `git write-tree` snapshots.
A failed tree diff falls through to the ref diff instead of reporting "no changes", a timed-out snapshot is
killed rather than leaked, and both degradations log to stderr.
- **The create_task race-warning fires for a live-but-idle drainer.** It keyed off in-progress tasks, so it
stayed silent in exactly the mid-curation race it guards. It now reads the worker heartbeat, matched to the
task's tags; the redundant `worker_likely_active` field is dropped from `board_status` (use `worker_draining`).

## [2.0.2] — 2026-06-30 — Faster completion + Bob-skill correctness

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Bob Control

[![CI](https://github.com/PounceAI/bob-control/actions/workflows/ci.yml/badge.svg)](https://github.com/PounceAI/bob-control/actions/workflows/ci.yml)
[![MCP](https://lobehub.com/badge/mcp/pounceai-bob-control)](https://lobehub.com/mcp/pounceai-bob-control)

Bob Control turns an AI coding agent from a one-prompt-at-a-time chat window into an unattended
queue worker. Fill a board and each task gets dispatched, auto-approved inside risk guardrails,
Expand Down Expand Up @@ -40,6 +41,23 @@ npm run build # tsc -> dist/, then bundles claude-plugin/server/server.mjs
npm run smoke # optional self-test
```

Or skip the clone and run the published package — the MCP server (`bob-control`) and CLI
(`bob-tasks`) come straight from npm. Point an MCP client at it instead of an absolute `dist/` path:

```jsonc
{
"mcpServers": {
"bob-tasks": { "type": "stdio", "command": "npx", "args": ["-y", "@pounceai/bob-control"] }
}
}
```

Under Claude Code the board resolves automatically (it sets `CLAUDE_PROJECT_DIR`); any other MCP
client should add `"env": { "BOB_TASKS_DB": "…" }` pointing at the board the worker drains — a bare
`npx` server with no board env writes to a throwaway path inside the npx cache that nothing can share.

The unattended **worker** and the Claude Code **plugin** still want the repo (see below).

Needs Node 22.5+ (uses the built-in `node:sqlite`, so there's no native build step).
Board path resolution: `BOB_TASKS_DB` (explicit) › `BOB_TASKS_PORTABLE=1` (a shared
`~/.bob-tasks/tasks.db`) › `BOB_TASKS_WORKTREE_SHARED=1` (every linked git worktree resolves the
Expand Down
2 changes: 1 addition & 1 deletion claude-plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.0.2",
"version": "2.1.0",
"author": {
"name": "Joshua Gilbert"
},
Expand Down
11 changes: 9 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bob-control",
"version": "2.0.2",
"name": "@pounceai/bob-control",
"version": "2.1.0",
"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",
Expand All @@ -18,9 +18,16 @@
],
"type": "module",
"bin": {
"bob-control": "dist/server.js",
"bob-tasks": "dist/cli.js"
},
"files": ["dist/**/*.js", "!dist/**/*.test.js", "!dist/ipc-test-harness.js", "!dist/smoke.js"],
"publishConfig": {
"access": "public"
},
"scripts": {
"prepublishOnly": "npm run build && npm run check:shebang",
"check:shebang": "node -e \"process.exit(['dist/server.js','dist/cli.js'].every(f => require('fs').readFileSync(f,'utf8').startsWith('#!/usr/bin/env node')) ? 0 : 1)\"",
"build": "tsc && npm run build:plugin",
"build:plugin": "esbuild src/server.ts --bundle --platform=node --format=esm --target=node22 --outfile=claude-plugin/server/server.mjs",
"server": "node dist/server.js",
Expand Down
104 changes: 103 additions & 1 deletion src/bob-polls.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createPollLoop, type PollDeps, type PollResult, type VerifyResult } from "./bob-polls.js";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createPollLoop,
defaultCaptureSnapshot,
defaultCheckDidWork,
type PollDeps,
type PollResult,
type VerifyResult,
} from "./bob-polls.js";

// A recording harness: a poll loop wired to fakes that capture each continue
// dispatch, log line, and note, plus a stub verifier the test controls. A continue
Expand Down Expand Up @@ -517,3 +528,94 @@ test("plan-stop detection: combines with verify-and-continue correctly", async (
assert.match(h.dispatchArgs[0], /presented a plan/);
assert.match(h.dispatchArgs[1], /did NOT pass verification/);
});

// ── defaultCaptureSnapshot: untracked-aware (real git) ────────────────────────────────────────────
// Plan-stop shares the verifier's blind spot: a `git status`/`git diff` snapshot can't see an edit to
// a still-untracked file, so it read as "no new work". The tree-sha snapshot changes on the edit.

function gitRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "bobpolls-snap-"));
const run = (...args: string[]) => spawnSync("git", args, { cwd: dir });
run("init", "-q");
run("config", "user.email", "t@t.t");
run("config", "user.name", "t");
run("config", "commit.gpgsign", "false");
writeFileSync(join(dir, "README.md"), "# repo\n");
run("add", "-A");
run("commit", "-qm", "init");
return dir;
}

test("defaultCaptureSnapshot: an edit to a still-untracked file changes the snapshot", async () => {
const dir = gitRepo();
try {
writeFileSync(join(dir, "scratch.py"), "x = 1\n"); // untracked before the task
const before = await defaultCaptureSnapshot(dir);
assert.notEqual(before, "GIT_ERROR");

writeFileSync(join(dir, "scratch.py"), "x = 1\ny = 2\n"); // task edits the still-untracked file
const after = await defaultCaptureSnapshot(dir);
assert.notEqual(after, "GIT_ERROR");
assert.notEqual(after, before, "editing an untracked file must change the snapshot");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("defaultCaptureSnapshot: an unchanged tree yields a stable snapshot", async () => {
const dir = gitRepo();
try {
writeFileSync(join(dir, "scratch.py"), "x = 1\n");
const a = await defaultCaptureSnapshot(dir);
const b = await defaultCaptureSnapshot(dir);
assert.equal(a, b, "no change → identical snapshot (no false plan-stop)");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("defaultCaptureSnapshot: a non-git cwd yields the GIT_ERROR sentinel", async () => {
const snap = await defaultCaptureSnapshot(join(tmpdir(), "definitely-not-a-git-repo-xyz"));
assert.equal(snap, "GIT_ERROR");
});

// ── defaultCheckDidWork: the plan-stop verdict (real git) ─────────────────────────────────────────
// Direct coverage for the verdict itself, not just the snapshot: an inverted current===baseline check
// or a mis-ordered GIT_ERROR guard would otherwise ship green (the loop tests inject their own stub).

test("defaultCheckDidWork: an unchanged tree since baseline → didWork false (plan-stop)", async () => {
const dir = gitRepo();
try {
writeFileSync(join(dir, "scratch.py"), "x = 1\n");
const baseline = await defaultCaptureSnapshot(dir);
const res = await defaultCheckDidWork(dir, baseline);
assert.equal(res.didWork, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("defaultCheckDidWork: an edit to a still-untracked file since baseline → didWork true", async () => {
const dir = gitRepo();
try {
writeFileSync(join(dir, "scratch.py"), "x = 1\n");
const baseline = await defaultCaptureSnapshot(dir);
writeFileSync(join(dir, "scratch.py"), "x = 1\ny = 2\n");
const res = await defaultCheckDidWork(dir, baseline);
assert.equal(res.didWork, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("defaultCheckDidWork: a GIT_ERROR baseline is treated as work done (never a false plan-stop)", async () => {
const dir = gitRepo();
try {
// Both sides can read GIT_ERROR; the sentinel guard must win over the equality check, else two
// GIT_ERRORs would compare equal and wrongly report "no work".
const res = await defaultCheckDidWork(dir, "GIT_ERROR");
assert.equal(res.didWork, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
73 changes: 8 additions & 65 deletions src/bob-polls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { spawn } from "node:child_process";
import type { DispatchResult } from "./bob-ipc.js";
import { snapshotWorktreeTreeBounded } from "./git.js";

/**
* The poll loop reads and passes a dispatch result straight through, so it IS a DispatchResult
Expand Down Expand Up @@ -106,90 +107,32 @@ export async function defaultVerify(
}

/**
* Capture a content-aware snapshot of the working tree state.
* Combines `git status --porcelain` (new/deleted/modified files) with `git diff HEAD`
* (actual content changes in tracked files). This allows detecting changes even when
* the tree is already dirty from prior tasks.
*
* Known limitation: editing an UNTRACKED file that a prior task created is NOT detected
* (porcelain shows ?? for both snapshots, diff omits untracked content). This is acceptable
* because untracked files are typically intermediate artifacts, not the primary deliverable.
* Working-tree snapshot as a single untracked-aware tree sha: two shas differ iff content changed —
* including edits to files that stay untracked, which a `git status`/`git diff` pair misses (status
* shows the same `??`, diff omits untracked content). null (non-git, git failure, timeout) → GIT_ERROR,
* which checkDidWork reads as "assume work done" over a false plan-stop.
*/
export async function defaultCaptureSnapshot(cwd: string, timeoutMs = 30_000): Promise<string> {
return new Promise<string>((resolve) => {
// Run both commands in parallel for efficiency
const statusProc = spawn("git", ["status", "--porcelain"], { cwd, stdio: "pipe" });
const diffProc = spawn("git", ["diff", "HEAD"], { cwd, stdio: "pipe" });

let statusOut = "";
let diffOut = "";
let completed = 0;
let settled = false;

// Single idempotent exit: clears the timer, kills both children, resolves once. A git that hangs
// (a credential prompt, an index.lock) is bounded by the timeout → GIT_ERROR instead of forever.
const finish = (s: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
for (const p of [statusProc, diffProc]) {
try {
p.kill();
} catch {
/* already exited */
}
}
resolve(s);
};
const timer = setTimeout(() => finish("GIT_ERROR"), timeoutMs);
timer.unref?.();

const checkComplete = () => {
if (++completed === 2) finish(`STATUS:\n${statusOut}\nDIFF:\n${diffOut}`);
};

statusProc.stdout?.on("data", (chunk: Buffer) => (statusOut += chunk.toString()));
statusProc.on("close", (code: number | null) => (code !== 0 ? finish("GIT_ERROR") : checkComplete()));
statusProc.on("error", () => finish("GIT_ERROR"));

diffProc.stdout?.on("data", (chunk: Buffer) => (diffOut += chunk.toString()));
diffProc.on("close", (code: number | null) => (code !== 0 ? finish("GIT_ERROR") : checkComplete()));
diffProc.on("error", () => finish("GIT_ERROR"));
});
return (await snapshotWorktreeTreeBounded(cwd, timeoutMs)) ?? "GIT_ERROR";
}

/**
* Check if real work happened by comparing the current snapshot to a baseline.
* Returns didWork=false if the snapshot is unchanged (no new changes since baseline),
* meaning Bob likely just presented a plan without implementing it.
*/
async function defaultCheckDidWork(cwd: string, baseline: string): Promise<WorkCheckResult> {
export async function defaultCheckDidWork(cwd: string, baseline: string): Promise<WorkCheckResult> {
const current = await defaultCaptureSnapshot(cwd);

// Git command failed: conservatively assume work happened.
if (current === "GIT_ERROR" || baseline === "GIT_ERROR") {
return { didWork: true, reason: "git check failed, assuming work done" };
}

// Compare snapshots
if (current === baseline) {
return { didWork: false, reason: "working tree unchanged from baseline (no new changes)" };
}

// Snapshot changed: work detected
const statusBefore = baseline.match(/STATUS:\n(.*?)\nDIFF:/s)?.[1] || "";
const statusAfter = current.match(/STATUS:\n(.*?)\nDIFF:/s)?.[1] || "";
const beforeLines = statusBefore.trim() ? statusBefore.trim().split("\n").length : 0;
const afterLines = statusAfter.trim() ? statusAfter.trim().split("\n").length : 0;
const delta = afterLines - beforeLines;

if (delta > 0) {
return { didWork: true, reason: `${delta} new file change${delta === 1 ? "" : "s"} detected` };
} else if (delta < 0) {
return { didWork: true, reason: `${-delta} file change${delta === -1 ? "" : "s"} resolved` };
} else {
return { didWork: true, reason: "file content changed" };
}
return { didWork: true, reason: "working tree changed since baseline" };
}

/**
Expand Down
32 changes: 31 additions & 1 deletion src/checkpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
preserveWipToBranch,
releaseCheckpoint,
} from "./checkpoint.js";
import { snapshotWorktreeTree } from "./git.js";
import { snapshotWorktreeTree, snapshotWorktreeTreeBounded } from "./git.js";
import { getDb, createTask, setCheckpoint, getCheckpoint, clearCheckpoint, getNotes, recordArtifact } from "./db.js";

function git(dir: string, ...args: string[]): string {
Expand Down Expand Up @@ -202,6 +202,36 @@ describe("checkpoint capture + restore (real git)", () => {
);
rm(dir);
});

it("snapshotWorktreeTreeBounded returns the same tree as the unbounded call under a generous timeout", async () => {
const dir = makeRepo();
commit(dir, "tracked.txt", "v1");
writeFileSync(join(dir, "untracked.txt"), "new");
const [bounded, unbounded] = await Promise.all([
snapshotWorktreeTreeBounded(dir, 30_000),
snapshotWorktreeTree(dir),
]);
assert.ok(bounded && /^[0-9a-f]{40}$/.test(bounded));
assert.equal(bounded, unbounded, "bounded wrapper yields the identical snapshot when git is fast");
rm(dir);
});

it("snapshotWorktreeTreeBounded resolves null on a non-git cwd (and never throws)", async () => {
const dir = mkdtempSync(join(tmpdir(), "bob-nogit-"));
assert.equal(await snapshotWorktreeTreeBounded(dir, 30_000), null);
rm(dir);
});

it("snapshotWorktreeTree: an aborted signal yields null and leaves no temp index behind", async () => {
const dir = makeRepo();
commit(dir, "tracked.txt", "v1");
const ac = new AbortController();
ac.abort(); // stand in for a bounded-caller timeout: the git children must be killed, not orphaned
assert.equal(await snapshotWorktreeTree(dir, ac.signal), null);
const leftovers = readdirSync(join(dir, ".git")).filter((f) => f.startsWith("bob-tmp-index-"));
assert.deepEqual(leftovers, [], "the temp index must be cleaned up even when git is aborted");
rm(dir);
});
});

describe("checkpoint orchestration + persistence (db)", () => {
Expand Down
Loading