feat(schedule): CLI commands add/list/done/cancel/snooze/run + wiring

This commit is contained in:
Mikael Hugo 2026-05-05 01:18:02 +02:00
parent b92d7bc96b
commit 77e429a088
5 changed files with 639 additions and 0 deletions

View file

@ -0,0 +1,438 @@
/**
* SF Command /sf schedule
*
* Schedule management: add, list, done, cancel, snooze, run.
* Entries stored as append-only JSONL in .sf/schedule.jsonl (project)
* or ~/.sf/schedule.jsonl (global).
*/
import { createScheduleStore } from "./schedule/schedule-store.js";
import { generateULID } from "./schedule/schedule-ulid.js";
import { isValidKind } from "./schedule/schedule-types.js";
import { execSync } from "node:child_process";
// ─── Duration parser ────────────────────────────────────────────────────────
/**
* Parse a duration string into milliseconds.
*
* Purpose: convert human-friendly durations like "2w" or "30m" into
* epoch milliseconds for computing due_at timestamps.
*
* Consumer: schedule add and snooze commands.
*
* @param {string} str
* @returns {number} milliseconds
*/
export function parseDuration(str) {
const match = str.trim().match(/^(\d+)([wdhm])$/i);
if (!match) {
throw new Error(`Invalid duration: "${str}". Expected format: <number><unit> where unit is w(weeks), d(days), h(hours), m(minutes).`);
}
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
switch (unit) {
case "w": return value * 7 * 24 * 60 * 60 * 1000;
case "d": return value * 24 * 60 * 60 * 1000;
case "h": return value * 60 * 60 * 1000;
case "m": return value * 60 * 1000;
default:
throw new Error(`Unknown duration unit: ${unit}`);
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function _basePath() {
return process.cwd();
}
function _isoRelative(dueAt) {
const now = Date.now();
const due = new Date(dueAt).getTime();
const diff = due - now;
const abs = Math.abs(diff);
const days = Math.floor(abs / (24 * 60 * 60 * 1000));
const hours = Math.floor((abs % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
if (diff < 0) {
if (days > 0) return `overdue ${days}d`;
if (hours > 0) return `overdue ${hours}h`;
return "overdue";
}
if (days > 0) return `${days}d`;
if (hours > 0) return `${hours}h`;
return "soon";
}
function _formatTable(rows) {
if (rows.length === 0) return "";
const colCount = rows[0].length;
const widths = new Array(colCount).fill(0);
for (const row of rows) {
for (let i = 0; i < colCount; i++) {
widths[i] = Math.max(widths[i], String(row[i]).length);
}
}
return rows
.map((row) =>
row.map((cell, i) => String(cell).padEnd(widths[i])).join(" "),
)
.join("\n");
}
function _findEntry(store, scope, idPrefix) {
const entries = store.readEntries(scope);
const match = entries.find((e) => e.id.startsWith(idPrefix));
if (!match) {
const exact = entries.find((e) => e.id === idPrefix);
if (exact) return { entry: exact, entries };
}
return { entry: match, entries };
}
// ─── Subcommands ────────────────────────────────────────────────────────────
async function addItem(args, ctx) {
const parts = args.trim().split(/\s+/);
let kind = "reminder";
let scope = "project";
let dueAt = null;
let titleParts = [];
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === "--kind" || p === "-k") {
kind = parts[++i];
continue;
}
if (p === "--scope" || p === "-s") {
scope = parts[++i];
continue;
}
if (p === "--in") {
const dur = parts[++i];
try {
dueAt = new Date(Date.now() + parseDuration(dur)).toISOString();
} catch (err) {
ctx.ui.notify(err.message, "warning");
return;
}
continue;
}
if (p === "--at") {
const iso = parts[++i];
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) {
ctx.ui.notify(`Invalid ISO date: ${iso}`, "warning");
return;
}
dueAt = new Date(parsed).toISOString();
continue;
}
titleParts.push(p);
}
if (!isValidKind(kind)) {
ctx.ui.notify(`Unknown kind: ${kind}. Valid: reminder, milestone_check, review_due, recurring`, "warning");
return;
}
if (scope !== "project" && scope !== "global") {
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
return;
}
if (!dueAt) {
ctx.ui.notify("Usage: /sf schedule add --in <duration> <title>\n /sf schedule add --at <ISO-date> <title>", "warning");
return;
}
const title = titleParts.join(" ").trim();
if (!title) {
ctx.ui.notify("Missing title. Example: /sf schedule add --in 2w 'Review adoption metrics'", "warning");
return;
}
const store = createScheduleStore(_basePath());
const entry = {
id: generateULID(),
kind,
status: "pending",
due_at: dueAt,
created_at: new Date().toISOString(),
payload: { message: title },
created_by: "user",
};
store.appendEntry(scope, entry);
ctx.ui.notify(`Scheduled: ${entry.id}\nDue: ${entry.due_at}`, "success");
}
async function listItems(args, ctx) {
const parts = args.trim().split(/\s+/).filter(Boolean);
let scope = "project";
let showDueOnly = false;
let showAll = false;
let json = false;
for (let i = 0; i < parts.length; i++) {
const p = parts[i];
if (p === "--scope" || p === "-s") {
scope = parts[++i];
continue;
}
if (p === "--due" || p === "-d") {
showDueOnly = true;
continue;
}
if (p === "--all" || p === "-a") {
showAll = true;
continue;
}
if (p === "--json" || p === "-j") {
json = true;
continue;
}
}
if (scope !== "project" && scope !== "global") {
ctx.ui.notify(`Unknown scope: ${scope}. Valid: project, global`, "warning");
return;
}
const store = createScheduleStore(_basePath());
const now = new Date().toISOString();
let entries;
if (showDueOnly) {
entries = store.findDue(scope, now);
} else if (showAll) {
entries = store.readEntries(scope);
} else {
entries = store.readEntries(scope).filter((e) => e.status === "pending");
}
entries.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
if (json) {
ctx.ui.notify(JSON.stringify(entries, null, 2), "info");
return;
}
if (entries.length === 0) {
ctx.ui.notify("No scheduled items.", "info");
return;
}
const rows = [["ID", "Title", "Due", "Status", "Kind"]];
for (const e of entries) {
const title = e.payload?.message || e.payload?.command || e.id;
rows.push([
e.id.slice(0, 8),
title.slice(0, 40),
_isoRelative(e.due_at),
e.status,
e.kind,
]);
}
ctx.ui.notify(_formatTable(rows), "info");
}
async function markDone(args, ctx) {
const idPrefix = args.trim();
if (!idPrefix) {
ctx.ui.notify("Usage: /sf schedule done \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const updated = {
...entry,
status: "done",
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Marked done: ${entry.id}`, "success");
}
async function markCancel(args, ctx) {
const idPrefix = args.trim();
if (!idPrefix) {
ctx.ui.notify("Usage: /sf schedule cancel \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const updated = {
...entry,
status: "cancelled",
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Cancelled: ${entry.id}`, "success");
}
async function snoozeItem(args, ctx) {
const parts = args.trim().split(/\s+/).filter(Boolean);
let idPrefix = "";
let by = "";
for (let i = 0; i < parts.length; i++) {
if (parts[i] === "--by" || parts[i] === "-b") {
by = parts[++i];
} else if (!idPrefix) {
idPrefix = parts[i];
}
}
if (!idPrefix || !by) {
ctx.ui.notify("Usage: /sf schedule snooze \u003cid\u003e --by \u003cduration\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
let offsetMs;
try {
offsetMs = parseDuration(by);
} catch (err) {
ctx.ui.notify(err.message, "warning");
return;
}
const newDue = new Date(new Date(entry.due_at).getTime() + offsetMs).toISOString();
const updated = {
...entry,
status: "snoozed",
due_at: newDue,
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Snoozed: ${entry.id}\nNew due: ${newDue}`, "success");
}
async function runItem(args, ctx) {
const idPrefix = args.trim();
if (!idPrefix) {
ctx.ui.notify("Usage: /sf schedule run \u003cid\u003e", "warning");
return;
}
const store = createScheduleStore(_basePath());
const { entry } = _findEntry(store, "project", idPrefix);
if (!entry) {
ctx.ui.notify(`Item ${idPrefix} not found in project scope.`, "warning");
return;
}
const kind = entry.kind;
const payload = entry.payload || {};
switch (kind) {
case "reminder":
case "milestone_check":
case "review_due": {
const title = payload.message || entry.id;
ctx.ui.notify(`Reminder: ${title}`, "info");
break;
}
case "command": {
const cmd = payload.command;
if (!cmd) {
ctx.ui.notify(`Command entry ${entry.id} has no command in payload.`, "warning");
return;
}
try {
const capture = payload.capture === "stdout";
const result = execSync(cmd, {
stdio: capture ? ["pipe", "pipe", "pipe"] : "inherit",
encoding: "utf-8",
});
if (capture) {
ctx.ui.notify(result, "info");
}
} catch (err) {
const stderr = err.stderr || err.message || String(err);
ctx.ui.notify(`Command failed: ${stderr}`, "error");
const updated = {
...entry,
status: "cancelled",
created_at: new Date().toISOString(),
payload: { ...payload, result_note: stderr },
};
store.appendEntry("project", updated);
return;
}
break;
}
default: {
ctx.ui.notify(`Unknown kind "${kind}" for item ${entry.id}.`, "warning");
return;
}
}
// Mark done on success
const updated = {
...entry,
status: "done",
created_at: new Date().toISOString(),
};
store.appendEntry("project", updated);
ctx.ui.notify(`Completed: ${entry.id}`, "success");
}
// ─── Public handler ─────────────────────────────────────────────────────────
/**
* Handle /sf schedule subcommands.
*
* Purpose: route schedule CLI input to the appropriate subcommand.
*
* Consumer: commands dispatcher (dispatcher.js).
*
* @param {string} args
* @param {import("@singularity-forge/pi-coding-agent").ExtensionContext} ctx
*/
export async function handleSchedule(args, ctx) {
const parts = args.trim().split(/\s+/).filter(Boolean);
const sub = parts[0] ?? "";
const rest = parts.slice(1).join(" ");
switch (sub) {
case "add":
return addItem(rest, ctx);
case "list":
return listItems(rest, ctx);
case "done":
return markDone(rest, ctx);
case "cancel":
return markCancel(rest, ctx);
case "snooze":
return snoozeItem(rest, ctx);
case "run":
return runItem(rest, ctx);
case "":
ctx.ui.notify(
"Usage: /sf schedule add|list|done|cancel|snooze|run\n" +
" add --in \u003cduration\u003e [--kind \u003ckind\u003e] [--scope \u003cscope\u003e] \u003ctitle\u003e\n" +
" list [--due] [--all] [--json] [--scope \u003cscope\u003e]\n" +
" done \u003cid\u003e\n" +
" cancel \u003cid\u003e\n" +
" snooze \u003cid\u003e --by \u003cduration\u003e\n" +
" run \u003cid\u003e",
"info",
);
return;
default:
ctx.ui.notify(`Unknown schedule subcommand: ${sub}. Use /sf schedule for usage.`, "warning");
}
}

View file

@ -149,6 +149,7 @@ export const TOP_LEVEL_SUBCOMMANDS = [
{ cmd: "do", desc: "Route freeform text to the right SF command" },
{ cmd: "session-report", desc: "Session cost, tokens, and work summary" },
{ cmd: "backlog", desc: "Manage backlog items (add, promote, remove, list)" },
{ cmd: "schedule", desc: "Manage scheduled items (add, list, done, cancel, snooze, run)" },
{ cmd: "pr-branch", desc: "Create clean PR branch filtering .sf/ commits" },
{ cmd: "add-tests", desc: "Generate tests for completed slices" },
{
@ -399,6 +400,14 @@ const NESTED_COMPLETIONS = {
{ cmd: "promote", desc: "Promote backlog item to active slice" },
{ cmd: "remove", desc: "Remove backlog item" },
],
schedule: [
{ cmd: "add", desc: "Add a scheduled item" },
{ cmd: "list", desc: "List scheduled items" },
{ cmd: "done", desc: "Mark item as done" },
{ cmd: "cancel", desc: "Cancel a scheduled item" },
{ cmd: "snooze", desc: "Snooze an item by duration" },
{ cmd: "run", desc: "Run a scheduled item now" },
],
todo: [
{ cmd: "triage", desc: "Triage root TODO.md into .sf/triage artifacts" },
{ cmd: "triage --no-clear", desc: "Triage TODO.md without resetting it" },

View file

@ -78,6 +78,11 @@ export function showHelp(ctx, args = "") {
" /sf knowledge <type> <text> Add rule, pattern, or lesson to KNOWLEDGE.md",
" /sf codebase [generate|update|stats|rag] Manage CODEBASE.md and optional code search",
"",
"SCHEDULE",
" /sf schedule add --in <dur> <title> Schedule a follow-up item",
" /sf schedule list Show pending scheduled items",
" /sf schedule done <id> Mark an item complete",
"",
"SETUP & CONFIGURATION",
" /sf init Project init wizard — detect, configure, bootstrap .sf/",
" /sf setup Global setup status [llm|search|remote|keys|prefs]",

View file

@ -205,6 +205,12 @@ export async function handleWorkflowCommand(trimmed, ctx, pi) {
await handleBacklog(trimmed.replace(/^backlog\s*/, "").trim(), ctx, pi);
return true;
}
// ── Schedule management ──
if (trimmed === "schedule" || trimmed.startsWith("schedule ")) {
const { handleSchedule } = await import("../../commands-schedule.js");
await handleSchedule(trimmed.replace(/^schedule\s*/, "").trim(), ctx);
return true;
}
// ── Custom workflow commands (`/sf workflow ...`) ──
if (trimmed === "workflow" || trimmed.startsWith("workflow ")) {
const sub = trimmed.slice("workflow".length).trim();

View file

@ -0,0 +1,181 @@
/**
* Schedule CLI command tests.
*
* Purpose: verify the /sf schedule subcommands: add, list, done, cancel,
* snooze, run, and the duration parser.
*
* Consumer: CI test runner (vitest).
*/
import assert from "node:assert/strict";
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, it } from "vitest";
import { parseDuration, handleSchedule } from "../commands-schedule.js";
function mockCtx() {
const notifications = [];
return {
ui: {
notify: (msg, type = "info") => {
notifications.push({ msg, type });
},
},
notifications,
};
}
describe("parseDuration", () => {
it("parses weeks", () => {
assert.equal(parseDuration("2w"), 2 * 7 * 24 * 60 * 60 * 1000);
});
it("parses days", () => {
assert.equal(parseDuration("1d"), 24 * 60 * 60 * 1000);
});
it("parses hours", () => {
assert.equal(parseDuration("1h"), 60 * 60 * 1000);
});
it("parses minutes", () => {
assert.equal(parseDuration("30m"), 30 * 60 * 1000);
});
it("throws on invalid format", () => {
assert.throws(() => parseDuration("foo"), /Invalid duration/);
});
});
describe("handleSchedule", () => {
let testDir;
let originalCwd;
beforeEach(() => {
testDir = join(
tmpdir(),
`sf-schedule-cmd-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
);
mkdirSync(testDir, { recursive: true });
originalCwd = process.cwd();
process.chdir(testDir);
});
afterEach(() => {
process.chdir(originalCwd);
try {
rmSync(testDir, { recursive: true });
} catch {
// ignore
}
});
describe("add", () => {
it("adds a scheduled item with --in", async () => {
const ctx = mockCtx();
await handleSchedule("add --in 1d test item", ctx);
assert.equal(ctx.notifications.length, 1);
assert.equal(ctx.notifications[0].type, "success");
assert.ok(ctx.notifications[0].msg.includes("Scheduled:"));
});
it("rejects missing title", async () => {
const ctx = mockCtx();
await handleSchedule("add --in 1d", ctx);
assert.equal(ctx.notifications[0].type, "warning");
assert.ok(ctx.notifications[0].msg.includes("Missing title"));
});
it("rejects missing --in or --at", async () => {
const ctx = mockCtx();
await handleSchedule("add test item", ctx);
assert.equal(ctx.notifications[0].type, "warning");
assert.ok(ctx.notifications[0].msg.includes("Usage:"));
});
});
describe("list", () => {
it("lists items after adding", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d first item", ctx1);
const ctx2 = mockCtx();
await handleSchedule("list", ctx2);
assert.equal(ctx2.notifications.length, 1);
assert.equal(ctx2.notifications[0].type, "info");
assert.ok(ctx2.notifications[0].msg.includes("first item"));
});
it("shows empty message when no items", async () => {
const ctx = mockCtx();
await handleSchedule("list", ctx);
assert.equal(ctx.notifications[0].type, "info");
assert.ok(ctx.notifications[0].msg.includes("No scheduled items"));
});
});
describe("done", () => {
it("marks an item done", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d item to complete", ctx1);
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
assert.ok(id);
const ctx2 = mockCtx();
await handleSchedule(`done ${id.slice(0, 8)}`, ctx2);
assert.equal(ctx2.notifications[0].type, "success");
assert.ok(ctx2.notifications[0].msg.includes("Marked done"));
});
});
describe("cancel", () => {
it("cancels an item", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d item to cancel", ctx1);
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
assert.ok(id);
const ctx2 = mockCtx();
await handleSchedule(`cancel ${id.slice(0, 8)}`, ctx2);
assert.equal(ctx2.notifications[0].type, "success");
assert.ok(ctx2.notifications[0].msg.includes("Cancelled"));
});
});
describe("snooze", () => {
it("snoozes an item", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d item to snooze", ctx1);
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
assert.ok(id);
const ctx2 = mockCtx();
await handleSchedule(`snooze ${id.slice(0, 8)} --by 2d`, ctx2);
assert.equal(ctx2.notifications[0].type, "success");
assert.ok(ctx2.notifications[0].msg.includes("Snoozed"));
});
});
describe("run", () => {
it("runs a reminder item and marks done", async () => {
const ctx1 = mockCtx();
await handleSchedule("add --in 1d --kind reminder reminder item", ctx1);
const id = ctx1.notifications[0].msg.match(/Scheduled: (\S+)/)?.[1];
assert.ok(id);
const ctx2 = mockCtx();
await handleSchedule(`run ${id.slice(0, 8)}`, ctx2);
assert.ok(ctx2.notifications.some((n) => n.msg.includes("Reminder:")));
assert.ok(ctx2.notifications.some((n) => n.type === "success"));
});
});
describe("usage", () => {
it("shows usage when called with no subcommand", async () => {
const ctx = mockCtx();
await handleSchedule("", ctx);
assert.equal(ctx.notifications[0].type, "info");
assert.ok(ctx.notifications[0].msg.includes("Usage:"));
});
});
});