plan

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: 12 Imported by: 0

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

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

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

func IsExecutionBandStatus(s lifecycle.Status) bool

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

func IsLegalChangeStatusTarget(s lifecycle.Status) bool

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

func IsSingleFilePlanPath(plansDir, filePath string) bool

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

func ValidateSlug(slug string) error

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

type ChangeStatusResult struct {
	Slug string
	From lifecycle.Status
	To   lifecycle.Status
}

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:

  1. Resolve <slug> to an existing Plan file (flat or directory form). A missing file returns exit 3.
  2. lifecycle.Validate against the KindPlan matrix. Illegal transitions return exit 4.
  3. lifecycle.Rewrite the **Status:** line; capture original for rollback.
  4. Optionally write the `**Superseded By:**` successor reference and the `## Resolution` note, each rolled back together with the status line.
  5. 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 Mode

type Mode string

Mode enumerates valid `**Mode:**` body-metadata values.

const (
	ModeFull Mode = "full"
	ModeStub Mode = "stub"
)

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

func Discover(plansDir string) ([]*Plan, error)

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

func Parse(path string) (*Plan, error)

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

func (p *Plan) DeriveExecutionBand() (string, bool)

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

func (p *Plan) TaskRollup() Rollup

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

Jump to

Keyboard shortcuts

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