BullseyeBacklog
A backlog whose source of truth is a plain Markdown file. BACKLOG.md sits in your repo, git-versioned and hand-editable — you can read it raw, edit it in vim or GitHub's web editor, and diff it like any other file. On top of it this extension adds a webview grid, an AI scrub judge, an ask-and-answer queue that puts a question from your AI on the story instead of in a chat log, an MCP server so agent-mode AI drives the same engine you do, and a semantic git merge driver so two machines editing the backlog do not shred it.
The file is the source of truth; the AI is the assistant. Nothing lives in a database, nothing is proprietary, and every mutation — human or agent — goes through one engine that re-serializes the file to a canonical form. There is no state the AI owns that you cannot open in an editor.
The three files
| File |
What it holds |
BACKLOG.md |
The AT-A-GLANCE table, the OPEN story bodies, and today's DONE receipts. |
BACKLOG_ARCHIVE.md |
DONE receipts moved out of the backlog, newest-first. Append-only history. |
BACKLOG_FUTURE.md |
Deferred proposals — one per ### heading. |
They live in the workspace root by default, or wherever bullseyeBacklog.folder points — the panel, the file watcher and the MCP server all read that one setting, so they cannot disagree about which backlog is real. Missing files are seeded from the canonical renderer (src/templates.ts), not from hand-kept text, so a fresh file is byte-identical to what a mutation would write. Stale-format files are migrated on open (src/extension.ts).
Writes are atomic — temp file, fsync, rename — under a per-backlog cross-process writer lock keyed to the absolute path, so the extension and a running MCP server never interleave writes (../engine/src/api.ts).
The panel
Opens as an editor tab (a webview panel), not an Activity Bar view. It appears automatically when the workspace has a backlog; otherwise use BullseyeBacklog: Open in Editor Tab, or click the status-bar item.
Status bar
A $(target) N item sits immediately right of the git branch indicator, showing open-story count and a $(warning) N badge for posts still waiting on you. Clicking it opens the panel. Hovering gives a Markdown card with the logo, the running version, lane counts, a per-priority open/blocked table, and clickable Open · Refresh · Scrub · Archive · Posts links (src/extension.ts).
At-A-Glance grid
Stories are grouped by lane (OUTSTANDING / ONGOING / WAITING), each lane collapsible, with a separate collapsed 📦 Done strip. Columns: THEME · PRI · STORY · SIZE · CREATED · DONE · % LEFT.
- Sort by clicking a column header — three states, ascending → descending → back to file order. File order is exactly what
BACKLOG.md renders (priority, then deps, blocked last, effort, % desc). The toolbar also carries a segmented 📄 File / 🔴 Pri / 📏 Size / 📊 % / # ID control. The chosen sort produces one flat list with nothing hoisted out of it — blocked rows stay marked by their own styling rather than being pulled into a section that breaks the order you just asked for.
- Resize any column by dragging its header edge; widths persist.
- Priority ledger — a chip per priority in the sticky header, carrying the live open count. Clicking a chip filters the grid to that priority; All clears it.
- 🚧 only checkbox filters to blocked rows.
- Find (
Ctrl/Cmd+F) highlights every match, Enter / Shift+Enter step through them, Esc clears. 📄 Details widens the search into story bodies and opens the collapsed body of every hit. Text not found in the open list is searched in BACKLOG_ARCHIVE.md and BACKLOG_FUTURE.md with surrounding context.
- Expand / Collapse all, and per-story carets.
Per-story actions
Expand a story to see Scope, Mandate, Files, Accept, Remaining, Deps and Posts, plus buttons: Ship, Block / Unblock, Edit headline (validated against the 80-char / 20-word cap in the field), Priority picker, and lane moves (→ OUTSTANDING / → ONGOING / → WAITING — human-only by design; the MCP move tool refuses without humanConfirmed).
Click the % LEFT cell to edit percent and the LEFT clause inline. A bare — is rejected: a percent with no honest LEFT clause is the classic dishonest-progress tell.
canonical (on-disk file is byte-identical to serialize(model)) · MCP (server registered for the agent) · scrub (last scrub state vs. the file on disk) · ⚠ N drifted (stories whose referenced files have an mtime newer than BACKLOG.md — the code moved on and the row did not, see src/drift.ts) · ❓ N posts pending · 🔀 merge when the last git merge left a record.
Panels
📜 Log — every message the panel sent and what the engine did with it, replayed into a reopened panel. 🔀 Merge — the last merge, story by story. ❓ Posts — the ask-and-answer queue. ⚙️ Settings — every setting with a folder browser, live model discovery, provider presets and a real connection test. Ship composer — verb, engine-stamped HH:MM:SS in machine-local time, receipt body, Verify / Operator-verify clause, and a canonical receipt preview before you commit.
The panel live-reloads on any write to the backlog (fs.watch on the file, plus every directory containing a file referenced by a story's filePaths).
Scrub
Two halves, one button.
Mechanical (../engine/src/api.ts) — re-derives the 🚧 blocked mirror, re-sorts, rewrites BACKLOG.md canonically, and regenerates the AT-A-GLANCE view. The AAG↔OPEN bijection, the anchors, the --- separators and the id ordering are recomputed, never eye-verified.
Judgment — the calls the engine refuses to fake: is this percent honest given the LEFT clause and the remaining scope, is this blocker a real external dependency or a false gate, is this story actually shippable. There is no keyword list; keyword matching got blockers wrong in both directions. The judge sees the whole backlog in one conversation, which is what surfaces the duplicate story, the story another one silently finished, and the dependency that already shipped.
With bullseyeBacklog.ai.codebaseAccess on (default), the judge gets read-only Read / Grep / Glob over the workspace and is told the story's own fields are the claim, not the evidence. Your repo's own CLAUDE.md is deliberately not auto-discovered, so it cannot colour a backlog verdict.
What gets applied without asking: setPercent and unblock verdicts at or above ai.confidenceThreshold; ship verdicts are applied regardless of confidence. The reasoning is that the file is git-versioned — a wrong ship is one git diff away from being seen and one revert away from being gone — and the panel lists everything it did, with per-story confidence, reasoning and the full prompt/response trace. keep verdicts and below-threshold verdicts are reported and not written.
The judge is a live, cancellable run with per-story cards, a timer, projected cost, and measured token usage (null, not zero, when the provider cannot report). Auto-scrub on file change is off by default because that judgment is a paid model call over the whole backlog — see autoScrubCooldownMs.
A backlog file that will not parse is offered to the same provider for structural repair, under a prompt that may only fix broken table rows, malformed headings and duplicate ids — never user-authored text — and the result is rejected outright if it loses the AAG markers (src/corruptionFixer.ts).
AI providers
Four, no auto-probe — you choose, and an unset provider is an error that names the fix (src/llm/registry.ts):
| Provider |
What it needs |
vscode-lm |
A model already registered with VS Code — Copilot, or any BYOK key added in the Chat model picker. BYOK needs no Copilot subscription. |
claude-cli |
Your installed claude binary, reusing its existing login. No API key, no extra cost. |
anthropic-api |
Your own Anthropic API key. ai.baseUrl can point it at an Anthropic-compatible host. |
openai-compatible |
Any /chat/completions endpoint — Ollama, LM Studio, OpenRouter, DeepSeek. |
All four can audit against the source tree: the CLI through its own tools under --add-dir, the other three through a shared read-only tool box.
Ship and archive
Ship closes a story into ## DONE with a stamped receipt: verb, title, body, and a Verify: or Operator-verify: clause. Theme, priority and size travel with it. Partial ship (MCP) lands a piece: a DONE receipt for what landed, plus a rewritten row for the rest.
Archive moves every DONE receipt into BACKLOG_ARCHIVE.md, newest-first, one click. Id generation is archive-aware, so ids never reset onto a number the archive is already holding. rollDone (MCP) keeps only the most recent working day in DONE.
Ask and answer — the post queue
Your AI's questions, findings and artifacts go onto the story, not into a chat transcript that scrolls away. Four kinds: QUESTION (a choice), READOUT (a finding or summary), ARTIFACT (a document, with a link), MESSAGE (anything else). Each renders ❓ on the row and in the toolbar pill until it is settled, and the whole thread stays in the story body and archives with it.
- Free text is mandatory. A card may offer 2–4 options, but every card also renders an Other box, and what you type there is what gets recorded — never the option label. The engine refuses a post that tries to supply its own "Other" placeholder as a selectable choice. Set
multiSelect when several answers can be true at once.
- A post is a conversation. Reply, and the agent owes you a
respond(); your follow-up on top of an answer is where the real direction arrives. pendingPosts tags each unsettled post waitingOn: OWNER or waitingOn: AGENT so neither side loses track of whose turn it is.
- Ack is one click, offered only where it means something — a readout with no options and no turns yet.
- Dismiss settles a post that events overtook: the work shipped in a receipt, you ruled on the story instead of the card, the premise went stale. It is never a silent delete — who, when and why stay on the story forever. A reason is optional for you (demanding one made dismissing cost as much as answering, so the queue never drained) and mandatory for an agent. Dismiss all drains the queue in one action, each dismissed individually so one refusal cannot swallow the rest.
MCP server — for agent-mode AI
The same engine, exposed as MCP tools, over two faces that can never advertise different backlog sources for the same workspace:
- VS Code agent mode / Copilot — registered at activation via
vscode.lm.registerMcpServerDefinitionProvider (VS Code 1.101+), launched with the resolved backlog folder as its cwd (src/mcpProvider.ts).
- Claude Code — the extension writes the workspace's
.mcp.json on activation, owning only the bullseye-backlog key and carrying every other server's key through untouched (src/claudeWiring.ts). A malformed .mcp.json is reported and never clobbered. The entry resolves the server bundle at run time across Code / Insiders / VSCodium, so it survives extension upgrades.
The server ships the lifecycle contract as MCP instructions, so it reaches Cursor, Copilot and anything else driving the backlog — create before you write code, move the percent after each chunk, ship the same turn the work is done, unblock anything the owner could simply do, and never leave a question only in prose.
| Tool |
What it does |
create |
Open a story before writing code. Several asks in one message means several calls. |
get |
Read one story by id as a structured object. |
list |
List stories, filtered by pri / lane / blocked / theme / percent range. |
update |
Edit any field except id and lane; row and body reflow together. |
remove |
Delete a story that was never shipped, with a reason. |
ship |
Close a story with a stamped receipt. |
partial |
Land a piece: DONE receipt for what landed, rewritten row for the rest. |
setPercent |
Update % with a LEFT clause naming the true next step. Bare — rejected. |
block |
Mark a story blocked by a real external dependency. |
unblock |
Clear a blocker. |
post |
Record a question / readout / artifact / message on the story. |
pendingPosts |
Every unsettled post, tagged waitingOn OWNER or AGENT. Never writes. |
respond |
Answer a post the owner spoke on last. Refused otherwise. |
dismiss |
Settle a post events overtook, with the reason that overtook it. |
move |
Move lane. Requires humanConfirmed: true. |
scrub |
Mechanical scrub + a judgment checklist for the calling AI to rule on. |
archive |
Move all DONE receipts to BACKLOG_ARCHIVE.md. |
rollDone |
Keep only the most recent working day in DONE. |
status |
Assembled AAG print + counts + 🚧 blockers + 🅿️ parking lot. Never writes. |
validate |
Invariant audit, per story PASS or FAIL with rule and reason. Never writes. |
next |
Select the top non-blocked OUTSTANDING story. Selection only. |
render |
The whole backlog as canonical markdown — the exact bytes a write produces. |
Source: ../mcp/src/server.ts. Mutation results are slimmed on the wire — the full diff and story echo exist for the panel, which renders both; an agent can render neither, and the diff alone was 87–91% of every payload.
scrub over MCP does not spend a second model: it hands the calling AI a checklist, on the reasoning that the agent already holds the working set and reading the code costs it almost nothing, while a server-side judge would pay a model to rebuild that context.
Semantic git merge driver
Two machines editing one BACKLOG.md is the normal case, and git's line merge shreds it — a table where every row is a story and every story has a body is exactly the shape line-level three-way merging gets wrong. So the extension registers a per-story merge driver (../engine/src/mergeDriver.ts, src/mergeWiring.ts):
- The
.gitattributes half is committed and travels with the clone. The merge.bullseye.driver half lives in .git/config, which is never cloned — so the extension writes it on every activation, idempotently, on every machine that installs it.
- All three files are covered.
BACKLOG.md merges story by story with the AAG table recomputed; BACKLOG_ARCHIVE.md merges as a per-receipt union, so a receipt archived independently on each host is kept under both rather than chosen between; BACKLOG_FUTURE.md merges per proposal, and two hosts editing the same proposal get both blocks back to back rather than one silently dropped.
- It never writes a conflict marker and always resolves. An unmerged backlog is a defeat however well the content merged — it lands in Merge Changes asking you which of your own two machines to discard, which is the one question you cannot answer.
- A shipped receipt can never be deleted by a merge. Deletability is a property of the entity: only an open story can go, because
remove() demands a reason for it.
- Two tripwires stop a merge, and only these: an id present on a side that reached neither the merged file nor a documented departure, and a merged file that does not read back as itself through parse→render. Either throws, leaving your file untouched.
- Every merge writes a record into the repository's own git directory — local, never cloned, never committed — categorising each id as ADDED / SHIPPED / EDITED / ARCHIVED / DELETED / KEPT / RENUMBERED / UNTOUCHED / RECONCILED. The panel's 🔀 Merge button reads it back, so "what did the last merge do to my backlog" is answerable hours later, on the machine that was not looking when it happened.
Commands
Every command is under the BullseyeBacklog category in the Command Palette.
| Command |
Title |
Notes |
bullseyeBacklog.open |
Open in Editor Tab |
Opens (or reveals) the panel. |
bullseyeBacklog.createBacklog |
Create Backlog |
Palette entry hidden once a backlog exists. Seeds the three files and wires MCP + the merge driver. |
bullseyeBacklog.refresh |
Refresh |
Re-reads the backlog from disk. Palette entry requires a backlog. |
bullseyeBacklog.scrub |
Scrub (mechanical fixes + judgment checklist) |
Mechanical scrub, then the AI judge. Palette entry requires a backlog. |
bullseyeBacklog.archive |
Archive DONE |
Moves DONE receipts to BACKLOG_ARCHIVE.md. Palette entry requires a backlog. |
bullseyeBacklog.showAiConfig |
Show AI Configuration |
Opens the exact assembled scrub prompt — your policy text plus the locked role framing and JSON contract — read-only. |
bullseyeBacklog.testModel |
Test AI Connection |
Sends a real ping to the configured provider and reports which model answered and how long it took. |
bullseyeBacklog.focusPosts |
Show Posts |
Opens the panel on the post queue. Palette entry requires a backlog; also reachable from the status-bar hover and the ❓ Posts button. |
Settings
All under bullseyeBacklog.* (package.json).
| Setting |
Type |
Default |
What it does |
folder |
string |
"" (workspace root) |
Directory holding the three backlog files. Relative resolves against the workspace root; absolute paths are allowed, including outside the workspace; ~ expands. The panel, watcher and MCP server all follow it, and changing it re-points them with no reload. A folder that does not exist falls back to the workspace root and says so. |
ai.provider |
enum |
"" |
vscode-lm · claude-cli · anthropic-api · openai-compatible. Unset is an error, not a silent probe. |
ai.model |
string |
"" |
Model family (vscode-lm) or complete model id (everything else). Blank uses the provider default. |
ai.anthropicApiKey |
string |
"" |
Key for anthropic-api. Plain text in settings.json, carried by Settings Sync — keep it in User settings. |
ai.openAiApiKey |
string |
"" |
Key for openai-compatible. Blank for local servers. Same plain-text caveat. |
ai.baseUrl |
string |
"" |
Endpoint for openai-compatible; host override for anthropic-api. Ignored by the other two. |
ai.claudeCommand |
string |
"" |
Path to the claude binary. Blank finds it on PATH. |
ai.codebaseAccess |
boolean |
true |
Give the scrub judge read-only Read/Grep/Glob over the workspace so it checks claims against source. Off means judging from story prose alone. |
ai.confidenceThreshold |
number |
0.7 |
Minimum confidence for a setPercent or unblock verdict to be written. |
autoScrubCooldownMs |
number |
0 (off) |
Milliseconds of quiet after the last write to BACKLOG.md before a scrub fires. Each write restarts the countdown, so a burst produces one scrub. Off by default because a scrub is a paid model call over the whole backlog and every agent mutation counts as a write. |
limits.headlineWords |
number |
20 |
Word cap on a story headline. |
limits.leftWords |
number |
20 |
Word cap on the LEFT: clause. A bare — is rejected regardless of this number. |
limits.themeWords |
number |
4 |
Word cap on the Theme / Epic cell; the emoji prefix does not count. |
ai.doneDefinition |
string |
"" |
What must be true before the judge may recommend ship. Injected verbatim into the scrub prompt. Blank uses the built-in default. |
ai.blockedDefinition |
string |
"" |
What counts as a genuine external blocker versus a false gate. This text is the entire blocked policy — there is no keyword list. Injected verbatim. |
ai.scrubGuidance |
string |
"" |
How to audit progress and phrase a replacement LEFT clause. The word caps above are appended automatically. Injected verbatim. |
The three prompt boxes are yours; the role framing and the JSON output contract the verdict parser depends on are locked and read-only. Show AI Configuration prints the assembled result.
The word caps exist so the grid stays scannable. Scrub is the only step that ever rewrites a headline or a LEFT clause, and these numbers go into its prompt; validate also reports any story already over a cap.
Requirements
VS Code 1.101 or later. An AI provider is required for scrub and corruption repair, and claude-cli needs neither an API key nor a Copilot subscription. Everything else — the grid, inline edits, ship, archive, the post queue, the MCP server and the merge driver — works with no AI configured at all.
Getting started
- Open a folder. If it has no
BACKLOG.md, run BullseyeBacklog: Create Backlog.
- The panel opens as an editor tab.
.mcp.json and the git merge driver are wired in the same breath.
- Pick a provider in ⚙️ Settings and press ⚡ Test connection.
- Press ✨ Scrub.
License: MIT.