From f854e32c6bed8ab74f0ec56afdb9b39a9cd534ca Mon Sep 17 00:00:00 2001 From: Ary Rabelo Date: Fri, 3 Jul 2026 12:09:21 -0300 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20add=20/clear-note=20and=20/rebuild-?= =?UTF-8?q?note=20commands,=20retitle=20widget=20to=20PlanQueue=20=C2=B7?= =?UTF-8?q?=20Notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 +++++++ README.md | 2 ++ src/main.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b26dd..eab95a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `/clear-note` command: empties the current note (appending the previous content to `.history.md` first) behind a confirmation prompt; a no-op on an already-empty note. +- `/rebuild-note` command: behind a confirmation prompt, clears the note and asks the agent to rebuild the plan from the whole session via `make_note`, keeping only the remaining work (skipping anything already done or marked `- [x]`); the old note is passed verbatim and saved to history. On an empty note it bootstraps a fresh plan (no confirmation). + +### Changed + +- Widget title changed from `PlanQueue` to `PlanQueue · Notes`. + ## [0.1.0] - 2026-07-02 PlanQueue first public release: an OMP (Oh My Pi) extension that renders a diff --git a/README.md b/README.md index 0e31d27..3878755 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Type `/planqueue` to open a keyboard picker listing notes from your **other** se - The `note_add` tool lets the agent append tasks to your note itself: say "coloca na nota ..." (or "add to the list", "remember to ...") and it appends a `- [ ]` line. Auto-available in every session once the extension is installed — no separate skill install. - `/make-note ` turns a high-level goal into a ready-to-drain queue in one shot: the agent decomposes it into sequential prompts and writes them via the `make_note` tool — one `- [ ]` task per step, indented detail lines sent with their prompt, and a `---` barrier wherever it decides you should review. Then drive it with `Ctrl+↓` / auto-run. (`/note ` is the manual one-liner: it appends a single `- [ ]` task without the agent.) +- `/clear-note` empties the current note in one step. The previous content is appended to the `.history.md` sibling first, so nothing is lost — but it can't be auto-restored, so you confirm before it clears. A no-op on an already-empty note. +- `/rebuild-note` clears the note (after the same kind of confirmation) and asks the agent to rebuild it from the whole session: it analyzes the conversation so far and calls `make_note` afresh, keeping only the work still remaining (skipping anything already done or marked `- [x]`). The old note is passed to the agent verbatim (and saved to history). On an empty note it just asks the agent to bootstrap a fresh plan. ### Copy the note diff --git a/src/main.ts b/src/main.ts index 7a2fbb7..21c6443 100644 --- a/src/main.ts +++ b/src/main.ts @@ -196,7 +196,7 @@ async function safeFlush( /** Build the themed widget styler mirroring OMP's HUD widgets (bold title, `└` tree hook, 2-space indent). */ function widgetStyle(theme: Theme): WidgetStyle { return { - title: theme.bold(theme.fg("accent", "PlanQueue")), + title: theme.bold(theme.fg("accent", "PlanQueue \u00b7 Notes")), hook: theme.tree.hook, indent: " ", hint: (t: string): string => theme.fg("dim", t), @@ -310,6 +310,33 @@ function makeNotePrompt(goal: string): string { ); } +/** + * Meta-prompt asking the agent to populate an empty note from the session so far. + * Shared by the empty-note bootstrap (fires once per session) and `/rebuild-note` + * when the note is already empty. + */ +const BOOTSTRAP_NOTE_PROMPT = + "The session note is empty. Based on the conversation so far, please call the make_note tool to create a note with:\n" + + "1. A short `heading` summarizing what we are working on (becomes `# Heading` at the top of the note).\n" + + "2. Two or more follow-up sub-tasks as `steps` that I can track and dispatch from the queue.\n" + + "Keep the heading concise and the tasks actionable."; + +/** + * Meta-prompt for `/rebuild-note`: the note was just cleared for a rebuild, so ask + * the agent to recreate the plan from the WHOLE conversation, keeping only the + * remaining actionable work. The old note is included verbatim at the end. + */ +function rebuildNotePrompt(oldNote: string): string { + return ( + "I just cleared my PlanQueue note to rebuild it from scratch (the previous content is included below and is saved to history). " + + "Analyze the WHOLE conversation so far and recreate the plan by calling the make_note tool: pass a concise `heading` and `steps` " + + "that cover ONLY the REMAINING actionable work — skip anything already completed in this session or marked `- [x]` in the old note. " + + "Put supporting detail in `details` (sent together with the prompt as one message) and set `barrierAfter: true` only where I must review before the queue continues. " + + "Keep prompts concrete and self-contained.\n\nOld note:\n" + + oldNote + ); +} + /** Register the `make_note` tool so the agent can write a whole decomposed prompt-queue plan to the note. */ function registerMakeNoteTool( pi: ExtensionAPI, @@ -405,9 +432,10 @@ interface NoteCommandDeps { legacySessionsDirs: () => readonly string[] | undefined; persist: (ctx: ExtensionContext, next: string) => Promise; openEditor: (ctx: ExtensionContext) => Promise; + resetQueue: () => void; } -/** Register the `note`, `notes`, and `make-note` slash commands. */ +/** Register the `note`, `planqueue`, `make-note`, `clear-note`, and `rebuild-note` slash commands. */ function registerNoteCommands(pi: ExtensionAPI, deps: NoteCommandDeps): void { pi.registerCommand("note", { description: @@ -442,6 +470,51 @@ function registerNoteCommands(pi: ExtensionAPI, deps: NoteCommandDeps): void { return Promise.resolve(); }, }); + pi.registerCommand("clear-note", { + description: + "Clear the current PlanQueue note (previous version stays in history)", + handler: async ( + _args: string, + ctx: ExtensionCommandContext, + ): Promise => { + if (!ctx.hasUI) return; + if (deps.content().trim() === "") { + ctx.ui.notify("Note is already empty", "info"); + return; + } + const confirmed = await ctx.ui.confirm( + "Clear note?", + "The current note is saved to history and can't be auto-restored.", + ); + if (!confirmed) return; + await deps.persist(ctx, ""); + deps.resetQueue(); + ctx.ui.notify("Note cleared (previous version in history)", "info"); + }, + }); + pi.registerCommand("rebuild-note", { + description: + "Clear the note and ask the agent to rebuild the plan from the whole session", + handler: async ( + _args: string, + ctx: ExtensionCommandContext, + ): Promise => { + if (!ctx.hasUI) return; + const old = deps.content(); + if (old.trim() === "") { + pi.sendUserMessage(BOOTSTRAP_NOTE_PROMPT); + return; + } + const confirmed = await ctx.ui.confirm( + "Rebuild note?", + "The current note is cleared (saved to history) and the agent recreates the plan from the whole session.", + ); + if (!confirmed) return; + await deps.persist(ctx, ""); + deps.resetQueue(); + pi.sendUserMessage(rebuildNotePrompt(old)); + }, + }); } export default async function planQueueExtension( @@ -576,13 +649,7 @@ export default async function planQueueExtension( pi.on("session_stop", (_event, _ctx) => { if (bootstrapped || content.trim() !== "") return; bootstrapped = true; - pi.sendUserMessage( - "The session note is empty. Based on the conversation so far, please call the make_note tool to create a note with:\n" + - "1. A short `heading` summarizing what we are working on (becomes `# Heading` at the top of the note).\n" + - "2. Two or more follow-up sub-tasks as `steps` that I can track and dispatch from the queue.\n" + - "Keep the heading concise and the tasks actionable.", - { deliverAs: "followUp" }, - ); + pi.sendUserMessage(BOOTSTRAP_NOTE_PROMPT, { deliverAs: "followUp" }); }); pi.registerShortcut(shortcuts.editNotes as KeyId, { @@ -602,6 +669,7 @@ export default async function planQueueExtension( legacySessionsDirs: () => legacySessionsDirs, persist, openEditor, + resetQueue: () => queue.reset(), }); registerNoteAddTool(pi, { notePath: () => notePath, From 164da9ae1679afc5716f0447a0662ce5b73eddeb Mon Sep 17 00:00:00 2001 From: Ary Rabelo Date: Sat, 4 Jul 2026 16:00:19 -0300 Subject: [PATCH 2/3] feat: planqueue backlog CLI codifying the ordering interface Replaces the pending-items skill's hand-pasted SQL with a real CLI over ~/.planqueue/backlog.db. The store OWNS the two invariants the prose SQL left to caller discipline: - PRAGMA foreign_keys = ON on every (fresh) connection. - an atomic cycle-guard inside the same transaction as every edge insert, so a dependency can never close a cycle (which the non-recursive ready query would otherwise turn into a silent deadlock). src/backlog.ts = BacklogStore (bun:sqlite; ready/blocked/addDep/setStatus/addEpic). src/cli.ts = argv dispatch, JSON stdout, imports (not copies) resolveLocation from @aryrabelo/planqueue-core to derive the current repo for ready ordering. New bin 'planqueue'. 7 unit tests (temp db) cover ready ordering, blocked, and the cycle guard (self/missing/direct/transitive). bun test + typecheck + biome clean. Follow-up (blocked by protected-path hook): DOX pass on src/AGENTS.md to document the new CLI surface. --- package.json | 5 +- src/backlog.ts | 193 ++++++++++++++++++++++++++++++++++++++++++ src/cli.ts | 144 +++++++++++++++++++++++++++++++ tests/backlog.test.ts | 70 +++++++++++++++ 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 src/backlog.ts create mode 100644 src/cli.ts create mode 100644 tests/backlog.test.ts diff --git a/package.json b/package.json index e8c3220..7aba05c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aryrabelo/planqueue", - "version": "0.1.0", + "version": "0.1.1", "description": "PlanQueue: an OMP harness extension that turns a session-notes panel into a FIFO prompt queue for coding agents, persisted per repo/branch/session.", "type": "module", "license": "MIT", @@ -42,6 +42,9 @@ "engines": { "bun": ">=1.0.0" }, + "bin": { + "planqueue": "./src/cli.ts" + }, "scripts": { "test": "bun test", "typecheck": "tsc --noEmit", diff --git a/src/backlog.ts b/src/backlog.ts new file mode 100644 index 0000000..452e53d --- /dev/null +++ b/src/backlog.ts @@ -0,0 +1,193 @@ +/** + * Plan-level backlog store: epics + cross-repo dependencies in one local SQLite + * DB (`~/.planqueue/backlog.db`), the codified interface behind the + * `pending-items` skill's hand-pasted SQL. + * + * This module OWNS the two invariants the prose SQL left to caller discipline: + * (1) `PRAGMA foreign_keys = ON` on every connection (SQLite defaults it OFF, + * and each connection is fresh), and (2) an atomic cycle-guard that runs inside + * the same transaction as every edge insert, so a dependency can never close a + * cycle (which the non-recursive `ready` query would otherwise turn into a + * silent, undetectable deadlock). + * + * App-layer (I/O) by design — it lives here, not in the pure `planqueue-core`. + */ +import { Database } from "bun:sqlite"; + +export type EpicStatus = "pending" | "in_progress" | "done"; +export type EpicPriority = "high" | "medium" | "low"; + +/** A pickable epic: pending/in_progress with every dependency already done. */ +export interface ReadyEpic { + id: string; + title: string; + priority: EpicPriority; + repo: string | null; +} + +/** A blocked epic and the unfinished epics it is waiting on. */ +export interface BlockedEpic { + id: string; + title: string; + waitingOn: string[]; +} + +/** Fields accepted when adding an epic; unset columns take schema defaults. */ +export interface NewEpic { + id: string; + title: string; + status?: EpicStatus; + priority?: EpicPriority; + body?: string; + repo?: string | null; + branch?: string | null; +} + +/** Why an `addDep` was refused, when `ok` is false. */ +export type AddDepReason = "self" | "missing" | "cycle"; + +export interface AddDepResult { + ok: boolean; + reason?: AddDepReason; +} + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS epics ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + priority TEXT NOT NULL DEFAULT 'medium', + body TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT, + session_id TEXT, + note_path TEXT, + repo TEXT, + branch TEXT, + worktree TEXT, + issue_external_id TEXT, + issue_external_portal TEXT, + issue_external_last_status TEXT, + pull_request TEXT +); +CREATE INDEX IF NOT EXISTS idx_epics_repo ON epics(repo); +CREATE INDEX IF NOT EXISTS idx_epics_session ON epics(session_id); +CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status); +CREATE TABLE IF NOT EXISTS epic_deps ( + epic_id TEXT NOT NULL REFERENCES epics(id) ON DELETE CASCADE, + depends_on_id TEXT NOT NULL REFERENCES epics(id) ON DELETE CASCADE, + PRIMARY KEY (epic_id, depends_on_id) +); +`; + +export class BacklogStore { + private readonly db: Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + // Own the FK invariant: SQLite defaults foreign_keys OFF, per connection. + this.db.exec("PRAGMA foreign_keys = ON;"); + this.db.exec(SCHEMA); + } + + close(): void { + this.db.close(); + } + + addEpic(epic: NewEpic): boolean { + const res = this.db + .query( + `INSERT OR IGNORE INTO epics (id, title, status, priority, body, created_at, repo, branch) + VALUES ($id, $title, $status, $priority, $body, datetime('now'), $repo, $branch)`, + ) + .run({ + $id: epic.id, + $title: epic.title, + $status: epic.status ?? "pending", + $priority: epic.priority ?? "medium", + $body: epic.body ?? "", + $repo: epic.repo ?? null, + $branch: epic.branch ?? null, + }); + return res.changes > 0; + } + + /** Pickable epics. When `currentRepo` is set, its epics sort first (else + * global order: priority, then age) — matching the `pending-items` skill. */ + ready(currentRepo?: string | null): ReadyEpic[] { + return this.db + .query( + `SELECT id, title, priority, repo FROM epics t + WHERE t.status IN ('pending','in_progress') + AND NOT EXISTS ( + SELECT 1 FROM epic_deps d JOIN epics p ON p.id = d.depends_on_id + WHERE d.epic_id = t.id AND p.status <> 'done') + ORDER BY + CASE WHEN $repo IS NOT NULL AND repo = $repo THEN 0 ELSE 1 END, + CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END, + created_at`, + ) + .all({ $repo: currentRepo ?? null }) as ReadyEpic[]; + } + + /** Blocked epics with the unfinished epics each is waiting on. */ + blocked(): BlockedEpic[] { + const rows = this.db + .query( + `SELECT t.id AS id, t.title AS title, group_concat(p.id) AS waiting + FROM epics t + JOIN epic_deps d ON d.epic_id = t.id + JOIN epics p ON p.id = d.depends_on_id AND p.status <> 'done' + WHERE t.status IN ('pending','in_progress') + GROUP BY t.id ORDER BY t.id`, + ) + .all() as Array<{ id: string; title: string; waiting: string | null }>; + return rows.map((r) => ({ + id: r.id, + title: r.title, + waitingOn: r.waiting ? r.waiting.split(",") : [], + })); + } + + /** Add `epicId depends on dependsOnId`, refusing self-loops, missing epics, + * and any edge that would close a cycle. Check + insert are one transaction. */ + addDep(epicId: string, dependsOnId: string): AddDepResult { + if (epicId === dependsOnId) return { ok: false, reason: "self" }; + const run = this.db.transaction((): AddDepResult => { + const missing = + this.db.query("SELECT 1 FROM epics WHERE id = ?").get(epicId) == null || + this.db.query("SELECT 1 FROM epics WHERE id = ?").get(dependsOnId) == + null; + if (missing) return { ok: false, reason: "missing" }; + // Adding (epicId -> dependsOnId) closes a cycle iff epicId is already + // reachable from dependsOnId along depends_on edges. + const row = this.db + .query( + `WITH RECURSIVE reach(id) AS ( + SELECT depends_on_id FROM epic_deps WHERE epic_id = $from + UNION + SELECT d.depends_on_id FROM epic_deps d JOIN reach r ON d.epic_id = r.id) + SELECT count(*) AS n FROM reach WHERE id = $target`, + ) + .get({ $from: dependsOnId, $target: epicId }) as { n: number }; + if (row.n > 0) return { ok: false, reason: "cycle" }; + this.db + .query( + "INSERT OR IGNORE INTO epic_deps (epic_id, depends_on_id) VALUES (?, ?)", + ) + .run(epicId, dependsOnId); + return { ok: true }; + }); + return run(); + } + + /** Set an epic's status. Returns whether a row was updated. */ + setStatus(id: string, status: EpicStatus): boolean { + const res = this.db + .query( + "UPDATE epics SET status = ?, updated_at = datetime('now') WHERE id = ?", + ) + .run(status, id); + return res.changes > 0; + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..7d9c5f2 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,144 @@ +#!/usr/bin/env bun +/** + * `planqueue backlog` CLI — the codified interface over `~/.planqueue/backlog.db` + * that replaces the `pending-items` skill's hand-pasted SQL. stdout is JSON only + * (agent-friendly); usage/errors go to stderr. Exit code doubles as a gate: + * add-dep and set-status exit non-zero when they change nothing / are refused. + * + * Subcommands: ready | blocked | add-dep | set-status | + * add [--priority p] [--status s] [--repo r]. + * The `backlog` prefix is optional (`planqueue ready` == `planqueue backlog ready`). + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { resolveLocation } from "@aryrabelo/planqueue-core"; +import { BacklogStore, type EpicPriority, type EpicStatus } from "./backlog"; + +const DEFAULT_DB = join(homedir(), ".planqueue", "backlog.db"); +const STATUSES: readonly EpicStatus[] = ["pending", "in_progress", "done"]; +const PRIORITIES: readonly EpicPriority[] = ["high", "medium", "low"]; + +function optValue(args: string[], key: string): string | undefined { + const i = args.indexOf(key); + return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined; +} + +/** Derive the current repo slug by importing (not copying) resolveLocation. */ +function deriveRepo(): string | null { + try { + const top = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"]) + .stdout.toString() + .trim(); + const branch = Bun.spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"]) + .stdout.toString() + .trim(); + return resolveLocation({ + cwd: process.cwd(), + repoToplevel: top.length > 0 ? top : null, + branch: branch.length > 0 ? branch : null, + sessionId: "cli", + }).repo; + } catch { + return null; + } +} + +function usage(): never { + process.stderr.write( + "usage: planqueue backlog <ready|blocked|add-dep <A> <B>|" + + "set-status <id> <pending|in_progress|done>|add <id> <title> [--priority p] [--status s] [--repo r]> " + + "[--db <path>] [--repo <r>]\n", + ); + process.exit(2); +} + +function write(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function dispatch(store: BacklogStore, args: string[]): number { + switch (args[0]) { + case "ready": { + const repo = optValue(args, "--repo") ?? deriveRepo(); + write(store.ready(repo)); + return 0; + } + case "blocked": { + write(store.blocked()); + return 0; + } + case "add-dep": { + const a = args[1]; + const b = args[2]; + if ( + a === undefined || + b === undefined || + a.startsWith("--") || + b.startsWith("--") + ) + usage(); + const res = store.addDep(a, b); + write(res); + return res.ok ? 0 : 1; + } + case "set-status": { + const id = args[1]; + const status = args[2]; + if ( + id === undefined || + status === undefined || + !STATUSES.includes(status as EpicStatus) + ) { + usage(); + } + const changed = store.setStatus(id, status as EpicStatus); + write({ changed }); + return changed ? 0 : 1; + } + case "add": { + const id = args[1]; + const title = args[2]; + if ( + id === undefined || + title === undefined || + id.startsWith("--") || + title.startsWith("--") + ) { + usage(); + } + const priority = optValue(args, "--priority"); + const status = optValue(args, "--status"); + if ( + priority !== undefined && + !PRIORITIES.includes(priority as EpicPriority) + ) + usage(); + if (status !== undefined && !STATUSES.includes(status as EpicStatus)) + usage(); + const inserted = store.addEpic({ + id, + title, + priority: priority as EpicPriority | undefined, + status: status as EpicStatus | undefined, + repo: optValue(args, "--repo") ?? null, + }); + write({ inserted }); + return inserted ? 0 : 1; + } + default: + return usage(); + } +} + +function main(argv: string[]): number { + const args = argv[0] === "backlog" ? argv.slice(1) : argv; + const dbPath = optValue(args, "--db") ?? DEFAULT_DB; + const store = new BacklogStore(dbPath); + try { + return dispatch(store, args); + } finally { + store.close(); + } +} + +process.exit(main(process.argv.slice(2))); diff --git a/tests/backlog.test.ts b/tests/backlog.test.ts new file mode 100644 index 0000000..5f2b092 --- /dev/null +++ b/tests/backlog.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BacklogStore } from "../src/backlog"; + +let dir: string; +let store: BacklogStore; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "backlog-")); + store = new BacklogStore(join(dir, "backlog.db")); +}); + +afterEach(async () => { + store.close(); + await rm(dir, { recursive: true, force: true }); +}); + +test("ready returns leaf epics; a done dependency unblocks its dependent", () => { + store.addEpic({ id: "a", title: "A", priority: "high" }); + store.addEpic({ id: "b", title: "B", priority: "high" }); + expect(store.addDep("b", "a").ok).toBe(true); // b depends on a + expect(store.ready().map((e) => e.id)).toEqual(["a"]); // b blocked by a + store.setStatus("a", "done"); + expect(store.ready().map((e) => e.id)).toEqual(["b"]); +}); + +test("ready orders by priority, and current-repo bias floats its epics first", () => { + store.addEpic({ id: "hi", title: "hi", priority: "high", repo: "x" }); + store.addEpic({ id: "lo", title: "lo", priority: "low", repo: "y" }); + expect(store.ready().map((e) => e.id)).toEqual(["hi", "lo"]); // priority + expect(store.ready("y").map((e) => e.id)).toEqual(["lo", "hi"]); // repo bias +}); + +test("blocked lists the unfinished epics each waits on", () => { + store.addEpic({ id: "a", title: "A" }); + store.addEpic({ id: "b", title: "B" }); + store.addDep("b", "a"); + expect(store.blocked()).toEqual([{ id: "b", title: "B", waitingOn: ["a"] }]); + store.setStatus("a", "done"); + expect(store.blocked()).toEqual([]); // a done -> b no longer blocked +}); + +test("addDep refuses self-loops, missing epics, and direct cycles", () => { + store.addEpic({ id: "a", title: "A" }); + store.addEpic({ id: "b", title: "B" }); + expect(store.addDep("a", "a")).toEqual({ ok: false, reason: "self" }); + expect(store.addDep("a", "ghost")).toEqual({ ok: false, reason: "missing" }); + expect(store.addDep("b", "a").ok).toBe(true); // b -> a + expect(store.addDep("a", "b")).toEqual({ ok: false, reason: "cycle" }); // closes a<->b +}); + +test("addDep detects transitive cycles", () => { + for (const id of ["a", "b", "c"]) store.addEpic({ id, title: id }); + expect(store.addDep("b", "a").ok).toBe(true); // b -> a + expect(store.addDep("c", "b").ok).toBe(true); // c -> b + expect(store.addDep("a", "c")).toEqual({ ok: false, reason: "cycle" }); // a->c->b->a +}); + +test("setStatus reports whether a row changed", () => { + store.addEpic({ id: "a", title: "A" }); + expect(store.setStatus("a", "done")).toBe(true); + expect(store.setStatus("ghost", "done")).toBe(false); +}); + +test("addEpic is idempotent on id (INSERT OR IGNORE)", () => { + expect(store.addEpic({ id: "a", title: "A" })).toBe(true); + expect(store.addEpic({ id: "a", title: "A again" })).toBe(false); +}); From 2edb283e6e8f2eb3f05c851dc1b02126566c4ccf Mon Sep 17 00:00:00 2001 From: Ary Rabelo <aryrabelo@gmail.com> Date: Sat, 4 Jul 2026 17:57:55 -0300 Subject: [PATCH 3/3] chore(release): finalize 0.1.1 changelog and sync display label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document the 0.1.1 changes: /clear-note, /rebuild-note, the widget/label rename, and the new planqueue backlog CLI (bin entry). - Sync pi.setLabel to 'PlanQueue · Notes' to match the widget branding. --- CHANGELOG.md | 5 +++++ src/main.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eab95a8..9832932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.1] - 2026-07-03 + ### Added - `/clear-note` command: empties the current note (appending the previous content to `.history.md` first) behind a confirmation prompt; a no-op on an already-empty note. - `/rebuild-note` command: behind a confirmation prompt, clears the note and asks the agent to rebuild the plan from the whole session via `make_note`, keeping only the remaining work (skipping anything already done or marked `- [x]`); the old note is passed verbatim and saved to history. On an empty note it bootstraps a fresh plan (no confirmation). +- `planqueue backlog` CLI (`bin` entry): a JSON-out interface over `~/.planqueue/backlog.db` (`ready`, `blocked`, `add-dep`, `set-status`, `add`) that codifies the plan-level backlog ordering, replacing hand-pasted SQL. Exit code doubles as a gate. ### Changed - Widget title changed from `PlanQueue` to `PlanQueue · Notes`. +- Extension display label (`pi.setLabel`) changed from `PlanQueue` to `PlanQueue · Notes` to match the widget branding. ## [0.1.0] - 2026-07-02 @@ -40,4 +44,5 @@ FIFO prompt queue for coding agents. - Internal iterations preceding the public PlanQueue identity are not tracked here; `0.1.0` is the first release under this name. +[0.1.1]: https://github.com/aryrabelo/planqueue/releases/tag/v0.1.1 [0.1.0]: https://github.com/aryrabelo/planqueue/releases/tag/v0.1.0 diff --git a/src/main.ts b/src/main.ts index 21c6443..745775a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -532,7 +532,7 @@ export default async function planQueueExtension( let bootstrapped = false; const shortcuts = await loadShortcuts(pi); - pi.setLabel("PlanQueue"); + pi.setLabel("PlanQueue \u00b7 Notes"); function refreshWidget(ctx: ExtensionContext): void { if (!ctx.hasUI) return;