idea

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

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

View Source
var RequiredHeaderFields = []string{
	"Status",
	"Date",
	"Owner",
	"Promotes To",
	"Supersedes",
	"Related Ideas",
}

RequiredHeaderFields lists required **X:** fields in order.

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

View Source
var ValidIdeaTypes = map[string]bool{
	"feature-request": true,
	"change-request":  true,
}

ValidIdeaTypes enumerates the allowed values for the **Type:** field.

View Source
var ValidRelationships = map[string]bool{
	"depends_on":     true,
	"alternative_to": true,
	"extends":        true,
	"conflicts_with": true,
}

Valid relationship types for Related Ideas entries.

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

func EnsureArchivedIndexStub(specRoot string) (created bool, err error)

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

func FeatureSourceIdeas(specRoot string) (map[string][]string, error)

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

func FindIdeaDirectories(specRoot string) ([]string, error)

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

func IsLegalChangeStatusTarget(s lifecycle.Status) bool

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

func ResolveIdeasDir(specDir string) string

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

func ResolveSeedsDir(specDir string) string

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

func ValidateSlug(slug string) error

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:

  1. Resolve <slug> at the active path (exit 3 if missing).
  2. Materialize the lint-clean archived-index stub (exit 10 on I/O error).
  3. Collision check on the archived path (exit 1).
  4. Snapshot the original bytes, write the flagged content to the archived path, and remove the active file.
  5. 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

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

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

  1. Resolve <slug> to an active file at spec/ideas/<slug>.md. A missing active file (even if an archived copy exists) returns exit 3.
  2. lifecycle.Validate against the Idea matrix. Illegal transitions return exit 4.
  3. lifecycle.Rewrite the **Status:** line; capture original for rollback.
  4. 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

type HeaderField struct {
	Name  string
	Value string
	Line  int
}

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

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

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

func (i *Idea) ArchiveNote() string

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

func (i *Idea) Archived() bool

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

func (i *Idea) EffectiveType() string

EffectiveType returns the Type field value, defaulting to "feature-request" when the field is absent.

func (*Idea) IdeaType added in v0.5.1

func (i *Idea) IdeaType() string

IdeaType returns the Type field value or "" if absent. An absent Type is treated as "feature-request" by convention.

func (*Idea) Phase added in v0.5.1

func (i *Idea) Phase() string

Phase returns the Phase field value or "" if absent.

func (*Idea) PromotesTo

func (i *Idea) PromotesTo() []string

PromotesTo returns the parsed Promotes To slugs (comma-separated list). A value of "—" or "-" means empty.

func (*Idea) RelatedIdeas

func (i *Idea) RelatedIdeas() []string

RelatedIdeas returns the raw entries (un-split) from the Related Ideas field.

func (*Idea) Status

func (i *Idea) Status() string

Status returns the Status field value or "" if missing.

func (*Idea) Supersedes

func (i *Idea) Supersedes() []string

Supersedes returns the parsed Supersedes slugs.

func (*Idea) Targets added in v0.5.1

func (i *Idea) Targets() string

Targets returns the Targets field value or "" if absent.

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

type Table struct {
	Headers []string
	Rows    [][]string
}

Table captures a markdown pipe-table within a section.

func ParseTable

func ParseTable(body string) *Table

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.

Jump to

Keyboard shortcuts

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