diff --git a/src/resources/extensions/sf/commands-backlog.js b/src/resources/extensions/sf/commands-backlog.js
index 49b2766e7..4d30bbfd3 100644
--- a/src/resources/extensions/sf/commands-backlog.js
+++ b/src/resources/extensions/sf/commands-backlog.js
@@ -2,18 +2,33 @@
* SF Command — /sf backlog
*
* Structured backlog management with 999.x numbering.
- * Items stored in .sf/WORK-QUEUE.md as markdown checklist.
+ * Items live in `.sf/sf.db`; `.sf/WORK-QUEUE.md` is a legacy import fallback.
* Items can be promoted to active slices via add-slice.
*/
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
-import { dirname, join } from "node:path";
-import { sfRoot } from "./paths.js";
+import { existsSync, mkdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import {
+ addBacklogItem as addBacklogItemToDb,
+ isDbAvailable,
+ listBacklogItems,
+ openDatabase,
+ removeBacklogItem as removeBacklogItemFromDb,
+ updateBacklogItemStatus,
+} from "./sf-db.js";
-function backlogPath(basePath) {
- return join(sfRoot(basePath), "WORK-QUEUE.md");
+function ensureBacklogDb(basePath) {
+ if (isDbAvailable()) return true;
+ const sfDir = join(basePath, ".sf");
+ mkdirSync(sfDir, { recursive: true });
+ return openDatabase(join(sfDir, "sf.db"));
}
-function parseBacklog(basePath) {
- const filePath = backlogPath(basePath);
+
+function legacyBacklogPath(basePath) {
+ return join(basePath, ".sf", "WORK-QUEUE.md");
+}
+
+function parseLegacyBacklog(basePath) {
+ const filePath = legacyBacklogPath(basePath);
if (!existsSync(filePath)) return [];
const content = readFileSync(filePath, "utf-8");
const items = [];
@@ -25,38 +40,38 @@ function parseBacklog(basePath) {
items.push({
id: match[2],
title: match[3].trim(),
- done: match[1] === "x",
+ status: match[1] === "x" ? "promoted" : "pending",
note: match[4] ?? "",
});
}
}
return items;
}
-function writeBacklog(basePath, items) {
- const filePath = backlogPath(basePath);
- mkdirSync(dirname(filePath), { recursive: true });
- const lines = ["# Backlog\n"];
- for (const item of items) {
- const check = item.done ? "x" : " ";
- const note = item.note ? ` (${item.note})` : "";
- lines.push(`- [${check}] ${item.id} — ${item.title}${note}`);
+
+function importLegacyBacklogIfNeeded(basePath) {
+ if (listBacklogItems().length > 0) return 0;
+ let imported = 0;
+ for (const item of parseLegacyBacklog(basePath)) {
+ addBacklogItemToDb({
+ id: item.id,
+ title: item.title,
+ status: item.status,
+ note: item.note,
+ source: "legacy-work-queue",
+ });
+ imported += 1;
}
- lines.push(""); // trailing newline
- writeFileSync(filePath, lines.join("\n"), "utf-8");
+ return imported;
}
-function nextBacklogId(items) {
- let maxNum = 0;
- for (const item of items) {
- const match = item.id.match(/^999\.(\d+)$/);
- if (match) {
- const num = parseInt(match[1], 10);
- if (num > maxNum) maxNum = num;
- }
- }
- return `999.${maxNum + 1}`;
+
+function currentItems(basePath) {
+ if (!ensureBacklogDb(basePath)) return parseLegacyBacklog(basePath);
+ importLegacyBacklogIfNeeded(basePath);
+ return listBacklogItems();
}
+
async function listBacklog(basePath, ctx) {
- const items = parseBacklog(basePath);
+ const items = currentItems(basePath);
if (items.length === 0) {
ctx.ui.notify(
"Backlog is empty. Add items with /sf backlog add
",
@@ -66,31 +81,37 @@ async function listBacklog(basePath, ctx) {
}
const lines = ["Backlog:\n"];
for (const item of items) {
- const status = item.done ? "✓" : "○";
+ const done = item.status === "promoted" || item.status === "done";
+ const status = done ? "✓" : "○";
const note = item.note ? ` (${item.note})` : "";
lines.push(` ${status} ${item.id} — ${item.title}${note}`);
}
- const pending = items.filter((i) => !i.done).length;
+ const pending = items.filter((i) => i.status === "pending").length;
lines.push(`\n${pending} pending, ${items.length - pending} promoted/done`);
ctx.ui.notify(lines.join("\n"), "info");
}
+
async function addBacklogItem(basePath, title, ctx) {
if (!title) {
ctx.ui.notify("Usage: /sf backlog add ", "warning");
return;
}
- const items = parseBacklog(basePath);
- const id = nextBacklogId(items);
+ if (!ensureBacklogDb(basePath)) {
+ ctx.ui.notify("Backlog DB is unavailable; cannot add item.", "warning");
+ return;
+ }
+ importLegacyBacklogIfNeeded(basePath);
+ const cleanTitle = title.replace(/^['"]|['"]$/g, "");
const date = new Date().toISOString().slice(0, 10);
- items.push({
- id,
- title: title.replace(/^['"]|['"]$/g, ""),
- done: false,
+ const id = addBacklogItemToDb({
+ title: cleanTitle,
+ status: "pending",
note: `added ${date}`,
+ source: "manual",
});
- writeBacklog(basePath, items);
- ctx.ui.notify(`Added ${id}: "${title}"`, "success");
+ ctx.ui.notify(`Added ${id}: "${cleanTitle}"`, "success");
}
+
async function promoteBacklogItem(basePath, itemId, ctx, _pi) {
if (!itemId) {
ctx.ui.notify(
@@ -99,41 +120,50 @@ async function promoteBacklogItem(basePath, itemId, ctx, _pi) {
);
return;
}
- const items = parseBacklog(basePath);
- const item = items.find((i) => i.id === itemId);
+ if (!ensureBacklogDb(basePath)) {
+ ctx.ui.notify("Backlog DB is unavailable; cannot promote item.", "warning");
+ return;
+ }
+ importLegacyBacklogIfNeeded(basePath);
+ const item = listBacklogItems().find((i) => i.id === itemId);
if (!item) {
ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning");
return;
}
- if (item.done) {
+ if (item.status === "promoted" || item.status === "done") {
ctx.ui.notify(`${itemId} is already promoted/done.`, "info");
return;
}
- // Promote — currently requires single-writer engine (not yet available)
- // Mark as promoted in backlog for now; slice creation will be available with the engine.
- item.done = true;
- item.note = `promoted ${new Date().toISOString().slice(0, 10)}`;
- writeBacklog(basePath, items);
+ updateBacklogItemStatus(
+ itemId,
+ "promoted",
+ `promoted ${new Date().toISOString().slice(0, 10)}`,
+ );
ctx.ui.notify(
- `Promoted ${itemId}: "${item.title}" — add it to the roadmap manually or wait for engine slice commands.`,
+ `Promoted ${itemId}: "${item.title}" — add it to the roadmap manually or use the slice planning tools.`,
"info",
);
}
+
async function removeBacklogItem(basePath, itemId, ctx) {
if (!itemId) {
ctx.ui.notify("Usage: /sf backlog remove ", "warning");
return;
}
- const items = parseBacklog(basePath);
- const idx = items.findIndex((i) => i.id === itemId);
- if (idx === -1) {
+ if (!ensureBacklogDb(basePath)) {
+ ctx.ui.notify("Backlog DB is unavailable; cannot remove item.", "warning");
+ return;
+ }
+ importLegacyBacklogIfNeeded(basePath);
+ const item = listBacklogItems().find((i) => i.id === itemId);
+ if (!item) {
ctx.ui.notify(`Backlog item ${itemId} not found.`, "warning");
return;
}
- const removed = items.splice(idx, 1)[0];
- writeBacklog(basePath, items);
- ctx.ui.notify(`Removed ${removed.id}: "${removed.title}"`, "success");
+ removeBacklogItemFromDb(itemId);
+ ctx.ui.notify(`Removed ${item.id}: "${item.title}"`, "success");
}
+
export async function handleBacklog(args, ctx, pi) {
const basePath = process.cwd();
const parts = args.trim().split(/\s+/);
@@ -149,7 +179,6 @@ export async function handleBacklog(args, ctx, pi) {
case "remove":
return removeBacklogItem(basePath, rest.trim(), ctx);
default:
- // Treat as implicit add
return addBacklogItem(basePath, args, ctx);
}
}
diff --git a/src/resources/extensions/sf/commands-todo.js b/src/resources/extensions/sf/commands-todo.js
index 004376f72..524d6cf5b 100644
--- a/src/resources/extensions/sf/commands-todo.js
+++ b/src/resources/extensions/sf/commands-todo.js
@@ -19,6 +19,7 @@ import {
import { dirname, join } from "node:path";
import { projectRoot } from "./commands/context.js";
import { sfRoot } from "./paths.js";
+import { addBacklogItem, isDbAvailable, openDatabase } from "./sf-db.js";
const _EMPTY_TODO = "# TODO\n\nDump anything here.\n";
const MAX_DUMP_CHARS = 48_000;
@@ -276,17 +277,6 @@ function renderSkillProposals(result) {
.join("\n") + "\n"
);
}
-function backlogPath(basePath) {
- return join(sfRoot(basePath), "WORK-QUEUE.md");
-}
-function nextBacklogId(content) {
- let maxNum = 0;
- for (const match of content.matchAll(/^- \[[ x]\] 999\.(\d+) — /gm)) {
- const num = Number.parseInt(match[1], 10);
- if (Number.isFinite(num) && num > maxNum) maxNum = num;
- }
- return `999.${maxNum + 1}`;
-}
function renderBacklogJsonl(items, triagedAt) {
return (
items
@@ -308,22 +298,26 @@ function renderBacklogJsonl(items, triagedAt) {
function appendBacklogItems(basePath, titles, triageRunId) {
const cleanTitles = titles.map((title) => title.trim()).filter(Boolean);
if (cleanTitles.length === 0) return 0;
- const filePath = backlogPath(basePath);
- mkdirSync(dirname(filePath), { recursive: true });
- let content = existsSync(filePath)
- ? readFileSync(filePath, "utf-8")
- : "# Backlog\n\n";
- if (!content.endsWith("\n")) content += "\n";
const date = new Date().toISOString().slice(0, 10);
const triagedAt = new Date().toISOString();
const backlogItems = [];
- for (const title of cleanTitles) {
- const id = nextBacklogId(content);
- content += `- [ ] ${id} — ${title.replace(/^['"]|['"]$/g, "")} (triaged ${date})\n`;
- backlogItems.push({ id, title: title.replace(/^['"]|['"]$/g, "") });
+ if (!isDbAvailable()) {
+ const root = sfRoot(basePath);
+ mkdirSync(root, { recursive: true });
+ openDatabase(join(root, "sf.db"));
}
- writeFileSync(filePath, content, "utf-8");
- // Also write JSONL backlog entries
+ for (const title of cleanTitles) {
+ const cleanTitle = title.replace(/^['"]|['"]$/g, "");
+ const id = addBacklogItem({
+ title: cleanTitle,
+ status: "pending",
+ note: `triaged ${date}`,
+ source: "todo-triage",
+ triageRunId,
+ });
+ backlogItems.push({ id, title: cleanTitle });
+ }
+ // Also write versioned JSONL triage evidence; the executable backlog lives in DB.
const backlogDir = join(basePath, ".sf", "triage", "backlog");
mkdirSync(backlogDir, { recursive: true });
const jsonlPath = join(backlogDir, `${triageRunId}.jsonl`);
diff --git a/src/resources/extensions/sf/sf-db.js b/src/resources/extensions/sf/sf-db.js
index bcf394cbc..992d35e8b 100644
--- a/src/resources/extensions/sf/sf-db.js
+++ b/src/resources/extensions/sf/sf-db.js
@@ -78,7 +78,7 @@ function openRawDb(path) {
loadProvider();
return new DatabaseSync(path);
}
-const SCHEMA_VERSION = 34;
+const SCHEMA_VERSION = 35;
function indexExists(db, name) {
return !!db
.prepare(
@@ -140,6 +140,25 @@ function ensureRepoProfileTables(db) {
"CREATE INDEX IF NOT EXISTS idx_repo_file_observations_status ON repo_file_observations(git_status, ownership)",
);
}
+function ensureBacklogTables(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS backlog_items (
+ id TEXT PRIMARY KEY,
+ title TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ note TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT '',
+ triage_run_id TEXT DEFAULT NULL,
+ sequence INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ promoted_at TEXT DEFAULT NULL
+ )
+ `);
+ db.exec(
+ "CREATE INDEX IF NOT EXISTS idx_backlog_items_status_sequence ON backlog_items(status, sequence, id)",
+ );
+}
function ensureSolverEvalTables(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS solver_eval_runs (
@@ -863,6 +882,7 @@ function initSchema(db, fileBacked) {
"CREATE INDEX IF NOT EXISTS idx_self_feedback_kind ON self_feedback(kind, ts)",
);
ensureRepoProfileTables(db);
+ ensureBacklogTables(db);
ensureSolverEvalTables(db);
ensureHeadlessRunTables(db);
ensureUokMessageTables(db);
@@ -1925,6 +1945,15 @@ function migrateSchema(db) {
":applied_at": new Date().toISOString(),
});
}
+ if (currentVersion < 35) {
+ ensureBacklogTables(db);
+ db.prepare(
+ "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)",
+ ).run({
+ ":version": 35,
+ ":applied_at": new Date().toISOString(),
+ });
+ }
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
@@ -3301,6 +3330,108 @@ export function getMilestone(id) {
if (!row) return null;
return rowToMilestone(row);
}
+function rowToBacklogItem(row) {
+ return {
+ id: row["id"],
+ title: row["title"],
+ status: row["status"],
+ note: row["note"] ?? "",
+ source: row["source"] ?? "",
+ triageRunId: row["triage_run_id"] ?? null,
+ sequence: row["sequence"] ?? 0,
+ createdAt: row["created_at"],
+ updatedAt: row["updated_at"],
+ promotedAt: row["promoted_at"] ?? null,
+ };
+}
+export function listBacklogItems() {
+ if (!currentDb) return [];
+ return currentDb
+ .prepare(
+ "SELECT * FROM backlog_items ORDER BY CASE WHEN sequence > 0 THEN 0 ELSE 1 END, sequence, id",
+ )
+ .all()
+ .map(rowToBacklogItem);
+}
+export function nextBacklogItemId() {
+ if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
+ const row = currentDb
+ .prepare(
+ "SELECT id FROM backlog_items WHERE id LIKE '999.%' ORDER BY CAST(substr(id, 5) AS INTEGER) DESC LIMIT 1",
+ )
+ .get();
+ const next = row?.id ? Number.parseInt(String(row.id).slice(4), 10) + 1 : 1;
+ return `999.${Number.isFinite(next) ? next : 1}`;
+}
+export function addBacklogItem({
+ id,
+ title,
+ note = "",
+ source = "manual",
+ triageRunId = null,
+ status = "pending",
+}) {
+ if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
+ const itemId = id ?? nextBacklogItemId();
+ const now = new Date().toISOString();
+ const sequenceRow = currentDb
+ .prepare(
+ "SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM backlog_items",
+ )
+ .get();
+ currentDb
+ .prepare(`INSERT INTO backlog_items (
+ id, title, status, note, source, triage_run_id, sequence, created_at, updated_at, promoted_at
+ ) VALUES (
+ :id, :title, :status, :note, :source, :triage_run_id, :sequence, :created_at, :updated_at, :promoted_at
+ )
+ ON CONFLICT(id) DO UPDATE SET
+ title = excluded.title,
+ status = excluded.status,
+ note = excluded.note,
+ source = excluded.source,
+ triage_run_id = excluded.triage_run_id,
+ updated_at = excluded.updated_at,
+ promoted_at = excluded.promoted_at`)
+ .run({
+ ":id": itemId,
+ ":title": title,
+ ":status": status,
+ ":note": note,
+ ":source": source,
+ ":triage_run_id": triageRunId,
+ ":sequence": sequenceRow?.sequence ?? 1,
+ ":created_at": now,
+ ":updated_at": now,
+ ":promoted_at": status === "promoted" ? now : null,
+ });
+ return itemId;
+}
+export function updateBacklogItemStatus(id, status, note = "") {
+ if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
+ const now = new Date().toISOString();
+ const result = currentDb
+ .prepare(`UPDATE backlog_items
+ SET status = :status,
+ note = :note,
+ updated_at = :updated_at,
+ promoted_at = CASE WHEN :status = 'promoted' THEN :updated_at ELSE promoted_at END
+ WHERE id = :id`)
+ .run({
+ ":id": id,
+ ":status": status,
+ ":note": note,
+ ":updated_at": now,
+ });
+ return (result?.changes ?? 0) > 0;
+}
+export function removeBacklogItem(id) {
+ if (!currentDb) throw new SFError(SF_STALE_STATE, "sf-db: No database open");
+ const result = currentDb
+ .prepare("DELETE FROM backlog_items WHERE id = :id")
+ .run({ ":id": id });
+ return (result?.changes ?? 0) > 0;
+}
/**
* Update a milestone's status in the database.
* Used by park/unpark to keep the DB in sync with the filesystem marker.
diff --git a/src/resources/extensions/sf/tests/backlog-db.test.mjs b/src/resources/extensions/sf/tests/backlog-db.test.mjs
new file mode 100644
index 000000000..ca00755e7
--- /dev/null
+++ b/src/resources/extensions/sf/tests/backlog-db.test.mjs
@@ -0,0 +1,87 @@
+import assert from "node:assert/strict";
+import {
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, test } from "vitest";
+import { handleBacklog } from "../commands-backlog.js";
+import { closeDatabase, listBacklogItems, openDatabase } from "../sf-db.js";
+
+const tmpRoots = [];
+
+afterEach(() => {
+ closeDatabase();
+ for (const root of tmpRoots.splice(0)) {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+function makeProject() {
+ const root = mkdtempSync(join(tmpdir(), "sf-backlog-db-"));
+ tmpRoots.push(root);
+ return root;
+}
+
+function makeCtx(messages) {
+ return {
+ ui: {
+ notify(message, level) {
+ messages.push({ message, level });
+ },
+ },
+ };
+}
+
+test("backlog_add_when_db_available_persists_without_work_queue_markdown", async () => {
+ const project = makeProject();
+ mkdirSync(join(project, ".sf"), { recursive: true });
+ openDatabase(join(project, ".sf", "sf.db"));
+ const previousCwd = process.cwd();
+ const messages = [];
+ try {
+ process.chdir(project);
+ await handleBacklog("add Fix dispatch priority", makeCtx(messages), null);
+ } finally {
+ process.chdir(previousCwd);
+ }
+
+ const items = listBacklogItems();
+ assert.equal(items.length, 1);
+ assert.equal(items[0].id, "999.1");
+ assert.equal(items[0].title, "Fix dispatch priority");
+ assert.equal(items[0].status, "pending");
+ assert.equal(existsSync(join(project, ".sf", "WORK-QUEUE.md")), false);
+ assert.equal(messages.at(-1).level, "success");
+});
+
+test("backlog_list_when_legacy_work_queue_exists_imports_once_to_db", async () => {
+ const project = makeProject();
+ const sfDir = join(project, ".sf");
+ mkdirSync(sfDir, { recursive: true });
+ writeFileSync(
+ join(sfDir, "WORK-QUEUE.md"),
+ "# Backlog\n\n- [ ] 999.7 — Legacy item (added 2026-05-07)\n",
+ "utf-8",
+ );
+ openDatabase(join(project, ".sf", "sf.db"));
+ const previousCwd = process.cwd();
+ const messages = [];
+ try {
+ process.chdir(project);
+ await handleBacklog("", makeCtx(messages), null);
+ await handleBacklog("", makeCtx(messages), null);
+ } finally {
+ process.chdir(previousCwd);
+ }
+
+ const items = listBacklogItems();
+ assert.equal(items.length, 1);
+ assert.equal(items[0].id, "999.7");
+ assert.equal(items[0].source, "legacy-work-queue");
+ assert.match(messages.at(-1).message, /999\.7/);
+});
diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs
index f98d71b4e..562b8b847 100644
--- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs
+++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs
@@ -142,7 +142,7 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill",
const version = db
.prepare("SELECT MAX(version) AS version FROM schema_version")
.get();
- assert.equal(version.version, 34);
+ assert.equal(version.version, 35);
const taskSpec = db
.prepare(
"SELECT milestone_id, slice_id, task_id, verify FROM task_specs WHERE task_id = 'T01'",