Documentation
¶
Overview ¶
Package idea — orthogonal archival for the Idea kind.
Archival is NOT a lifecycle status. An archived Idea keeps its real terminal **Status:** (e.g. Rejected, Stale, Implemented) and is additionally marked archived via two coordinated facts:
- a `**Archived:** true` header line in the artifact, and
- relocation to spec/ideas/archived/<slug>.md.
`idea archive` sets both; `idea unarchive` reverses both. Neither touches the **Status:** line — that is the sole concern of ChangeStatus. The two axes are independent: a Rejected Idea may be active or archived; an archived Idea may be Rejected, Stale, or Implemented.
Cross-references:
- Verb spec: spec/features/cli/idea/archive/README.md
- Status vocabulary: archival-not-a-status (SpecScore-wide)
Package idea parses and represents SpecScore Idea artifacts.
An Idea is a single markdown file under `spec/ideas/` (or `spec/ideas/archived/`) that captures a pre-spec, lintable one-pager: problem framing, recommended direction, MVP scope, exclusions, and dealbreaker assumptions. See `spec/features/idea/README.md` for the full artifact schema.
This package provides parsing utilities — the lint rules that enforce the schema live in `pkg/lint`.
Package idea — lifecycle transition orchestration for the Idea kind.
This file hosts ChangeStatus, the kind-specific orchestrator invoked by `specscore idea change-status`. It composes pkg/lifecycle/ primitives (state-machine validation, status-line rewrite, rollback). Archival is a separate, orthogonal axis handled by the `idea archive`/`idea unarchive` verbs in archive.go — change-status no longer relocates any file.
LINT INVOCATION lives in the cobra adapter (internal/cli/idea.go), NOT here, to avoid an import cycle: pkg/lint already imports pkg/idea for the idea-* lint rules, so pkg/idea cannot depend back on pkg/lint. The adapter passes a Linter 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/idea/change-status/README.md
- Meta contract: spec/features/cli/lifecycle-transitions/README.md
All behavioral REQs cited in those documents are enforced here; the cobra adapter is a thin shim over this function.
Index ¶
- Constants
- Variables
- func EnsureArchivedIndexStub(specRoot string) (created bool, err error)
- func FeatureSourceIdeas(specRoot string) (map[string][]string, error)
- func FindIdeaDirectories(specRoot string) ([]string, error)
- func IsLegalChangeStatusTarget(s lifecycle.Status) bool
- func LegalChangeStatusTargetNames() []string
- func LegalTransitionMatrix() string
- func ResolveIdeasDir(specDir string) string
- func ResolveSeedsDir(specDir string) string
- func Scaffold(opts ScaffoldOptions) ([]byte, error)
- func SortedStatuses() []string
- func ValidateSlug(slug string) error
- type ArchiveOptions
- type ArchiveResult
- type ChangeStatusOptions
- type ChangeStatusResult
- type Discovered
- type HeaderField
- type Idea
- func (i *Idea) ArchiveNote() string
- func (i *Idea) Archived() bool
- func (i *Idea) EffectiveType() string
- func (i *Idea) IdeaType() string
- func (i *Idea) Phase() string
- func (i *Idea) PromotesTo() []string
- func (i *Idea) RelatedIdeas() []string
- func (i *Idea) Status() string
- func (i *Idea) Supersedes() []string
- func (i *Idea) Targets() string
- type PostMutationHook
- type ScaffoldOptions
- type Section
- type Table
- type UnarchiveOptions
Constants ¶
const FormatURL = "https://specscore.md/idea-specification"
FormatURL is the canonical spec URL for the Idea document type, emitted in the frontmatter `format:` field per the artifact-frontmatter-convention.
Variables ¶
var RequiredHeaderFields = []string{
"Status",
"Date",
"Owner",
"Promotes To",
"Supersedes",
"Related Ideas",
}
RequiredHeaderFields lists required **X:** fields in order.
var RequiredSections = []string{
"Problem Statement",
"Context",
"Recommended Direction",
"Alternatives Considered",
"MVP Scope",
"Not Doing (and Why)",
"Key Assumptions to Validate",
"SpecScore Integration",
"Open Questions",
}
RequiredSections names the sections that every Idea MUST have, in order.
var ValidIdeaTypes = map[string]bool{ "feature-request": true, "change-request": true, }
ValidIdeaTypes enumerates the allowed values for the **Type:** field.
var ValidRelationships = map[string]bool{ "depends_on": true, "alternative_to": true, "extends": true, "conflicts_with": true, }
Valid relationship types for Related Ideas entries.
var ValidStatuses = map[string]bool{ "Draft": true, "In Review": true, "Approved": true, "Specifying": true, "Specified": true, "Implementing": true, "Implemented": true, "Rejected": true, "Stale": true, }
Valid statuses for an Idea — the canonical set per the SpecScore-wide status vocabulary. `Archived` is NOT a status: archival is an orthogonal axis carried by the `**Archived:** true` header + relocation to spec/ideas/archived/ (see Archived() and discover.go).
Functions ¶
func EnsureArchivedIndexStub ¶ added in v0.7.0
EnsureArchivedIndexStub guarantees that spec/ideas/archived/ exists and contains a lint-clean README.md index. It creates the directory if absent and writes archivedIndexStub to archived/README.md ONLY when that file does not already exist. It returns created=true iff it wrote the stub (so callers can stage exactly the file they materialized).
`specscore init` does not create spec/ideas/archived/ — the directory comes into existence on the first archive/promote, and a directory without README.md fires the readme-exists rule (error severity). Writing the stub keeps the archive/promote operation's output lint-clean.
specRoot is the project root that contains the spec/ subtree (NOT the spec/ directory itself).
func FeatureSourceIdeas ¶
FeatureSourceIdeas scans every `spec/features/**/README.md` and returns a map of feature slug -> []idea-slug based on the **Source Ideas:** header. Features without the field are omitted. The slug is the feature's path relative to `spec/features/` (e.g. `cli`, `cli/spec/lint`, `cli/lifecycle-transitions`), so nested sub-features are first-class.
func FindIdeaDirectories ¶
FindIdeaDirectories returns directories that exist at `spec/ideas/<slug>/` (violation per REQ: single-file). Ignores the reserved `archived/` and `seeds/` directories — `seeds/` holds sidekick-seed documents, which are a separate document type, not malformed Ideas.
func IsLegalChangeStatusTarget ¶
IsLegalChangeStatusTarget reports whether status is one of the user-facing --to values for `specscore idea change-status`. The CLI uses this for early flag validation (exit 2 InvalidArgs) BEFORE the state-machine check, so unrecognized values like --to=draft fail fast with a clear message rather than slipping through to a state-machine rejection at exit 4. The legal --to set is the union of every To column in the Idea matrix.
func LegalChangeStatusTargetNames ¶
func LegalChangeStatusTargetNames() []string
LegalChangeStatusTargetNames returns the canonical-titled names of the legal --to values, for stderr rendering when a user supplies an unrecognized value.
func LegalTransitionMatrix ¶
func LegalTransitionMatrix() string
LegalTransitionMatrix returns a human-readable, ANSI-free rendering of the Idea legal-transition matrix, suitable for inclusion in cobra `Long` help text. Built from lifecycle.LegalTargets so the help stays current as the matrix grows.
Rows are emitted in the order returned by lifecycle.LegalStatuses (alphabetical) but only for statuses that have ≥1 outgoing legal target. The output is intentionally simple two-column ASCII — no box characters or color codes.
func ResolveIdeasDir ¶ added in v0.9.0
ResolveIdeasDir returns the absolute ideas directory for the repo's root module, honoring path_overrides.ideas_path (configurable-ideas-path#req:single-resolver). specDir is the spec/ directory; the project root is its parent. When no config is present or no override is set, it returns specDir/ideas — identical to the historical default (configurable-ideas-path#req:ideas-path-default).
func ResolveSeedsDir ¶ added in v0.9.0
ResolveSeedsDir returns the absolute sidekick-seed directory, which is always "seeds" under the resolved ideas directory (configurable-ideas-path#req:seeds-follow-ideas).
func Scaffold ¶
func Scaffold(opts ScaffoldOptions) ([]byte, error)
Scaffold returns a lint-clean Idea file body for the given options. Every required section is emitted either with an HTML-comment prompt (the default) or with the supplied content.
func SortedStatuses ¶
func SortedStatuses() []string
Sort returns a slice of statuses sorted alphabetically (helper for tests).
func ValidateSlug ¶
ValidateSlug returns nil if slug matches `[a-z0-9]+(-[a-z0-9]+)*`.
Types ¶
type ArchiveOptions ¶ added in v0.13.0
type ArchiveOptions struct {
// SpecRoot is the project root that contains the `spec/` subtree.
SpecRoot string
// Slug is the Idea slug; the active file is resolved at
// SpecRoot/spec/ideas/<Slug>.md.
Slug string
// Note is an optional free-form `**Archive Note:**` value tied to the
// archive action. Empty (after trimming) writes no note line. The
// disposition reason belongs on the terminal status transition, not here.
Note string
// PostMutation is the post-relocation hook (typically a spec-lint pass).
// Required.
PostMutation PostMutationHook
}
ArchiveOptions packages the inputs to Archive.
type ArchiveResult ¶ added in v0.13.0
type ArchiveResult struct {
Slug string
// From is the path the file moved from; To is where it now lives.
From string
To string
}
ArchiveResult is the success payload returned by Archive/Unarchive.
func Archive ¶ added in v0.13.0
func Archive(opts ArchiveOptions) (ArchiveResult, error)
Archive files an active Idea out of view: it sets `**Archived:** true` (and an optional `**Archive Note:**`) in the header and relocates the file from spec/ideas/<slug>.md to spec/ideas/archived/<slug>.md, preserving the **Status:** line untouched.
Flow:
- Resolve <slug> at the active path (exit 3 if missing).
- Materialize the lint-clean archived-index stub (exit 10 on I/O error).
- Collision check on the archived path (exit 1).
- Snapshot the original bytes, write the flagged content to the archived path, and remove the active file.
- Run the PostMutation hook; any failure rolls back to the byte-identical pre-invocation state (file back at the active path, original content).
func Unarchive ¶ added in v0.13.0
func Unarchive(opts UnarchiveOptions) (ArchiveResult, error)
Unarchive reverses Archive: it clears the `**Archived:**` axis (removing the `**Archived:**` and `**Archive Note:**` header lines) and relocates the file from spec/ideas/archived/<slug>.md back to spec/ideas/<slug>.md, preserving the **Status:** line. The Idea retains whatever terminal status it carried while archived.
Flow mirrors Archive: resolve at the archived path (exit 3 if missing), collision check on the active path (exit 1), relocate, then run the PostMutation hook with full rollback on failure.
type ChangeStatusOptions ¶
type ChangeStatusOptions struct {
// SpecRoot is the project root that contains the `spec/` subtree
// (NOT the `spec/` directory itself). The Idea file is resolved at
// SpecRoot/spec/ideas/<Slug>.md.
SpecRoot string
// Slug is the Idea slug, e.g. "payment-fraud". Caller is expected
// to have validated it via idea.ValidateSlug.
Slug string
// To is the canonical (title-case) target status, e.g. "Approved"
// or "Stale". The cobra adapter parses the raw --to value via
// lifecycle.ParseStatus before reaching this function. (Archival is
// not a status — see the `idea archive` verb.)
To lifecycle.Status
// PostMutation is the post-rewrite hook (typically a spec-lint
// pass). Required; ChangeStatus returns exit 10 if nil.
PostMutation PostMutationHook
// Note is an optional free-form markdown transition note. When non-
// empty after trimming, it is written into the artifact body as a
// `## Resolution` section, atomically with the status rewrite, per
// lifecycle-transitions#REQ:optional-transition-note. An empty or
// whitespace-only value is a no-op (today's behavior). The note write
// participates in the same rollback guarantee as the status rewrite.
Note string
}
ChangeStatusOptions packages the inputs to ChangeStatus.
type ChangeStatusResult ¶
ChangeStatusResult is the success payload returned by ChangeStatus on exit 0. The cobra adapter formats it as the success-output line.
func ChangeStatus ¶
func ChangeStatus(opts ChangeStatusOptions) (ChangeStatusResult, error)
ChangeStatus performs an Idea-kind lifecycle transition end-to-end.
Flow (matches the verb spec step-list):
- Resolve <slug> to an active file at spec/ideas/<slug>.md. A missing active file (even if an archived copy exists) returns exit 3.
- lifecycle.Validate against the Idea matrix. Illegal transitions return exit 4.
- lifecycle.Rewrite the **Status:** line; capture original for rollback.
- Invoke the PostMutation hook. Failure → full rollback + exit 10.
Archival is NOT a status transition — `change-status` never relocates a file. Filing an Idea out of active view is the separate, orthogonal `idea archive` verb (see archive.go), which keeps the Idea's real terminal **Status:** and adds the `**Archived:** true` axis.
ChangeStatus performs all rollback internally — by the time it returns an error, the on-disk state is byte-identical to its pre-invocation shape per lifecycle-transitions#REQ:rollback-on-lint-failure.
type Discovered ¶
type Discovered struct {
Slug string
Path string // absolute or relative path to the .md file
Archived bool // true if located under archived/
IsProposal bool // true if located under spec/features/*/proposals/
FeatureDir string // non-empty for proposals: the feature directory slug
}
Discovered is a summary of an Idea file found during discovery.
func Discover ¶
func Discover(specRoot string) ([]Discovered, error)
Discover walks `<specRoot>/ideas` and returns every idea file found. Returns ([], nil) if the directory does not exist.
type HeaderField ¶
HeaderField captures a single **Name:** value line.
type Idea ¶
type Idea struct {
Path string
Slug string
Title string // full title line (without leading "# ")
TitleName string // name portion after "Idea: " or "Proposal: "
TitleOK bool // true if title matches "# Idea: <Name>" or "# Proposal: <Name>"
TitlePrefix string // "Idea" or "Proposal" (set when TitleOK is true)
TitleLine int
HasTitle bool
Fields []HeaderField // in-order header fields encountered
FieldByName map[string]HeaderField
Sections []Section
SectionByTitle map[string]*Section
RawLines []string
}
Idea is a parsed Idea artifact.
func Parse ¶
Parse reads an Idea file and returns its parsed representation. Parse is resilient — it returns a partial Idea even if the file is malformed (missing title, missing sections). Callers (lint rules) decide what is an error.
func (*Idea) ArchiveNote ¶ added in v0.13.0
ArchiveNote returns the optional **Archive Note:** value or "". The note is tied to the archive action and is OPTIONAL; the disposition reason lives in the terminal status transition's note, not here.
func (*Idea) Archived ¶ added in v0.13.0
Archived reports whether the Idea carries `**Archived:** true` in its header. Archival is an orthogonal axis to lifecycle status: an archived Idea keeps its real terminal **Status:** (e.g. Rejected, Stale, Implemented) and is additionally marked archived via this flag plus relocation to spec/ideas/archived/. An absent or non-`true` value means not archived.
func (*Idea) EffectiveType ¶ added in v0.5.1
EffectiveType returns the Type field value, defaulting to "feature-request" when the field is absent.
func (*Idea) IdeaType ¶ added in v0.5.1
IdeaType returns the Type field value or "" if absent. An absent Type is treated as "feature-request" by convention.
func (*Idea) PromotesTo ¶
PromotesTo returns the parsed Promotes To slugs (comma-separated list). A value of "—" or "-" means empty.
func (*Idea) RelatedIdeas ¶
RelatedIdeas returns the raw entries (un-split) from the Related Ideas field.
func (*Idea) Supersedes ¶
Supersedes returns the parsed Supersedes slugs.
type PostMutationHook ¶
type PostMutationHook func() error
PostMutationHook is the callback the lifecycle verbs (ChangeStatus, Archive, Unarchive) invoke after a successful on-disk mutation. It is the integration point for `specscore spec lint --fix` plus the verify pass.
The hook MUST return nil on success. A non-nil return triggers full rollback (status/flag rewrite restored AND, for archive/unarchive, the file moved back) and the error is wrapped and returned by the caller.
The cobra adapter is responsible for wiring this to pkg/lint; tests can supply a fake to exercise the rollback paths without invoking lint.
type ScaffoldOptions ¶
type ScaffoldOptions struct {
Slug string
// Title is the human-readable name after `# Idea: ` or `# Proposal: `.
// Defaults to a title-cased version of the slug.
Title string
Owner string
// Date in ISO-8601 (YYYY-MM-DD). Defaults to today's UTC date.
Date string
// Status defaults to "Draft".
Status string
// Type is the idea type: "feature-request" (default) or "change-request".
// When "change-request", the title prefix becomes "# Proposal: " and the
// Type/Targets fields are emitted in the header.
Type string
// Targets is the feature slug this change-request targets. Only meaningful
// when Type is "change-request".
Targets string
// Phase is an optional lifecycle phase value pre-populated in the header.
Phase string
// Section content. Empty strings leave the default prompt in place.
HMW string // Problem Statement
Context string
RecommendedDirection string
Alternatives []string // Each element is a bullet for Alternatives Considered.
MVP string
// NotDoing is a list of exclusions. When empty, a lint-clean default
// list is emitted so the Not Doing section passes idea-not-doing-non-empty.
NotDoing []string
// Assumptions is an optional list of assumption-table rows.
// Each row is {Tier, Assumption, HowToValidate}. When empty, a
// lint-clean default table with one Must-be-true row is emitted.
Assumptions [][3]string
// SpecScore Integration overrides.
NewFeatures string
Existing string
Dependencies string
// OpenQuestions bullets (optional).
OpenQuestions []string
}
ScaffoldOptions controls the content emitted by Scaffold. Any field left empty keeps the section's default HTML-comment prompt.
type Section ¶
type Section struct {
Title string
StartLine int
EndLine int
Body string
Items []string // lines starting with "- "
}
Section captures a single ## heading and its body.
type Table ¶
Table captures a markdown pipe-table within a section.
func ParseTable ¶
ParseTable extracts a markdown pipe-table from the given section body. Returns nil if no table is present. Only the first table is returned.
type UnarchiveOptions ¶ added in v0.13.0
type UnarchiveOptions struct {
SpecRoot string
Slug string
PostMutation PostMutationHook
}
UnarchiveOptions packages the inputs to Unarchive.