9 KiB
SF Schedule System — Specification
Spec version: 1.0.0
Status: Implemented (M010 S02)
Owner: M010 S05
Overview
The SF schedule system provides time-based reminders and deferred work items that surface at a future date. Entries are stored in SQLite (schedule_entries) and queried on demand (pull-based), not fired by a daemon or cron job. This makes the system portable, auditable, and free of background processes.
Use sf schedule when something needs to happen at a specific future time but cannot (or should not) happen immediately:
- Schedule — time-bound items that must surface on a date, even if SF is not running continuously
- Backlog — priority-ordered items with no specific timing (SF's standard milestone/slice queue)
Design Rationale
Pull-Based, Not Daemon-Based
SF has no long-running daemon. Entries are not "fired" by a timer. Instead, the schedule store is queried at specific integration points:
- On launch —
loader.tscallsfindDue()and prints a banner if items are due - Autonomous mode boundaries —
sf headless query(machine snapshot) and the TUI status overlay include due/upcoming entries in their output - CLI query —
sf schedule list --dueshows items whosedue_at <= now
This means: if an item is scheduled for 3 AM and you open SF at 9 AM, you will see the item as overdue. There is no fire-at-exact-time guarantee. This is an explicit trade-off — see the pull-based ADR for the full decision record.
ULID Identifiers
Schedule entries use ULID (Universally Unique Lexicographically Sortable Identifier) instead of UUID. ULIDs are:
- 28 characters, Crockford Base32 encoded
- Lexicographically sortable by creation time (useful for schedule ordering)
- Unique enough to avoid collisions across concurrent appends
- Monotonic within millisecond precision via sub-millisecond counter
The generateULID() function in schedule-ulid.js is used for all new entries.
DB-Primary Ledger
Each write appends a row to schedule_entries. The latest row per ID wins on read. This means status transitions (pending → done, cancelled, snoozed) are implemented as ledger entries, not in-place mutations.
Legacy schedule.jsonl files are import-only compatibility inputs. Rows without schemaVersion are treated as legacy version 1. Unsupported future schema versions are ignored by the current reader. Corrupt lines are skipped with a warning, never fatal.
Storage Format
Storage Locations
| Scope | Path |
|---|---|
project |
<basePath>/.sf/sf.db |
global |
~/.sf/sf.db with scope = 'global' |
| legacy import | <basePath>/.sf/schedule.jsonl or ~/.sf/schedule.jsonl |
Schema
{
"schemaVersion": 1,
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", // ULID — 28 chars
"kind": "reminder", // ScheduleKind enum
"status": "pending", // pending | done | cancelled | snoozed
"due_at": "2026-06-15T09:00:00.000Z", // ISO-8601 timestamp
"created_at": "2026-05-15T09:00:00.000Z",
"snoozed_at": "2026-06-01T09:00:00.000Z", // ISO-8601 — set on each snooze
"payload": { "message": "Review adoption metrics" }, // kind-specific
"created_by": "user", // user | agent | system
"autonomous_dispatch": false // if true + kind=prompt/command, consume from autonomous mode
}
Legacy JSONL Line Example
{"schemaVersion":1,"id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","kind":"reminder","status":"pending","due_at":"2026-06-15T09:00:00.000Z","created_at":"2026-05-15T09:00:00.000Z","payload":{"message":"Review adoption metrics"},"created_by":"user","autonomous_dispatch":false}
Schedule Kinds
| Kind | Description | Payload fields |
|---|---|---|
reminder |
General time-based reminder | message, unitId?, milestoneId? |
milestone_check |
Milestone health check | milestoneId, checkType? |
review_due |
Review prompt surfaced at next planning turn | prUrl?, reviewer?, unitId? |
recurring |
Cron-based recurring entry (future) | cron, unitId?, milestoneId? |
review |
Alias for review_due — same behaviour |
— |
audit |
Audit surfaced at next planning turn | unitId? |
command |
Shell command run by explicit sf schedule run <id> |
command, capture? |
review and audit kinds are surfaced to the next autonomous planning turn (TBD: integration point in sf_plan_slice / sf_plan_task / autonomous dispatch). They are stored but not autonomous dispatched without a consumer.
CLI Reference
All commands are invoked as /schedule <subcommand> in the TUI or sf schedule <subcommand> from the shell.
sf schedule add
sf schedule add --in <duration> [--kind <kind>] [--scope <scope>] <title>
sf schedule add --at <ISO-date> [--kind <kind>] [--scope <scope>] <title>
Schedule a new item.
Flags:
--in <duration>— Relative time from now (e.g.2w,30m,1d,4h)--at <ISO-date>— Absolute ISO-8601 date--kind <kind>— Entry kind (default:reminder). Valid:reminder,milestone_check,review_due,review,audit,recurring,command--scope <scope>—project(default) orglobal
Examples:
sf schedule add --in 2w "Review feature adoption metrics"
sf schedule add --in 30m --kind milestone_check "Check M003 validation"
sf schedule add --at 2026-06-01T09:00:00Z --scope global "Team sync"
sf schedule list
sf schedule list [--due] [--all] [--json] [--scope <scope>]
List scheduled items.
Flags:
--due,-d— Show only items whosedue_at <= now(overdue + just-due)--all,-a— Show all entries includingdoneandcancelled--json,-j— Raw JSON output--scope <scope>—project(default) orglobal
Output columns: ID (8-char prefix), Title, Due (relative), Status, Kind
Examples:
sf schedule list
sf schedule list --due
sf schedule list --all --json
sf schedule list --scope global
sf schedule done
sf schedule done <id>
Mark a pending item as done. ID can be a prefix (ULID prefix match).
sf schedule done 01ARZ3ND
sf schedule cancel
sf schedule cancel <id>
Cancel a scheduled item. ID can be a prefix.
sf schedule cancel 01ARZ3ND
sf schedule snooze
sf schedule snooze <id> --by <duration>
Postpone a scheduled item by a relative duration. Updates due_at and sets snoozed_at.
sf schedule snooze 01ARZ3ND --by 1d
sf schedule snooze 01ARZ3ND --by 30m
sf schedule run
sf schedule run <id>
Execute a scheduled item. For reminder, milestone_check, review_due kinds: displays the title and marks done. For command kind: executes the stored shell command and captures output.
sf schedule run 01ARZ3ND
Integration Points
Loader Banner (loader.ts)
On every SF startup, loader.ts calls findDue() for both project and global scopes. If any items are due, it prints:
[forge] N scheduled item(s) due now. Manage: /schedule list
Machine Snapshot (sf headless query)
headless-query.ts populates a schedule field in QuerySnapshot:
schedule: {
due: ScheduleEntry[], // due_at <= now
upcoming: ScheduleEntry[] // due_at within 7 days
}
This feeds the sf status dashboard and autonomous dispatch context.
Milestone YAML Schedule (sf_plan_milestone)
The milestone plan schema supports a schedule[] array in the YAML spec:
schedule:
- in: 2w
kind: review
title: "Review adoption after shipping feature"
These entries are created at milestone creation time. The in field is relative to now. The on_complete variant fires a duration after milestone completion.
Autonomous Dispatch
When autonomous_dispatch: true and kind: "prompt" or kind: "command", the item is consumed by autonomous mode when due_at <= now. This is the mechanism for time-bound autonomous repo work.
Duration Format
All duration strings follow the format <number><unit>:
| Unit | Meaning |
|---|---|
w |
weeks |
d |
days |
h |
hours |
m |
minutes |
Examples: 30m, 4h, 2d, 1w
Examples
Reminder (2 weeks out)
sf schedule add --in 2w "Review feature adoption metrics"
Milestone Check (at milestone creation via plan YAML)
# In milestone spec
schedule:
- in: 2w
kind: milestone_check
title: "Validate M003 success criteria"
Audit (surfaced at next planning turn)
sf schedule add --in 1mo --kind audit "Audit ADR-007 decision implementation"
Command (shell command execution)
sf schedule add --in 30m --kind command "Reminder: run integration tests"
# Note: kind=command requires payload.command field — use the CLI directly
# to set kind=reminder for simple reminders
Global Scope (across all projects)
sf schedule add --in 1w --scope global "Review all open milestones"