From 77e429a08818573cbf4b264fa61ae67a538187f1 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Tue, 5 May 2026 01:18:02 +0200 Subject: [PATCH] feat(schedule): CLI commands add/list/done/cancel/snooze/run + wiring --- .../extensions/sf/commands-schedule.js | 438 ++++++++++++++++++ .../extensions/sf/commands/catalog.js | 9 + .../extensions/sf/commands/handlers/core.js | 5 + .../sf/commands/handlers/workflow.js | 6 + .../sf/tests/commands-schedule.test.mjs | 181 ++++++++ 5 files changed, 639 insertions(+) create mode 100644 src/resources/extensions/sf/commands-schedule.js create mode 100644 src/resources/extensions/sf/tests/commands-schedule.test.mjs diff --git a/src/resources/extensions/sf/commands-schedule.js b/src/resources/extensions/sf/commands-schedule.js new file mode 100644 index 000000000..72c83d97e --- /dev/null +++ b/src/resources/extensions/sf/commands-schedule.js @@ -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: 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 \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"); + } +} diff --git a/src/resources/extensions/sf/commands/catalog.js b/src/resources/extensions/sf/commands/catalog.js index e816efb6e..898fb5a96 100644 --- a/src/resources/extensions/sf/commands/catalog.js +++ b/src/resources/extensions/sf/commands/catalog.js @@ -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" }, diff --git a/src/resources/extensions/sf/commands/handlers/core.js b/src/resources/extensions/sf/commands/handlers/core.js index f26f4661b..98bf185e6 100644 --- a/src/resources/extensions/sf/commands/handlers/core.js +++ b/src/resources/extensions/sf/commands/handlers/core.js @@ -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]", diff --git a/src/resources/extensions/sf/commands/handlers/workflow.js b/src/resources/extensions/sf/commands/handlers/workflow.js index db2900dea..116438f99 100644 --- a/src/resources/extensions/sf/commands/handlers/workflow.js +++ b/src/resources/extensions/sf/commands/handlers/workflow.js @@ -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(); diff --git a/src/resources/extensions/sf/tests/commands-schedule.test.mjs b/src/resources/extensions/sf/tests/commands-schedule.test.mjs new file mode 100644 index 000000000..da8ff7212 --- /dev/null +++ b/src/resources/extensions/sf/tests/commands-schedule.test.mjs @@ -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:")); + }); + }); +});