singularity-forge/docs/specs/sf-schedule.md

297 lines
9 KiB
Markdown
Raw Permalink Normal View History

2026-05-06 06:02:46 +02:00
# 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.
2026-05-06 06:02:46 +02:00
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:
1. **On launch**`loader.ts` calls `findDue()` and prints a banner if items are due
2. **Autonomous mode boundaries**`sf headless query` (machine snapshot) and the TUI status overlay include due/upcoming entries in their output
2026-05-06 06:02:46 +02:00
3. **CLI query**`sf schedule list --due` shows items whose `due_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](../adr/0002-sf-schedule-pull-based.md) for the full decision record.
### ULID Identifiers
Schedule entries use [ULID](https://github.com/ulid/spec) (Universally Unique Lexicographically Sortable Identifier) instead of UUID. ULIDs are:
- 28 characters, Crockford Base32 encoded
- Lexicographically sortable by creation time (useful for schedule ordering)
2026-05-06 06:02:46 +02:00
- 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
2026-05-06 06:02:46 +02:00
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.
2026-05-06 06:02:46 +02:00
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.
2026-05-06 06:02:46 +02:00
---
## Storage Format
### Storage Locations
2026-05-06 06:02:46 +02:00
| Scope | Path |
|-------|------|
| `project` | `<basePath>/.sf/sf.db` |
| `global` | `~/.sf/sf.db` with `scope = 'global'` |
| legacy import | `<basePath>/.sf/schedule.jsonl` or `~/.sf/schedule.jsonl` |
2026-05-06 06:02:46 +02:00
### Schema
```json
{
2026-05-07 03:20:20 +02:00
"schemaVersion": 1,
2026-05-06 06:02:46 +02:00
"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
2026-05-06 06:02:46 +02:00
}
```
### Legacy JSONL Line Example
2026-05-06 06:02:46 +02:00
```
{"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}
2026-05-06 06:02:46 +02:00
```
---
## 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.
2026-05-06 06:02:46 +02:00
---
## CLI Reference
All commands are invoked as `/schedule <subcommand>` in the TUI or `sf schedule <subcommand>` from the shell.
2026-05-06 06:02:46 +02:00
### `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) or `global`
**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 whose `due_at <= now` (overdue + just-due)
- `--all`, `-a` — Show all entries including `done` and `cancelled`
- `--json`, `-j` — Raw JSON output
- `--scope <scope>``project` (default) or `global`
**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
2026-05-06 06:02:46 +02:00
```
### Machine Snapshot (`sf headless query`)
2026-05-06 06:02:46 +02:00
`headless-query.ts` populates a `schedule` field in `QuerySnapshot`:
```ts
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:
```yaml
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
2026-05-06 06:02:46 +02:00
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.
2026-05-06 06:02:46 +02:00
---
## 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)
```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"
```