Documentation
¶
Overview ¶
Package plan parses single-file Plan artifacts at spec/plans/<slug>.md per the SpecStudio plan-Feature contract (https://github.com/specscore/specstudio-skills/blob/main/spec/features/skills/plan/README.md).
The directory-form plans at spec/plans/<slug>/README.md historically used by specscore-cli are out of scope for this package — they are parsed by the existing plan-hierarchy / plan-roi-metadata lint checkers.
Package plan — lifecycle transition orchestration for the Plan kind.
This file hosts ChangeStatus, the kind-specific orchestrator invoked by `specscore plan change-status`. It composes pkg/lifecycle/ primitives (state-machine validation, status-line rewrite, rollback) and adds the Plan-specific structured `**Superseded By:**` successor reference.
`plan change-status` owns ONLY the human-authored arcs: the prep band (Draft/In Review/Approved) plus the dispositions (Rejected/Withdrawn/ Superseded/Deprecated). The execution band (Executing/Blocked/Implemented/ Failed) is LINT-DERIVED from the task-status rollup (rule P-007) and MUST NOT be settable here — the lifecycle KindPlan matrix encodes that by omitting every execution-band-entering arc. Plans are flat single files (or the optional directory form); change-status NEVER relocates a file.
LINT INVOCATION lives in the cobra adapter (internal/cli/plan.go), NOT here, to avoid an import cycle: pkg/lint imports pkg/plan for the plan-* lint rules, so pkg/plan cannot depend back on pkg/lint. The adapter passes a PostMutationHook callback into ChangeStatus; this package only knows "run the post-mutation hook, and roll back if it fails".
Cross-references:
- Verb spec: spec/features/cli/plan/change-status/README.md
- Meta contract: spec/features/cli/lifecycle-transitions/README.md
- Canonical lifecycle: spec/features/plan/README.md#req-status-transitions
Index ¶
- Constants
- func IsExecutionBandStatus(s lifecycle.Status) bool
- func IsLegalChangeStatusTarget(s lifecycle.Status) bool
- func IsSingleFilePlanPath(plansDir, filePath string) bool
- func LegalChangeStatusTargetNames() []string
- func LegalTransitionMatrix() string
- func Scaffold(opts ScaffoldOptions) ([]byte, error)
- func ValidateSlug(slug string) error
- type ChangeStatusOptions
- type ChangeStatusResult
- type DeferredAC
- type Mode
- type Plan
- type PostMutationHook
- type Rollup
- type ScaffoldOptions
- type Task
- type TaskStatus
Constants ¶
const FormatURL = "https://specscore.md/plan-specification"
FormatURL is the canonical spec URL for the Plan document type. It is carried verbatim in both the frontmatter `format:` field and the adherence-footer line, per the artifact-frontmatter-convention.
const PlaceholderBodyToken = "<!-- implement: pending -->"
PlaceholderBodyToken is the byte-exact marker the parser recognizes as a placeholder task body in `**Mode:** stub` Plans. The MVP working decision (see Open Questions in the plan-rules Feature) is an HTML comment so the marker is invisible in rendered markdown.
Variables ¶
This section is empty.
Functions ¶
func IsExecutionBandStatus ¶ added in v0.13.0
IsExecutionBandStatus reports whether s is one of the lint-derived execution-band statuses. The cobra adapter rejects these as --to values with a dedicated message that points the user at `spec lint --fix`.
func IsLegalChangeStatusTarget ¶ added in v0.13.0
IsLegalChangeStatusTarget reports whether status is one of the human- settable --to values for `specscore plan change-status`. This is the union of every To column in the KindPlan matrix MINUS the execution-band values (which appear in the matrix only as disposition From-states, never as a human-settable target). The cobra adapter uses this for the exit-2 early flag check BEFORE the state-machine check.
func IsSingleFilePlanPath ¶
IsSingleFilePlanPath reports whether path looks like a single-file Plan candidate location — i.e., directly under spec/plans/, has a `.md` extension, and is not named README.md (which is the index file).
It does NOT read the file; callers still must validate the title prefix via Parse() before treating it as a Plan.
func LegalChangeStatusTargetNames ¶ added in v0.13.0
func LegalChangeStatusTargetNames() []string
LegalChangeStatusTargetNames returns the canonical-titled names of the legal --to values, for stderr rendering and help text.
func LegalTransitionMatrix ¶ added in v0.13.0
func LegalTransitionMatrix() string
LegalTransitionMatrix returns a human-readable, ANSI-free rendering of the Plan legal-transition matrix, suitable for cobra `Long` help text. Built from lifecycle.LegalTargets so the help stays current as the matrix grows.
func Scaffold ¶ added in v0.7.0
func Scaffold(opts ScaffoldOptions) ([]byte, error)
Scaffold returns a lint-clean flat Plan file body: the artifact-frontmatter-convention frontmatter (`format:` + `status:` mirroring the body `**Status:** Draft`), the `# Plan:` title, the body-metadata header, the four required sections with HTML-comment prompts, and the adherence footer whose URL agrees with `format:`.
func ValidateSlug ¶ added in v0.7.0
ValidateSlug returns nil when slug is a lowercase, hyphen-separated, URL-safe identifier with no `/` (cli/plan/new#req:slug-format).
Types ¶
type ChangeStatusOptions ¶ added in v0.13.0
type ChangeStatusOptions struct {
// SpecRoot is the project root that contains the `spec/` subtree
// (NOT the `spec/` directory itself). The Plan is resolved at
// SpecRoot/spec/plans/<slug>.md (flat) or .../spec/plans/<slug>/README.md
// (the optional directory form).
SpecRoot string
// Slug is the Plan slug, e.g. "user-auth". Caller is expected to have
// validated it via plan.ValidateSlug.
Slug string
// To is the canonical (title-case) target status. The cobra adapter parses
// the raw --to value via lifecycle.ParseStatus and rejects execution-band /
// unrecognized values BEFORE reaching this function.
To lifecycle.Status
// Note is the optional free-form markdown transition note. When non-empty
// it is written as a `## Resolution` section, atomically with the status
// rewrite. REQUIRED (enforced by the cobra adapter) for the Withdrawn and
// Superseded dispositions.
Note string
// Successor is the slug of the plan that supersedes this one. REQUIRED
// (enforced by the cobra adapter) for --to=Superseded, rejected otherwise.
// It is written as a `**Superseded By:** <slug>` header line.
Successor string
// PostMutation is the post-rewrite hook (typically a spec-lint pass).
// Required; ChangeStatus returns exit 10 if nil.
PostMutation PostMutationHook
}
ChangeStatusOptions packages the inputs to ChangeStatus.
type ChangeStatusResult ¶ added in v0.13.0
ChangeStatusResult is the success payload returned on exit 0. The cobra adapter formats it as the `<slug>: <from> → <to>` success line.
func ChangeStatus ¶ added in v0.13.0
func ChangeStatus(opts ChangeStatusOptions) (ChangeStatusResult, error)
ChangeStatus performs a Plan-kind lifecycle transition end-to-end.
Flow:
- Resolve <slug> to an existing Plan file (flat or directory form). A missing file returns exit 3.
- lifecycle.Validate against the KindPlan matrix. Illegal transitions return exit 4.
- lifecycle.Rewrite the **Status:** line; capture original for rollback.
- Optionally write the `**Superseded By:**` successor reference and the `## Resolution` note, each rolled back together with the status line.
- Invoke the PostMutation hook. Failure → full rollback + exit 10.
ChangeStatus performs all rollback internally — by the time it returns an error, the on-disk state is byte-identical to its pre-invocation shape.
type DeferredAC ¶
type DeferredAC struct {
ACID string // `<feature-slug>#ac:<ac-slug>`
Line int // 1-based line of the entry
Reason string // text after the em-dash; opaque to lint
}
DeferredAC is a single `- <feature-slug>#ac:<ac-slug> — <reason>` line.
type Plan ¶
type Plan struct {
Path string // absolute path on disk
Slug string // filename without `.md`
HasPlanTitle bool // first H1 line was `# Plan: <title>`
TitleLine int // 1-based line number of the title (0 when absent)
Title string // the `<title>` portion after `# Plan: `
SourceFeature string // value of `**Source Feature:**` (empty when missing)
SourceFeatureLine int // 1-based line of the field; 0 when absent
SourceIdea string // Idea slug from `**Source:** idea:<slug>` (empty when not idea-sourced)
SourceNone bool // true when the source line is `**Source:** none` (source-less plan)
SourceRaw string // raw value of a `**Source:**` line as written (empty when absent)
SourceLine int // 1-based line of the `**Source:**` field; 0 when absent
Status string // value of `**Status:**` (empty when missing)
StatusLine int // 1-based line of the field; 0 when absent
Date string // value of `**Date:**` (empty when missing)
DateLine int // 1-based line of the field; 0 when absent
Owner string // value of `**Owner:**` (empty when missing)
OwnerLine int // 1-based line of the field; 0 when absent
Parent string // value of `**Parent:**` (empty when missing) — master/sub-plan composition
ParentLine int // 1-based line of the field; 0 when absent
Mode Mode // `full` (default) or `stub`
ModeLine int // 1-based line of `**Mode:**`; 0 when absent
ModeRaw string // raw value as written (used by P-004 to report invalid tokens)
ModeRawPresent bool // true when the field was present at all
ModeValueValid bool // true when ModeRaw parsed cleanly into Mode
Tasks []Task // task blocks in source order
DeferredACs []DeferredAC // entries under `## Deferred AC Coverage`
DeferredACsLine int // 1-based line of the H2 heading; 0 when absent
}
Plan is a parsed single-file Plan artifact.
func Discover ¶ added in v0.7.0
Discover walks the direct children of plansDir and returns the parsed single-file Plans found there, sorted alphabetically by Slug.
It selects candidates via IsSingleFilePlanPath (which excludes README.md and anything not directly under plansDir), Parses each, and keeps only files whose first H1 was `# Plan: <title>` (HasPlanTitle == true). Directory-form plans at spec/plans/<slug>/README.md are out of scope and skipped.
An absent plansDir is not an error: Discover returns an empty slice and nil.
func Parse ¶
Parse reads a candidate Plan file. It returns a populated Plan even when the file is not actually a Plan (HasPlanTitle == false in that case) so callers can distinguish "not a Plan" from "malformed Plan".
func (*Plan) DeriveExecutionBand ¶ added in v0.13.0
DeriveExecutionBand computes the plan's execution-band status from the rollup of its task statuses, per the canonical plan#req:status-rollup precedence (Failed > Executing > Blocked > Implemented). It returns ("", false) when the rollup is INDETERMINATE — there are no tasks, or at least one task is still pending so the set cannot resolve to a single band. The returned string, when ok, is the canonical Title-Case Plan status ("Failed"/"Executing"/"Blocked"/ "Implemented"). This reads task status only; it never writes it.
func (*Plan) TaskRollup ¶ added in v0.7.0
TaskRollup tallies p.Tasks by status. Total is len(p.Tasks); each per-status count is 0 when no task holds that status.
type PostMutationHook ¶ added in v0.13.0
type PostMutationHook func() error
PostMutationHook is the callback the cobra adapter wires to `specscore spec lint --fix` (plus a verify pass). It MUST return nil on success; a non-nil return triggers full rollback of every on-disk mutation and the error is returned by ChangeStatus.
type Rollup ¶ added in v0.7.0
type Rollup struct {
Total int `yaml:"total" json:"total"`
Done int `yaml:"done" json:"done"`
InProgress int `yaml:"in-progress" json:"in-progress"`
Pending int `yaml:"pending" json:"pending"`
Blocked int `yaml:"blocked" json:"blocked"`
}
Rollup counts a plan's tasks by their parsed task **Status:** value.
type ScaffoldOptions ¶ added in v0.7.0
type ScaffoldOptions struct {
Slug string
Title string // defaults to a title-cased slug
Owner string // defaults to "unknown"
Date string // ISO-8601 (YYYY-MM-DD); defaults to today's UTC date
// At most one of SourceFeature / SourceIdea may be set: a plan decomposes
// one Feature, one Idea, or no source at all (source-less, emitted as
// `**Source:** none`) (cli/plan/new#req:source-optional).
SourceFeature string
SourceIdea string
// Parent, when non-empty, records the master plan this plan is a sub-plan of
// (cli/plan/new#req:parent-ref-optional). It is emitted verbatim as a
// `**Parent:** <value>` header line after `**Supersedes:**`; resolution is
// deferred to lint (P-005). A same-repo slug or a cross-repo
// `<repo-slug>:<plan-slug>` soft reference.
Parent string
}
ScaffoldOptions controls the flat Plan file Scaffold emits.
type Task ¶
type Task struct {
Number int // parsed N from `### Task N:`
Name string // text after `Task N: `
HeadingLine int // 1-based line of the `### Task N:` heading
BodyLines []string // lines after the heading, up to the next task / H2 / EOF (verbatim)
BodyStart int // 1-based line where the body begins (one past the heading)
Verifies []string // AC IDs from `**Verifies:**`, in source order
VerifiesLine int // 1-based line of `**Verifies:**`; 0 when absent
VerifiesPresent bool // true when the field was present
Status TaskStatus
StatusLine int // 1-based line of `**Status:**`; 0 when absent
StatusRaw string // raw value as written
StatusPresent bool // true when the field was present
StatusValueValid bool // true when StatusRaw parsed cleanly into TaskStatus
DependsOn []int // predecessor task numbers, empty when none
DependsOnLine int // 1-based line of `**Depends-On:**`; 0 when absent
DependsOnRaw string // raw value as written
DependsOnPresent bool
DependsOnValid bool // true when raw value parsed cleanly (em-dash or list of ints)
HasPlaceholder bool // true when the body contains the placeholder token on its own line
PlaceholderLine int // 1-based line of the placeholder; 0 when absent
}
Task captures a `### Task N: <name>` block.
type TaskStatus ¶
type TaskStatus string
TaskStatus enumerates valid `**Status:**` task body-field values.
const ( StatusPending TaskStatus = "pending" StatusInProgress TaskStatus = "in-progress" StatusDone TaskStatus = "done" StatusBlocked TaskStatus = "blocked" StatusFailed TaskStatus = "failed" StatusAborted TaskStatus = "aborted" )