lifecycle

package
v0.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 18, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

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).
  • REQ: rollback-on-lint-failure — Rollback restores the original **Status:** line byte-for-byte.

Index

Constants

This section is empty.

Variables

View Source
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.

View Source
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

func AppendResolutionNote(artifactPath, note string) (original []byte, wrote bool, err error)

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.

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 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 RestoreBody added in v0.11.0

func RestoreBody(artifactPath string, original []byte) error

RestoreBody rewrites the artifact with original (the bytes returned by a prior AppendResolutionNote call), restoring it byte-for-byte. It is the body-mutation counterpart to lifecycle.Rollback and participates in the same rollback path.

func Rewrite

func Rewrite(artifactPath string, newStatus Status) (string, error)

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

The returned string is the ORIGINAL line content (including any line terminator that was attached to it), suitable for passing to Rollback to undo the mutation. The caller is responsible for retaining this value until index sync is confirmed successful.

If the file has no `**Status:**` line, Rewrite returns ErrStatusLineNotFound and the file is left untouched.

func Rollback

func Rollback(artifactPath string, originalStatusLine string) error

Rollback restores the artifact's `**Status:**` line to its pre-Rewrite content, identified by the originalStatusLine returned from the prior Rewrite call.

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. Concurrent modification is outside the contract (REQ: no-coordination in the lifecycle-transitions Meta spec).

func SetSupersededBy added in v0.13.0

func SetSupersededBy(artifactPath, successor string) (original []byte, wrote bool, err error)

SetSupersededBy writes a `**Superseded By:** <successor>` reference into the artifact's header block, mirroring the Decision "Superseded By" convention. It is the structured counterpart to the `--note` text and participates in the same atomic-transition + rollback path as AppendResolutionNote.

Semantics:

  • An empty or whitespace-only successor is treated as absent: the file is left untouched, wrote is false, and original is nil.
  • If a `**Superseded By:**` line already exists, 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 file bytes so the caller can roll back via RestoreBody as part of the surrounding atomic transition.

func Transition

func Transition(kind Kind, from Status, to Status) error

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.

Types

type InvalidTransitionError

type InvalidTransitionError struct {
	Kind         Kind
	From         Status
	To           Status
	LegalTargets []Status
}

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.

const (
	KindIdea    Kind = "idea"
	KindFeature Kind = "feature"
	KindPlan    Kind = "plan"
)

type ReasonRequiredError added in v0.11.0

type ReasonRequiredError struct {
	From Status
	To   Status
}

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

type ReasonRequiredTransition struct {
	From Status
	To   Status
}

ReasonRequiredTransition names a single (from, to) arc a verb designates as reason-required.

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

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.

func LegalSources

func LegalSources(kind Kind, to Status) []Status

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

func LegalStatuses(kind Kind) []Status

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

func LegalTargets(kind Kind, from Status) []Status

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

func ParseStatus(kind Kind, raw string) (Status, bool)

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 Validate

func Validate(kind Kind, artifactPath string, to Status) (Status, error)

Validate reads artifactPath, extracts its current Status, and checks that the (kind, current, to) transition is legal. It does NOT mutate the file.

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

Validate is the primitive that the CLI verb runs FIRST, before any mutation; it is the single check that guarantees REQ: state-machine-strictness.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL