Documentation
¶
Overview ¶
Package lifecycle implements the item state machine and its persistence effects. Fusion of the three lifecycle models: SpecKit (intent = proposal, work = tasks, linked via parent), OpenSpec (a proposal carries a delta spec; archiving merges it into the living spec.md), Cavekit-style tight loop (dense records, plain language — no encoding tricks).
States: draft -> submitted -> approved -> active -> done -> archived, plus rejected. Transitions follow a total order over the six main states (draft < submitted < approved < active < done < archived): forward jumps are always legal — every hop is optional, so draft->active or approved->archived is a single move call. Guards are enforced here, server-side:
- rejected is reachable from any of the six states except archived, and REQUIRES a note (the rejection corpus is a product feature);
- rejected is REVOCABLE (e.g. the rejection lacked information): the reject journal event snapshots the full item, so `move` can restore it into draft, submitted, approved or active (never done/archived) — and reject events survive every compaction, so revocability is permanent;
- done -> active (reopen) is the one backward hop kept outside rejection;
- archived requires no open children; skipping straight to archived (e.g. from active) implies done and runs the archive effects once;
- archived is terminal;
- rejected/archived items leave work.md — their summaries live in the journal, searchable via `find scope=rejection|history`.
Index ¶
- func Allowed(from string) []string
- func Draft(ws workspace.Root, _ Minter, kind, title, body, dir, parent string, ...) (item.Item, error)
- func Escalate(ws workspace.Root, mint Minter, it item.Item) (item.Item, item.Item, error)
- func IntentLine(id, title, gist string) string
- func Move(ws workspace.Root, id, to, note string, opts ...MoveOption) (item.Item, error)
- func ResolveBlocked(ws workspace.Root, id, outcome, note string) (item.Item, error)
- func RetainsBody(kind string) bool
- func ScopeFor(ws workspace.Root, dir string, targets []string, ...) (string, error)
- func Tombstone(ws workspace.Root, id string) (item.Item, bool, error)
- type ErrRoundsExhausted
- type Minter
- type MoveOption
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Allowed ¶
Allowed returns the transitions available from a state, in order: remaining forward states, then the done->active reopen special case, then rejected (unless from is archived or already rejected). rejected itself only allows revocation into draft/submitted/approved/active. blocked (see item.StateBlocked) is a side state like rejected but stricter still: it allows NO move-driven transitions at all — only lifecycle.ResolveBlocked, acting on the linked decision item's outcome, can move an item out of it. blocked is never a legal Move destination either (it never appears in any from's result here) — only Escalate sets it.
func Draft ¶
func Draft(ws workspace.Root, _ Minter, kind, title, body, dir, parent string, targets []string, refs ...string) (item.Item, error)
Draft creates a new item (state=draft) in the correct context dir: explicit dir > deepest common context dir of the targets > root.
refs is variadic so every pre-existing call site (none of which cite anything) keeps compiling unchanged — pass zero or more item IDs to stitch a citation set into the same single item.Upsert this function already performs. Draft does not validate refs itself (it has no journal-tombstone-aware "known ID" set to check against, and a self-reference cannot happen here since the ID does not exist yet anyway); callers that need validation (see item.UnknownRefs) must do it before calling Draft and refuse on a non-empty result. The mint parameter is accepted and ignored — see Minter. IDs come from item.MintID, which is collision-free across clones as well as across worktrees, which the counter never was.
func Escalate ¶
Escalate transitions a done item that has exhausted its feedback rounds (see ErrRoundsExhausted) into the item.StateBlocked side state and mints an adr item (kind=adr) recording the ways out: rescope, reject, or override-once (omitted once it.Override has already been spent once). Move cannot do this itself since it has no Minter; callers that catch ErrRoundsExhausted from Move are expected to call this with one. Returns the updated (now blocked) item and the newly minted decision item.
func IntentLine ¶ added in v0.8.1
IntentLine composes the `## intent` line archive appends to spec.md, and is the ONLY place that shape is written. internal/replay used to compose its own, shorter version — ID and title, no gist — and because git never merges .spectackle text, replay is main's sole writer: every record ever archived through the worktree flow, the documented primary workflow, landed on main with the note stripped. Two functions obliged to agree about a permanent artifact is the shape that produced that; one is the fix. The gist travels on the event (journal.Event.Gist) precisely so both callers pass the same value rather than each deriving one.
func Move ¶
Move transitions an item and applies the persistence effects. A rejected item (no longer in work.md) is restored from its reject journal snapshot.
When to is done, or a forward skip that implies done (archived — see the package doc comment), and the caller supplied WithAuditGate, Move refuses the transition if any anchor bound to the item's targets carries unresolved audit-class drift (drift.Tightened or drift.Diverged — a human has to look, per package drift's doc comment). drift.Evolved anchors never block: they are mechanically healable by check and re-stamp themselves the next time it runs, so gating on them would just make done unreachable without giving the caller anything to fix here. See auditGate below.
func ResolveBlocked ¶
ResolveBlocked resolves an item stuck in item.StateBlocked (see Escalate) according to outcome, recorded on its linked decision item:
- "rescope": item goes back to draft; Rounds resets to 0 (a fresh feedback budget), Grilled is kept (still-valid context for the rescoped work).
- "reject": the item is rejected outright (note required — it becomes the searchable rejection corpus); the reject snapshot includes Rounds/Grilled/Needs/Override so it stays revocable like any other rejection.
- "override-once": forces the item back to active for one more round without counting against the limit again (Rounds resets to 0); usable only once per item — Override is set and a second override-once errors.
func RetainsBody ¶ added in v0.8.0
RetainsBody reports whether a kind's archive tombstone keeps its body rather than compressing to a summary — the one place that answers it, so a renderer never has to restate the list. It matters to readers because Tombstone falls back to the summary when no body was retained: without this predicate, "the tombstone has a body" is true for every kind and says nothing.
func ScopeFor ¶ added in v0.7.1
func ScopeFor(ws workspace.Root, dir string, targets []string, resolve func(string) (string, bool)) (string, error)
ScopeFor derives an item's context dir: an explicit dir wins, otherwise the deepest context enclosing all path targets.
resolve maps a NODE-ID target to its file and is how node IDs reach their subsystem — lifecycle has no graph, so a caller that does (the MCP server) injects one. Without it, node IDs contribute nothing and an item scoped purely by nodes lands at root, which is what shipped until an independent gap hunt measured the consequence (B-01KYMCJFC3EMN): node IDs are the advertised currency, so the primary path was the broken one, and such items merged their archive delta into the WRONG intent section.
Types ¶
type ErrRoundsExhausted ¶
ErrRoundsExhausted is returned by Move when a done->active reopen would push an item's Rounds counter to (or past) the configured feedback round limit (ws.Cfg.Feedback.MaxRounds). Move cannot escalate the item itself — escalation mints a new decision item, which needs a Minter, and Move's signature carries none (changing it would break every existing caller). So Move only persists the incremented Rounds counter (+ journal event) and leaves the item on done, then returns this error carrying the item as it stands. The caller — mcpserver's move handler in a later task — is expected to catch it (errors.As) and call Escalate(ws, mint, it) to actually transition the item into item.StateBlocked and mint the decision item.
func (ErrRoundsExhausted) Error ¶
func (e ErrRoundsExhausted) Error() string
type Minter ¶
Minter turns a scan-derived floor into the next unique ID number. The swarm coordination DB provides one that is collision-free across parallel worktrees; nil falls back to floor+1 (single-agent behavior).
Item IDs no longer come from a Minter: ADR-0013 moved them to item.MintID, which needs no coordination because a UUIDv7 is unique without it. Draft and Escalate still TAKE a Minter — every caller passes one and rules are still minted through coord.NextID, which is a separate counter and a separate task's concern — but the value is now unused by the item path. The parameter stays so that no call site outside this package has to change for an ID-scheme swap; retiring it belongs with retiring the item counter itself.
type MoveOption ¶
type MoveOption func(*moveOpts)
MoveOption customizes a Move call. Options are the least invasive way to grow Move's parameters: every existing call site (positional ws, id, to, note) keeps compiling and keeps its old behavior untouched, since omitting opts... leaves moveOpts at its zero value.
func WithAuditGate ¶
WithAuditGate supplies the symbol graph and a rule-text resolver so Move can enforce the done-state audit gate (see the auditGate doc comment below). The ruleText closure mirrors the one internal/mcpserver's check tool builds from the loaded spec.Cascade: given a rule ID, return its current sentence and whether the rule still exists in the cascade (false routes the anchor to drift.Stale, which never blocks). Without this option Move performs no drift check at all — unchanged, pre-gate behavior.