# 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:

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
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)
- 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

```json
{
  "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) 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
```

### Machine Snapshot (`sf headless query`)

`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

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)

```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"
```
