Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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]

## [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

PlanQueue first public release: an OMP (Oh My Pi) extension that renders a
Expand All @@ -29,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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <goal>` 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 <text>` 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

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -42,6 +42,9 @@
"engines": {
"bun": ">=1.0.0"
},
"bin": {
"planqueue": "./src/cli.ts"
},
"scripts": {
"test": "bun test",
"typecheck": "tsc --noEmit",
Expand Down
193 changes: 193 additions & 0 deletions src/backlog.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
144 changes: 144 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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 <A> <B> | set-status <id> <status> |
* add <id> <title> [--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)));
Loading
Loading