Documentation
¶
Overview ¶
Package lifecycle hosts a kind-parameterized state machine for SpecScore artifact Status transitions. It is the shared implementation layer that the per-kind `change-status` CLI verbs consume.
The package is deliberately kind-agnostic: Idea, Feature, and any future doc kind plug their own legal-transition matrix into the package's lookup tables. Verb-specific logic (archive relocation, feature-id resolution, cobra wiring, exit-code mapping) lives in the calling CLI verbs, not here.
Architectural contract implemented by this package (see spec/features/cli/lifecycle-transitions/README.md):
- REQ: state-machine-strictness — Transition rejects any (from, to) pair not declared in the kind's matrix.
- REQ: not-idempotent — the matrix MUST NOT contain a self-loop (from == to). The package's init step panics if a self-loop is declared, so corruption is caught at startup rather than runtime.
- REQ: status-line-rewrite — Rewrite mutates only the **Status:** line, preserving every other byte (including line endings and trailing whitespace).
- Existing-artifact writers use one fail-fast per-path transaction, read and validate under lock, and atomically replace only expected bytes.
- Rollback remains an explicit legacy operation for callers whose broader contract still requires it; transaction users do not perform a late stale rollback after post-commit work fails.
Package lifecycle — the orthogonal "parked" scheduling axis.
Parked answers a different question than **Status:** does. Status is a MATURITY axis — how well-specified and agreed is this artifact? Parked is a SCHEDULING axis — when will we build it? A fully-specced, ratified (high-maturity) Feature can still be "not this release" (parked); an early Draft can be parked too. The two axes are independent, so parking an artifact:
- NEVER changes `**Status:**`. A parked artifact keeps whatever status it had; unparking restores nothing because nothing was taken away.
- Is NOT a lifecycle transition. There is no legal-transition matrix entry for it, and it is not gated on the artifact's current status — a Draft can be parked, so can an Approved.
This mirrors the existing "Archived" axis for the Idea kind (see pkg/idea/archive.go): both are structured header-line facts orthogonal to **Status:**, inserted immediately after the `**Status:**` line. Parked differs from Archived in one respect: it never relocates the file — a parked artifact stays exactly where it lives; only the header changes.
Unlike an optional structured field (e.g. SetSupersededBy's successor), parking REQUIRES a reason and a date — a bare `**Parked:** true` with no explanation rots into a graveyard nobody can audit, which is the exact failure this axis exists to prevent (see cli/parked#req:reason-required).
Index ¶
- Variables
- func AppendResolutionNote(artifactPath, note string) (original []byte, wrote bool, err error)
- func AppendResolutionNoteAfterLine(artifactPath, note string, afterLine int) (original []byte, wrote bool, err error)
- func AppendResolutionNoteAfterLineBytes(orig []byte, note string, afterLine int) ([]byte, bool, error)
- func AppendResolutionNoteBytes(orig []byte, note string) ([]byte, bool, error)
- func ClearParked(artifactPath string) (original []byte, wrote bool, err error)
- func CommittedError(path, phase string, err error) error
- func FenceCloses(line string, marker byte, minimumLength int) bool
- func GuardReason(set ReasonRequiredSet, from, to Status, note string) error
- func HTMLCommentContinues(fragment string) bool
- func IsFrontmatterFence(line string) bool
- func IsIndentedCode(line string) bool
- func IsLeadingFrontmatterFence(line string) bool
- func IsParked(artifactPath string) (bool, error)
- func IsPlanDisposition(s Status) bool
- func RenderBidirectionalMatrix(kind Kind) string
- func RenderEdges(label string, edges []StatusEdge) string
- func RestoreBody(artifactPath string, original []byte) error
- func Rewrite(artifactPath string, newStatus Status) (string, error)
- func RewriteBytes(original []byte, newStatus Status) ([]byte, string, error)
- func Rollback(artifactPath string, originalStatusLine string) error
- func RollbackBytes(current []byte, originalStatusLine string) ([]byte, error)
- func SetParked(artifactPath, reason string) (original []byte, wrote bool, err error)
- func SetSupersededBy(artifactPath, successor string) (original []byte, wrote bool, err error)
- func SetSupersededByBytes(orig []byte, successor string) ([]byte, bool, error)
- func SetSupersedes(artifactPath, target string) (original []byte, wrote bool, err error)
- func SetSupersedesBytes(original []byte, target string) ([]byte, bool, error)
- func StructuralMarkdownMask(lines []string, retainedComment string) []bool
- func TransformArtifact(path string, transform func(before []byte) (after []byte, err error)) error
- func Transition(kind Kind, from Status, to Status) error
- func VerifyPersistedStatus(artifactPath string, want Status) error
- func WithArtifactTransaction(path string, fn func(*ArtifactTransaction) error) error
- type ArtifactTransaction
- type CommittedMutationError
- type InvalidTransitionError
- type Kind
- type ParkedInfo
- type ReasonRequiredError
- type ReasonRequiredSet
- type ReasonRequiredTransition
- type RecoveryRequiredError
- type Status
- func CurrentStatus(kind Kind, artifactPath string) (Status, error)
- func LegalSources(kind Kind, to Status) []Status
- func LegalStatuses(kind Kind) []Status
- func LegalTargets(kind Kind, from Status) []Status
- func ParseStatus(kind Kind, raw string) (Status, bool)
- func PersistedStatus(artifactPath string) (body Status, frontmatter string, hasFrontmatter bool, err error)
- func StatusFromBytes(original []byte) (Status, error)
- func Validate(kind Kind, artifactPath string, to Status) (Status, error)
- type StatusEdge
- type StatusNotPersistedError
Constants ¶
This section is empty.
Variables ¶
var ErrConcurrentMutation = errors.New("lifecycle: artifact changed before amendment write")
ErrConcurrentMutation means the artifact changed after its caller read the exact bytes it intended to amend. Callers must re-read and make a fresh, explicit decision rather than overwrite the other writer's record.
var ErrInvalidTransition = errors.New("invalid lifecycle transition")
ErrInvalidTransition is returned by Transition (and Validate) when the requested (from, to) pair is not present in the kind's matrix.
The error carries the kind, source status, target status, and the legal target set from the current source, so the CLI layer can render a user-friendly message without re-querying the matrix.
var ErrNotParked = errors.New("lifecycle: artifact is not parked")
ErrNotParked is returned by ClearParked when the artifact carries no `**Parked:** true` axis to clear.
var ErrParkReasonRequired = errors.New("lifecycle: park requires a non-empty reason")
ErrParkReasonRequired is returned by SetParked when reason is empty or whitespace-only. A bare `park` with no explanation is rejected BEFORE any mutation — this is the load-bearing guard against the parked axis rotting into an unaudited graveyard.
var ErrStatusLineNotFound = errors.New("lifecycle: artifact has no **Status:** line")
ErrStatusLineNotFound is returned by Validate/Rewrite when the artifact does not contain a recognizable `**Status:**` line.
Functions ¶
func AppendResolutionNote ¶ added in v0.11.0
AppendResolutionNote writes the supplied markdown into the artifact body as a `## Resolution` section, implementing lifecycle-transitions#REQ:optional-transition-note.
Semantics:
- An empty or whitespace-only note is treated as absent: the file is left untouched, wrote is false, and original is nil.
- If a `## Resolution` H2 section already exists, the note is appended as a new trailing paragraph within it (the section is never relocated).
- If absent, the section is created immediately before the artifact footer line (`*This document follows the …*`) when one is present, else at EOF.
The markdown is written verbatim except for trailing-newline normalization; it is never reflowed, wrapped, truncated, or sanitized.
This wrapper is retained for historical compensating callers. On a write, original holds its exact pre-invocation bytes, but that snapshot is not post-commit rollback authority. Transaction-profile Task/Plan writers call AppendResolutionNoteBytes inside their single artifact transaction.
func AppendResolutionNoteAfterLine ¶ added in v0.37.0
func AppendResolutionNoteAfterLine(artifactPath, note string, afterLine int) (original []byte, wrote bool, err error)
AppendResolutionNoteAfterLine writes note in the artifact body after the 1-based afterLine anchor. Resolution-heading and footer discovery are both limited to that body scope, so a Markdown example before a canonical title or header cannot receive or suppress a real audit note. Pass 0 when the whole document is the body; AppendResolutionNote does so for artifact kinds that do not provide a structural body anchor.
The empty-note, verbatim-write, original-byte, and rollback semantics are identical to AppendResolutionNote. An anchor beyond the end of the file is rejected without mutating it.
func AppendResolutionNoteAfterLineBytes ¶ added in v0.37.0
func AppendResolutionNoteAfterLineBytes(orig []byte, note string, afterLine int) ([]byte, bool, error)
AppendResolutionNoteAfterLineBytes is the snapshot-pure, body-scoped form used by compound artifact transactions. afterLine is a 1-based anchor.
func AppendResolutionNoteBytes ¶ added in v0.35.0
AppendResolutionNoteBytes appends a resolution paragraph in memory.
func ClearParked ¶ added in v0.36.0
ClearParked reverses SetParked: it removes the `**Parked:**`, `**Parked Reason:**`, and `**Parked Date:**` header lines entirely — an absent flag means not parked, so unparking restores nothing (nothing was taken away; **Status:** was never touched).
If the artifact carries no `**Parked:** true` line, ClearParked returns ErrNotParked and leaves the file untouched — there is nothing to undo, which is more useful to the caller than a silent no-op (it catches a mistyped slug the caller believed was already parked).
On a write, original holds the exact pre-invocation file bytes, for the same rollback contract as SetParked.
func CommittedError ¶ added in v0.35.0
CommittedError constructs a typed recovery-required error for work that failed only after an artifact transaction became visible.
func FenceCloses ¶ added in v0.37.0
func GuardReason ¶ added in v0.11.0
func GuardReason(set ReasonRequiredSet, from, to Status, note string) error
GuardReason is the pre-mutation guard for reason-required transitions. If (from, to) is designated reason-required in set and note is empty or whitespace-only, it returns a *ReasonRequiredError naming the transition. Otherwise it returns nil.
Callers MUST invoke GuardReason BEFORE any artifact mutation, so a designated transition with a missing reason fails without touching the file (REQ: reason-required-transitions). Non-designated transitions and an empty set always return nil here, leaving `--note` optional.
func HTMLCommentContinues ¶ added in v0.37.0
func IsFrontmatterFence ¶ added in v0.37.0
func IsIndentedCode ¶ added in v0.37.0
func IsLeadingFrontmatterFence ¶ added in v0.37.0
func IsParked ¶ added in v0.36.0
IsParked is a convenience wrapper over ReadParked for callers that only need the boolean (e.g. a `--parked` list filter).
func IsPlanDisposition ¶ added in v0.32.1
IsPlanDisposition reports whether a Plan status is one of the four terminal dispositions — the states a Plan is retired INTO, and from which no work resumes.
Implemented is deliberately absent: it is the successful end of execution and still the live account of what was built, so a caller that treats a retired Plan as frozen history must not treat Implemented that way. Both `plan reconcile` (which refuses to resurrect a disposition) and the P-001/P-002 lint rules (which stop validating a retired Plan against a Feature that has since moved on) need exactly this set, so it is declared once here instead of being spelled out at each call site.
func RenderBidirectionalMatrix ¶ added in v0.36.0
RenderBidirectionalMatrix formats BidirectionalMatrix(kind) as ANSI-free text: every recognized status for kind, each with its previous/next legal-transition lists, with initial-only and terminal statuses spelled out rather than left to look like an omission.
Status vocabulary for feature (8 statuses):
Amending
previous: Approved, Stable
next: Approved, Stable
Approved
previous: Draft, In Review
next: Amending, Deprecated, Implementing, Rejected
...
func RenderEdges ¶ added in v0.36.0
func RenderEdges(label string, edges []StatusEdge) string
RenderEdges formats a []StatusEdge as the same ANSI-free text RenderBidirectionalMatrix produces, labeled with label instead of a lifecycle.Kind. It exists so a kind with its OWN small matrix outside pkg/lifecycle (e.g. pkg/issue, pkg/sidekick — neither is a registered Kind here) can still render in the identical shape, without duplicating this formatting logic.
func RestoreBody ¶ added in v0.11.0
RestoreBody is the explicit legacy whole-body compensating writer. It is not part of the Task/Plan transaction profile and MUST NOT be used after their artifact commit or post-mutation callback failure.
func Rewrite ¶
Rewrite mutates the artifact's `**Status:**` line in place, replacing only the value text. Every other byte of the file (line ordering, indentation, line endings, trailing whitespace) is preserved (REQ: status-line-rewrite).
Rewrite is retained for historical single-field callers. The returned string is the original line content for their explicitly documented legacy compensation path. Transaction-profile Task/Plan writers use RewriteBytes inside one TransformArtifact callback and never perform a late rollback.
If the file has no `**Status:**` line, Rewrite returns ErrStatusLineNotFound and the file is left untouched.
func RewriteBytes ¶ added in v0.35.0
RewriteBytes rewrites Status (and its frontmatter mirror) in memory. It is the pure transform used by compound artifact transactions.
func Rollback ¶
Rollback is the explicit legacy compensating status-line writer. It is not part of the Task/Plan transaction profile and MUST NOT be used after their artifact commit or post-mutation callback failure.
Rollback locates the file's current `**Status:**` line (which is now the MUTATED value), replaces that single line with originalStatusLine, and writes the file back. After Rollback returns nil, the file content is byte-identical to its pre-Rewrite state.
If the file has been mutated externally between Rewrite and Rollback such that no `**Status:**` line remains, Rollback returns ErrStatusLineNotFound. It does not prove ownership of unrelated current bytes; callers that have migrated to ArtifactTransaction must retain committed state instead.
func RollbackBytes ¶ added in v0.35.0
RollbackBytes is the pure in-memory legacy status-line restoration transform.
func SetParked ¶ added in v0.36.0
SetParked marks artifactPath as parked: it writes (or rewrites, if already present) a contiguous three-line block —
**Parked:** true **Parked Reason:** <reason> **Parked Date:** <today, UTC, YYYY-MM-DD>
— immediately after the `**Status:**` line, WITHOUT touching **Status:** itself or any other byte of the file. Re-parking an already-parked artifact overwrites the reason and resets the date to today — the same "rewrite the value in place" semantics SetSupersededBy/SetSupersedes use for their structured fields — so confirming "still deliberately deferred" is just re-running `park` with a fresh --reason.
reason is REQUIRED: an empty or whitespace-only value returns ErrParkReasonRequired and leaves the file untouched (no partial mutation). A file with no recognizable `**Status:**` line returns ErrStatusLineNotFound, also untouched.
On a write, original holds the exact pre-invocation file bytes so the caller can roll back via RestoreBody, matching every other structured header-field writer in this package (SetSupersededBy, SetSupersedes, AppendResolutionNote).
func SetSupersededBy ¶ added in v0.13.0
SetSupersededBy writes a `**Superseded By:** <successor>` reference into the canonical lifecycle-artifact header, mirroring the Decision convention. It is retained as a legacy single-field wrapper. Transaction-profile Task/Plan writers call SetSupersededByBytes inside their one artifact transaction and do not stitch this wrapper into a multi-write sequence.
Semantics:
- An empty or whitespace-only successor is treated as absent: the file is left untouched, wrote is false, and original is nil.
- Only fields in the canonical header after the first structural Decision, Plan, or Lesson title and before its first structural H2 participate.
- If a `**Superseded By:**` line already exists there, its value is rewritten in place (indentation and trailing whitespace preserved).
- Otherwise the line is inserted immediately after the `**Supersedes:**` header line when present, else immediately after the `**Status:**` line. A file with neither anchor returns ErrStatusLineNotFound and is left untouched.
On a write, original holds the exact pre-invocation bytes for historical compensating callers only; it is not a post-commit rollback authority.
func SetSupersededByBytes ¶ added in v0.35.0
SetSupersededByBytes applies the successor header transform in memory.
func SetSupersedes ¶ added in v0.32.0
SetSupersedes writes a `**Supersedes:** <target>` reference into the artifact's header block — the other half of the bidirectional link completed by SetSupersededBy on the target artifact. It is the Decision-kind counterpart: `decision change-status --to=superseded` calls SetSupersededBy on the OLD decision and SetSupersedes on the NEW (successor) decision in the same atomic transition, per D-supersedes-bidirectional.
Semantics mirror SetSupersededBy exactly:
- An empty or whitespace-only target is treated as absent: the file is left untouched, wrote is false, and original is nil.
- If a `**Supersedes:**` line already exists, its value is rewritten in place (indentation and trailing whitespace preserved). This is the common case for a Decision, whose scaffold always emits the field (defaulted to `—`).
- Otherwise the line is inserted immediately after the `**Status:**` header line. A file with no `**Status:**` line returns ErrStatusLineNotFound and is left untouched.
On a write, original holds the exact pre-invocation file bytes so the caller can roll back via RestoreBody as part of the surrounding atomic transition.
func SetSupersedesBytes ¶ added in v0.35.0
SetSupersedesBytes applies the predecessor header transform in memory.
func StructuralMarkdownMask ¶ added in v0.37.0
StructuralMarkdownMask classifies the source lines that are eligible to carry lifecycle-controlled Markdown structure. The Plan parser calls this same scanner, so lifecycle writers and Plan readers agree that frontmatter, fenced or indented code samples, and HTML comments are not real headings, fields, or footers. retainedComment is the one byte-exact HTML comment that a caller intentionally gives structural meaning (the Plan stub marker); it is empty for ordinary lifecycle artifacts.
It intentionally implements only the small Markdown subset required for structural safety rather than trying to render Markdown.
func TransformArtifact ¶ added in v0.35.0
TransformArtifact runs one complete existing-artifact transaction: acquire the per-artifact lifecycle fence, read the exact current bytes, resolve and validate against those bytes in transform, then atomically replace them when transform returns changed bytes. Transform must be pure: it must not call a public lifecycle writer (which would acquire the same non-reentrant lock).
func Transition ¶
Transition validates that (from, to) is a legal transition in kind's matrix. It returns nil on success and a wrapped ErrInvalidTransition on failure. The wrapped error carries the legal target set from the current source so callers can render a useful message.
Transition does NOT touch the filesystem; it is pure matrix lookup.
func VerifyPersistedStatus ¶ added in v0.37.3
VerifyPersistedStatus confirms that artifactPath actually carries want on BOTH status surfaces — the body `**Status:**` line and, when present, the frontmatter `status:` mirror.
It is the last step of a lifecycle transition, run after the post-mutation hook returns success, and it is what stands between a verb and the self-concealing failure described on StatusNotPersistedError. A verb MUST call it before printing its success line: the hook returning nil only proves the derived-index pass did not ERROR, never that it left the requested status in place.
It returns *StatusNotPersistedError on disagreement, or the underlying os/parse error when the artifact cannot be read.
func WithArtifactTransaction ¶ added in v0.35.0
func WithArtifactTransaction(path string, fn func(*ArtifactTransaction) error) error
WithArtifactTransaction locks path, reads its exact bytes, and invokes fn. It never waits: contention is immediately ErrConcurrentMutation. The callback may perform pre-publication work for a classified new-artifact publisher, then commit the board/index bytes through tx.Commit; ordinary writers should use TransformArtifact's pure callback.
Types ¶
type ArtifactTransaction ¶ added in v0.35.0
type ArtifactTransaction struct {
// contains filtered or unexported fields
}
ArtifactTransaction is the one existing-artifact mutation boundary. Before is read only after the path lock is acquired. Commit may be called at most once and performs the final expected-byte check, atomic replacement, and directory durability fence without reacquiring the lock.
func (*ArtifactTransaction) Before ¶ added in v0.35.0
func (t *ArtifactTransaction) Before() []byte
func (*ArtifactTransaction) Commit ¶ added in v0.35.0
func (t *ArtifactTransaction) Commit(after []byte) error
type CommittedMutationError ¶ added in v0.35.0
CommittedMutationError reports that the canonical artifact bytes are already visible and must be retained even though later durable-fence or derived work failed. Callers must surface recovery-required state; they must never roll the artifact back from a stale snapshot.
func (*CommittedMutationError) Error ¶ added in v0.35.0
func (e *CommittedMutationError) Error() string
func (*CommittedMutationError) Unwrap ¶ added in v0.35.0
func (e *CommittedMutationError) Unwrap() error
type InvalidTransitionError ¶
InvalidTransitionError is a typed error carrying the context of a rejected transition. It wraps ErrInvalidTransition, so callers can use errors.Is to detect this category.
func (*InvalidTransitionError) Error ¶
func (e *InvalidTransitionError) Error() string
Error implements the error interface. The message is human-readable and names both endpoints plus the legal target set from the current source.
func (*InvalidTransitionError) Unwrap ¶
func (e *InvalidTransitionError) Unwrap() error
Unwrap exposes ErrInvalidTransition so errors.Is(err, ErrInvalidTransition) returns true.
type Kind ¶
type Kind string
Kind names a doc kind that participates in the lifecycle state machine.
type ParkedInfo ¶ added in v0.36.0
type ParkedInfo struct {
Parked bool
Reason string
Date string // YYYY-MM-DD, or "" if absent/unparseable
}
ParkedInfo is the parsed parked axis of an artifact.
func ReadParked ¶ added in v0.36.0
func ReadParked(artifactPath string) (ParkedInfo, error)
ReadParked scans artifactPath for the `**Parked:**` / `**Parked Reason:**` / `**Parked Date:**` header lines and returns their parsed values. A file with no `**Parked:**` line (or `**Parked:** false`/anything other than `true`) reports Parked: false — absence means not parked, mirroring Idea's `**Archived:**` convention.
type ReasonRequiredError ¶ added in v0.11.0
ReasonRequiredError is returned by GuardReason when a designated reason-required transition is attempted without a non-empty note. It carries the (from, to) transition so the consuming verb can render a message and map it to exit code 2 (InvalidArgs).
func (*ReasonRequiredError) Error ¶ added in v0.11.0
func (e *ReasonRequiredError) Error() string
Error implements the error interface. The message names the transition and states that a reason is required, satisfying the contract's stderr message requirement.
type ReasonRequiredSet ¶ added in v0.11.0
type ReasonRequiredSet struct {
// contains filtered or unexported fields
}
ReasonRequiredSet is the set of transitions a verb designates as reason-required. The zero value is a valid empty set (designates nothing).
func NewReasonRequiredSet ¶ added in v0.11.0
func NewReasonRequiredSet(transitions ...ReasonRequiredTransition) ReasonRequiredSet
NewReasonRequiredSet builds a ReasonRequiredSet from the given transitions. Passing no transitions yields an empty set, equivalent to the zero value.
func (ReasonRequiredSet) RequiresReason ¶ added in v0.11.0
func (s ReasonRequiredSet) RequiresReason(from, to Status) bool
RequiresReason reports whether the (from, to) transition is designated reason-required in this set.
type ReasonRequiredTransition ¶ added in v0.11.0
ReasonRequiredTransition names a single (from, to) arc a verb designates as reason-required.
type RecoveryRequiredError ¶ added in v0.37.2
RecoveryRequiredError reports a fail-closed pre-publication state. The requested postimage was not published, but a durable preimage receipt was retained because a non-cooperating writer or filesystem fault prevented a safe restoration. Callers must preserve both paths for explicit recovery.
func (*RecoveryRequiredError) Error ¶ added in v0.37.2
func (e *RecoveryRequiredError) Error() string
func (*RecoveryRequiredError) Unwrap ¶ added in v0.37.2
func (e *RecoveryRequiredError) Unwrap() error
type Status ¶
type Status string
Status is a domain-scoped status value. The set of legal Status values is per-Kind and validated by the kind's transition table; callers SHOULD use ParseStatus to obtain a canonical Status from a raw flag string.
const ( IdeaDraft Status = "Draft" IdeaInReview Status = "In Review" IdeaApproved Status = "Approved" IdeaSpecifying Status = "Specifying" IdeaSpecified Status = "Specified" IdeaImplementing Status = "Implementing" IdeaImplemented Status = "Implemented" IdeaRejected Status = "Rejected" IdeaStale Status = "Stale" )
Idea statuses.
const ( FeatureDraft Status = "Draft" FeatureInReview Status = "In Review" FeatureApproved Status = "Approved" FeatureImplementing Status = "Implementing" FeatureStable Status = "Stable" FeatureAmending Status = "Amending" FeatureRejected Status = "Rejected" FeatureDeprecated Status = "Deprecated" // FeaturePlanned is a legacy, pre-vocabulary Feature status found in the // wild (e.g. datatug/datatug-cli's spec/features/cli/version/README.md) // that predates this Feature status vocabulary. It is recognized ONLY as a // legal FROM-state — symmetric to Draft, the other status with no legal // predecessor — so an artifact stuck at Planned has a forward exit through // change-status. It is deliberately NOT a legal `--to` target (no arc goes // INTO Planned, mirroring the existing Draft guard) and NOT in // pkg/feature/template.go's ValidStatuses (feature new must not scaffold // new Features into this legacy value). FeaturePlanned Status = "Planned" )
Feature statuses.
const ( PlanDraft Status = "Draft" PlanInReview Status = "In Review" PlanApproved Status = "Approved" PlanExecuting Status = "Executing" PlanBlocked Status = "Blocked" PlanImplemented Status = "Implemented" PlanFailed Status = "Failed" PlanRejected Status = "Rejected" PlanWithdrawn Status = "Withdrawn" PlanSuperseded Status = "Superseded" PlanDeprecated Status = "Deprecated" )
Plan statuses. The status models a plan's full lifecycle in three bands (prep / execution / disposition); see spec/features/plan/README.md. Only the prep band and the dispositions are human-authored — `plan change-status` owns those arcs. The execution band (Executing/Blocked/Implemented/Failed) is LINT-DERIVED from the task-status rollup (rule P-007) and MUST NOT be settable via change-status; those values appear in this matrix only as From-states for dispositions.
const ( TaskPlanning Status = "planning" TaskQueued Status = "queued" TaskInProgress Status = "in_progress" TaskBlocked Status = "blocked" TaskComplete Status = "complete" TaskFailed Status = "failed" TaskAborted Status = "aborted" )
Task statuses. A task moves through seven lifecycle states; the legal arcs are declared in the KindTask matrix below. The terminal states (Complete, Failed, Aborted) have no outgoing arcs. Values are lowercase to match the on-disk task-file **Status:** convention.
const ( LessonRecorded Status = "Recorded" LessonStated Status = "Stated" LessonEnforced Status = "Enforced" LessonWithdrawn Status = "Withdrawn" LessonSuperseded Status = "Superseded" )
Lesson statuses. A Lesson climbs the enforcement ladder — Recorded (Tier 0, written down), Stated (Tier 1, loaded by an agent before acting), Enforced (Tier 2, a machine refuses) — and may reach one of two terminal dispositions at any rung: Withdrawn (turned out wrong / not applicable) or Superseded (replaced by a newer lesson). The vocabulary deliberately mirrors the Plan disposition set rather than inventing new terms.
const ( DecisionDraft Status = "Draft" DecisionInReview Status = "In Review" DecisionApproved Status = "Approved" DecisionRejected Status = "Rejected" DecisionSuperseded Status = "Superseded" DecisionDeprecated Status = "Deprecated" )
Decision statuses. The vocabulary is closed by pkg/lint's D-status-values rule (decisionValidStatuses in pkg/lint/decision_rules.go) — these six values are the ONLY recognized **Status:** tokens for a Decision artifact; this const block mirrors that closed set rather than inventing one.
The three pre-terminal statuses (Draft, In Review, Approved) form the prep band, mirroring Plan's shape. The three dispositions (Rejected, Superseded, Deprecated) are terminal: D-archived-location requires every Decision carrying one of them to live under spec/decisions/archived/, which `decision change-status` enforces by relocating the file as part of the same atomic transition (see pkg/decision.ChangeStatus).
func CurrentStatus ¶ added in v0.36.0
CurrentStatus reads artifactPath's current Status without validating any transition — the read-only counterpart to Validate, used by query verbs (`<kind> transitions <slug>`) that report state without a --to target.
func LegalSources ¶
LegalSources is the inverse of LegalTargets: which from-states can transition INTO to. Used by error-message construction when target is valid as a status name but invalid for the current source state.
func LegalStatuses ¶
LegalStatuses returns every recognized status for a kind (the union of every From and To in its matrix), sorted alphabetically. Used by the CLI layer to validate a --to flag value before invoking the state-machine check.
func LegalTargets ¶
LegalTargets returns the legal target statuses reachable from (kind, from), sorted alphabetically. The empty slice is returned (never nil for an unknown kind, but never nil for a known kind either) when from is not a legal source state in the kind's matrix.
func ParseStatus ¶
ParseStatus does case-insensitive parsing of a raw flag-string against the kind's recognized statuses, returning the canonical title-cased Status on success.
Whitespace is trimmed; case is folded (so "draft", "Draft", "DRAFT", and " Draft " all match). Multi-word statuses ("Under Review") match case-insensitively but the internal-whitespace shape MUST match (i.e., "underreview" without a space does NOT match "Under Review"). This is the least-surprising behavior for a CLI flag.
func PersistedStatus ¶ added in v0.37.3
func PersistedStatus(artifactPath string) (body Status, frontmatter string, hasFrontmatter bool, err error)
PersistedStatus reads artifactPath and returns the two status surfaces that MUST agree after a transition: the body `**Status:**` value and the YAML frontmatter `status:` mirror. hasFrontmatter is false (and frontmatter is "") for artifacts that carry no frontmatter mirror — those are unaffected by the mirror half of the check.
func StatusFromBytes ¶ added in v0.35.0
StatusFromBytes returns the body Status parsed from bytes held by a compound transaction. It never reads from disk.
func Validate ¶
Validate is a legacy convenience that reads artifactPath, extracts its current Status, and checks that the transition is legal. It does not mutate. Transaction-profile writers MUST instead parse and call Transition from the exact bytes supplied by TransformArtifact; stitching Validate and Rewrite together would validate outside the artifact lock.
It returns the from status on success. On failure it returns one of:
- an os error if the file cannot be opened or read
- ErrStatusLineNotFound if the file has no recognizable **Status:** line
- an *InvalidTransitionError if the transition is illegal in kind's matrix
type StatusEdge ¶ added in v0.36.0
type StatusEdge struct {
Status Status `json:"status" yaml:"status"`
Previous []Status `json:"previous" yaml:"previous"`
Next []Status `json:"next" yaml:"next"`
}
StatusEdge is one status's place in a kind's transition matrix: which statuses can legally transition INTO it (Previous) and which it can legally transition TO (Next). Both are derived from the SAME transitionMatrix rows Transition/LegalTargets/LegalSources already consult — there is no second, hand-authored table to drift out of sync with the forward-only matrix.
An empty Previous means the status is only reachable by initial creation (e.g. Feature's Draft, written by `feature new`, never by change-status). An empty Next means the status is terminal — no change-status verb can move an artifact away from it.
func BidirectionalMatrix ¶ added in v0.36.0
func BidirectionalMatrix(kind Kind) []StatusEdge
BidirectionalMatrix returns a StatusEdge for every status LegalStatuses recognizes for kind, sorted alphabetically by Status. This is the complete status vocabulary for the kind — including statuses that never appear as a From (initial-only) or never appear as a To (terminal) in the forward-only matrix — so a reader never has to infer "is this terminal, or just missing from the table?" by hand.
func EdgeFor ¶ added in v0.36.0
func EdgeFor(kind Kind, status Status) StatusEdge
EdgeFor returns the StatusEdge for a single (kind, status) pair. It is the primitive `<kind> transitions <slug>` uses to report what a specific artifact — already read at its current status — can legally become next.
func (StatusEdge) IsInitialOnly ¶ added in v0.36.0
func (e StatusEdge) IsInitialOnly() bool
IsInitialOnly reports whether Status has no legal predecessor — it is reachable only by the kind's `new`/scaffold verb, never by change-status.
func (StatusEdge) IsTerminal ¶ added in v0.36.0
func (e StatusEdge) IsTerminal() bool
IsTerminal reports whether Status has no legal successor — no change-status verb can move an artifact away from it.
type StatusNotPersistedError ¶ added in v0.37.3
type StatusNotPersistedError struct {
// Path is the artifact whose status did not stick.
Path string
// Want is the status the transition requested.
Want Status
// GotBody is the body `**Status:**` value actually on disk.
GotBody Status
// GotFrontmatter is the YAML frontmatter `status:` mirror actually on
// disk, or "" when the artifact carries no frontmatter mirror.
GotFrontmatter string
// HasFrontmatter reports whether a frontmatter `status:` mirror exists.
HasFrontmatter bool
}
StatusNotPersistedError reports that an artifact does NOT carry the status a transition claimed to write. It is raised by VerifyPersistedStatus after the post-mutation hook (`spec lint --fix` + verify) has run, so it catches the case the plain write path cannot see: the rewrite itself succeeded, and then derived-index work rewrote the same line back to a different value.
Without this check a verb prints its success line — `<slug>: <from> → <to>` — and exits 0 while the file on disk is byte-identical to its pre-invocation state. That failure is self-concealing: the success line even quotes the correct from → to pair. Callers MUST treat this error as a failed transition, restore the pre-invocation state, and exit non-zero.
func (*StatusNotPersistedError) Error ¶ added in v0.37.3
func (e *StatusNotPersistedError) Error() string
Error implements the error interface. The message names the requested status and every surface that disagrees with it, so the operator can tell a re-derived status apart from a half-written one (body advanced, mirror stale).