feat(schedule): launch banner, headless query field, auto_dispatch type

This commit is contained in:
Mikael Hugo 2026-05-05 01:30:04 +02:00
parent a3f76d2679
commit 94ba38bdd6
6 changed files with 341 additions and 7 deletions

View file

@ -869,6 +869,18 @@ if (!cliFlags.worktree && !isPrintMode) {
}
}
// ---------------------------------------------------------------------------
// Scheduled items banner — remind user of due follow-ups
// ---------------------------------------------------------------------------
if (!cliFlags.worktree && !isPrintMode) {
try {
const { showScheduleBanner } = await import("./resources/extensions/sf/schedule-launch-banner.js");
await showScheduleBanner(process.cwd());
} catch {
/* non-fatal */
}
}
// ---------------------------------------------------------------------------
// Auto-redirect: autonomous mode with piped stdout → headless mode (#2732)
// When stdout is not a TTY (e.g. `sf auto | cat`, `sf auto > file`),

View file

@ -21,6 +21,7 @@ import { dirname, join } from "node:path";
import { createJiti } from "@mariozechner/jiti";
import { resolveBundledSourceResource } from "./bundled-resource-path.js";
import type { SFState } from "./resources/extensions/sf/types.js";
import { createScheduleStore } from "./resources/extensions/sf/schedule/schedule-store.js";
const jiti = createJiti(import.meta.filename, {
interopDefault: true,
@ -142,6 +143,22 @@ export interface QuerySnapshot {
runtime: {
units: RuntimeUnitSummary[];
};
schedule?: {
due: Array<{
id: string;
kind: string;
status: string;
due_at: string;
payload: unknown;
}>;
upcoming: Array<{
id: string;
kind: string;
status: string;
due_at: string;
payload: unknown;
}>;
};
}
export interface QueryResult {
@ -387,12 +404,28 @@ export async function buildQuerySnapshot(
lastHeartbeat: s.lastHeartbeat,
}));
// Load schedule entries
let scheduleEntries: QuerySnapshot["schedule"] = { due: [], upcoming: [] };
try {
const { createScheduleStore } = await import("./resources/extensions/sf/schedule/schedule-store.js");
const store = createScheduleStore(basePath);
const now = new Date();
const mapEntry = (e: { id: string; kind: string; status: string; due_at: string; payload: unknown }) => ({ id: e.id, kind: e.kind, status: e.status, due_at: e.due_at, payload: e.payload });
scheduleEntries = {
due: store.findDue("project", now).map(mapEntry),
upcoming: store.findUpcoming("project", now, 7).map(mapEntry),
};
} catch {
// Non-fatal — schedule data is best-effort in query output.
}
const snapshot: QuerySnapshot = {
schemaVersion: 1,
state,
next,
cost: { workers, total: workers.reduce((sum, w) => sum + w.cost, 0) },
runtime: { units: readRuntimeUnitSummaries(basePath) },
schedule: scheduleEntries,
};
return snapshot;

View file

@ -0,0 +1,58 @@
/**
* Schedule Launch Banner one-line stderr banner for due scheduled items.
*
* Purpose: surface pending schedule entries to the user on every sf launch
* so reminders and due tasks are visible without running /sf schedule list.
*
* Consumer: cli.ts interactive startup path.
*/
import { createScheduleStore } from "./schedule/schedule-store.js";
/**
* Print a one-line banner to stderr if there are due schedule entries.
*
* Reads project scope, de-duplicates by id, and prints:
* [forge] 2 scheduled items due: Review PR #123, Standup reminder
*
* Fast-exits when no due items exist. Non-fatal never throws.
*
* @param {string} basePath
* @returns {void}
*/
export function printScheduleBanner(basePath) {
const store = createScheduleStore(basePath);
const now = new Date().toISOString();
/** @type {import("./schedule/schedule-types.js").ScheduleEntry[]} */
let due = [];
try {
due = store.findDue("project", now);
} catch {
// Best-effort — never block startup
}
if (due.length === 0) return;
// Sort by due_at ascending
due.sort((a, b) => new Date(a.due_at) - new Date(b.due_at));
const titles = due
.slice(0, 3)
.map((e) => e.payload?.message || e.id.slice(0, 8));
const more = due.length > 3 ? ` (+${due.length - 3} more)` : "";
const label = due.length === 1 ? "scheduled item due" : "scheduled items due";
process.stderr.write(
`[forge] ${due.length} ${label}: ${titles.join(", ")}${more}\n`,
);
}
/**
* Alias for printScheduleBanner.
*
* @param {string} basePath
* @returns {void}
*/
export function showScheduleBanner(basePath) {
return printScheduleBanner(basePath);
}

View file

@ -64,13 +64,14 @@
/**
* @typedef {object} ScheduleEntry
* @property {string} id ULID monotonic, sortable, 28 chars
* @property {ScheduleKind} kind What kind of scheduled item this is
* @property {ScheduleStatus} status Current lifecycle status
* @property {string} due_at ISO-8601 timestamp
* @property {string} created_at ISO-8601 timestamp
* @property {SchedulePayload} payload Kind-specific data
* @property {ScheduleCreatedBy} created_by Who created the entry
* @property {string} id ULID monotonic, sortable, 28 chars
* @property {ScheduleKind} kind What kind of scheduled item this is
* @property {ScheduleStatus} status Current lifecycle status
* @property {string} due_at ISO-8601 timestamp
* @property {string} created_at ISO-8601 timestamp
* @property {SchedulePayload} payload Kind-specific data
* @property {ScheduleCreatedBy} created_by Who created the entry
* @property {boolean} [auto_dispatch] If true and kind='reminder', surface as dispatch input in auto-mode when due. Defaults false.
*/
// ─── Guards ─────────────────────────────────────────────────────────────────

View file

@ -0,0 +1,187 @@
/**
* Schedule Launch Banner unit tests.
*
* Purpose: verify the banner prints due entries to stderr and stays quiet
* when nothing is due.
*
* 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 {
printScheduleBanner,
showScheduleBanner,
} from "../schedule-launch-banner.js";
import { createScheduleStore } from "../schedule/schedule-store.js";
import { generateULID } from "../schedule/schedule-ulid.js";
function captureStderr(fn) {
const chunks = [];
const original = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk) => {
chunks.push(String(chunk));
return true;
};
try {
fn();
} finally {
process.stderr.write = original;
}
return chunks.join("");
}
function makeEntry(overrides = {}) {
const now = new Date().toISOString();
return {
id: generateULID(),
kind: "reminder",
status: "pending",
due_at: now,
created_at: now,
payload: { message: "test reminder" },
created_by: "user",
...overrides,
};
}
describe("schedule-launch-banner", () => {
let testDir;
let originalCwd;
beforeEach(() => {
testDir = join(
tmpdir(),
`sf-banner-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("printScheduleBanner", () => {
it("is silent when no entries are due", () => {
const output = captureStderr(() => printScheduleBanner(testDir));
assert.equal(output, "");
});
it("prints a banner for a single due entry", () => {
const store = createScheduleStore(testDir);
const entry = makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
payload: { message: "Review PR" },
});
store.appendEntry("project", entry);
const output = captureStderr(() =>
printScheduleBanner(testDir),
);
assert.ok(output.includes("1 scheduled item due"));
assert.ok(output.includes("Review PR"));
});
it("prints a banner for multiple due entries", () => {
const store = createScheduleStore(testDir);
store.appendEntry(
"project",
makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
payload: { message: "First" },
}),
);
store.appendEntry(
"project",
makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
payload: { message: "Second" },
}),
);
const output = captureStderr(() =>
printScheduleBanner(testDir),
);
assert.ok(output.includes("2 scheduled items due"));
assert.ok(output.includes("First"));
assert.ok(output.includes("Second"));
});
it("truncates to 3 titles with a +more suffix", () => {
const store = createScheduleStore(testDir);
for (let i = 0; i < 5; i++) {
store.appendEntry(
"project",
makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
payload: { message: `Item ${i}` },
}),
);
}
const output = captureStderr(() =>
printScheduleBanner(testDir),
);
assert.ok(output.includes("(+2 more)"));
});
it("does not print done entries", () => {
const store = createScheduleStore(testDir);
store.appendEntry(
"project",
makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
status: "done",
payload: { message: "Already done" },
}),
);
const output = captureStderr(() =>
printScheduleBanner(testDir),
);
assert.equal(output, "");
});
it("does not print future entries", () => {
const store = createScheduleStore(testDir);
store.appendEntry(
"project",
makeEntry({
due_at: "2030-01-01T00:00:00.000Z",
payload: { message: "Future" },
}),
);
const output = captureStderr(() =>
printScheduleBanner(testDir),
);
assert.equal(output, "");
});
});
describe("showScheduleBanner", () => {
it("is an alias for printScheduleBanner", () => {
const store = createScheduleStore(testDir);
store.appendEntry(
"project",
makeEntry({
due_at: "2024-01-01T00:00:00.000Z",
payload: { message: "Alias test" },
}),
);
const out1 = captureStderr(() => printScheduleBanner(testDir));
const out2 = captureStderr(() => showScheduleBanner(testDir));
assert.equal(out1, out2);
});
});
});

View file

@ -0,0 +1,43 @@
/**
* Schedule field in headless query output.
*
* Purpose: verify that `sf headless query` JSON includes a schedule field
* with due and upcoming entries.
*
* Consumer: CI test runner (vitest).
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "vitest";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(__dirname, "..", "headless-query.ts"), "utf-8");
test("headless-query QuerySnapshot type includes schedule field", () => {
assert.ok(
src.includes("schedule?:"),
"QuerySnapshot should have an optional schedule field",
);
});
test("headless-query schedule field has due and upcoming arrays", () => {
const scheduleBlock = src.slice(src.indexOf("schedule?:"));
assert.ok(scheduleBlock.includes("due:"), "schedule should have due array");
assert.ok(
scheduleBlock.includes("upcoming:"),
"schedule should have upcoming array",
);
});
test("headless-query buildQuerySnapshot populates schedule entries", () => {
assert.ok(
src.includes("scheduleEntries") || src.includes("schedule:"),
"buildQuerySnapshot should populate schedule data",
);
assert.ok(
src.includes("findDue") && src.includes("findUpcoming"),
"buildQuerySnapshot should call findDue and findUpcoming",
);
});