Documentation
¶
Overview ¶
Package item persists lifecycle items (proposals, tasks, bugs, research) as `##`-anchored blocks in the per-context work.md — ACTIVE items only. Rejected and archived items leave work.md; their summaries live in the journal. One item = one block keeps merge conflicts block-local and work.md bounded (anti file-sprawl: no per-item files, ever).
Index ¶
- Constants
- Variables
- func CheckHeader(it Item) error
- func ExternalRef(r string) bool
- func KindOf(id string) string
- func Letter(kind string) string
- func MintID(kind string) string
- func MintIDAt(kind string, ts time.Time) string
- func NormalizeHeaderValue(v string) string
- func Num(id string) int
- func ParseOptions(body string) []string
- func Record(it Item) string
- func Remove(root workspace.Root, it Item) error
- func Statuses() []string
- func UnknownRefs(selfID string, refs []string, known map[string]bool) []string
- func Upsert(root workspace.Root, it Item) error
- func ValidKind(k string) bool
- func ValidStatus(s string) bool
- type Item
Constants ¶
const ( StateDraft = "draft" StateSubmitted = "submitted" StateApproved = "approved" StateActive = "active" StateDone = "done" StateArchived = "archived" StateRejected = "rejected" // StateBlocked is a side state, not part of the six-state main order // (like rejected): an item lands here only via lifecycle.Escalate, when // a done->active reopen would exceed the configured feedback round // limit. It is deliberately excluded from lifecycle's stateOrder/ // orderedStates — Move() refuses every transition into or out of it; // only lifecycle.ResolveBlocked (driven by the linked decision item's // outcome) can move an item out of blocked. StateBlocked = "blocked" )
States of the lifecycle state machine (see internal/lifecycle).
Variables ¶
var IDRe = regexp.MustCompile(`^` + idPat + `$`)
IDRe matches an item ID of either scheme: the legacy sequential form (P-0007, ADR-0007, and the legacy adr letter D-0007) and the ADR-0013 kind-prefixed record ID (T-01K9XJ...). Both are accepted permanently — see the pattern doc above for why legacy IDs can never be dropped.
var LegacyIDRe = regexp.MustCompile(`^` + legacyIDPat + `$`)
LegacyIDRe matches ONLY the pre-ADR-0013 sequential form. It exists to tell the two schemes apart — a migration deciding what still needs rewriting, a caller reporting which scheme a record predates. Nothing may use it to reject an ID: IDRe is the acceptance test, and it accepts both.
Functions ¶
func CheckHeader ¶ added in v0.8.4
CheckHeader refuses an item whose header writeWork could not read back identically — the corruption B-01KYN3E973F20 recorded, where a newline in an ADR field swallowed every field after it into the body and lost the answer's status write with them.
Two different limits, for two different reasons:
Prose fields may span lines, because Parse rejoins a non-key line onto the field above it, but never a BLANK line: the blank line is exactly what ends the machine header, so writing one puts the remaining fields on the far side of the boundary. A trailing newline is the same defect one character shorter — it emits that blank line too — which is why the test is on v+"\n". A LEADING newline is fine and deliberately allowed: it writes an empty value line the parser reads as "" and then continues onto, which round trips.
Single-line fields may hold no newline at all. Title is not stored as a field: it shares the "## " heading line with the ID, so a newline there splits one item into two — the second with a heading Parse won't match, or worse, one it will.
func ExternalRef ¶ added in v0.2.0
ExternalRef reports whether a ref cites something outside the workspace by URL rather than an item inside it by ID.
This is the PREFERRED way to cite a tracker issue: structured, unambiguous, and carrying its own repository, instead of prose a parser has to guess at. The alternative — recognizing "GitHub issue 26" in a body — has a blast radius that is not worth it, since acting on a false positive closes a stranger's issue; a URL in the refs field cannot be mistaken for a rule ID, a record count or a version number.
Validation is deliberately shallow: the scheme is checked, the destination is not. Whether the far end exists is the forge's answer to give, and a citation that 404s is still a citation the record should keep.
func KindOf ¶ added in v0.2.0
KindOf returns the kind an item ID names ("" if the ID is malformed). It reads the prefix, which both schemes share, so a legacy P-0007 and a record-ID P-01K9XJ... both answer "proposal" — callers deriving a kind from an ID must go through here rather than testing prefixes by hand, which is how the four-digit shape leaked into helpers in the first place.
func MintID ¶ added in v0.2.0
MintID mints a fresh ID for a kind, stamped with the current time.
Per ADR-0013 the number no longer comes from a counter. coord.NextID is serialized per machine but its database is gitignored, so two independent clones drafting concurrently minted the same ID and replay silently collapsed the two records into one (reproduced in R-0006). A UUIDv7 needs no coordination at all to be unique, and because it leads with a millisecond timestamp the canonical encoding also sorts by mint time. Returns "" for an unknown kind, matching Letter.
func MintIDAt ¶ added in v0.2.0
MintIDAt mints an ID for a kind stamped with ts instead of the clock. A migration rewriting historical records mints from each record's own creation time so the archive keeps its chronology instead of collapsing into the migration moment; tests use it for deterministic same-millisecond batches.
func NormalizeHeaderValue ¶ added in v0.8.4
NormalizeHeaderValue coerces a prose value into something the header can represent: blank lines collapse to single newlines and trailing newlines are dropped, which is exactly what CheckHeader refuses.
Refusing is the right answer for a value a CALLER supplied — the caller can be told, and the record is not yet at stake. It is the wrong answer on a restore path, where the value was manufactured by this program (a truncation marker appended after a cut that landed on a newline) and there is no caller to report to. Refusing there does not protect the record, it strands it: a rejected item that cannot be revoked is unreachable, which is worse than a prose field whose blank line was closed up. So the two paths differ on purpose — CheckHeader guards arguments, this coerces derived values.
func Num ¶
Num extracts the counter of a LEGACY sequential item ID (P-0007 -> 7), handling both single-letter and multi-letter (ADR-0007) prefixes. It returns 0 for anything else, record IDs included: an ADR-0013 ID has no counter, and a record ID whose base32 tail happens to open with digits would otherwise yield a meaningless number that a caller could mistake for a real one.
func ParseOptions ¶
ParseOptions extracts the fixed option set (if any) an adr item's Body records. Options are the rejected alternatives, not just the chosen Decision — this project's own record-keeping rules name them as knowledge worth keeping, so callers that carry an ADR onward (see internal/knowledge's Extract) need them, not just the Context/Decision/ Consequences/Status fields item.Item already exposes.
Three body shapes are understood, tried in this order:
- one `option: <text>` line per option, verbatim (no comma-splitting — an option's own text may contain commas). This is what NEW decisions write.
- the legacy `options: a, b, c` comma-joined line older decisions wrote — kept forever so existing items/journals stay answerable without migration (when an option's own text contains commas, the comma-split fragments it into more pieces than were intended, but that shattered form is exactly what remains answerable — it is not rewritten to option: lines).
- lifecycle.Escalate's `... outcome=a|b|c.` / `... choose=a|b|c.` sentence.
No match = free text (nil).
This is the SINGLE parser. It began as a copy of internal/mcpserver's unexported decideOptions, extracted so internal/knowledge could carry ADR options onto its portable Entry without importing internal/mcpserver; the follow-up this comment used to ask for — delete decideOptions and call this instead — is done, and it was not cosmetic. While both copies existed, a change to the body text they both parse landed in neither.
func Remove ¶
Remove deletes an item block from its context's work.md. Same whole-cycle locking as Upsert, and for the same reason.
func Statuses ¶ added in v0.8.2
func Statuses() []string
Statuses lists the accepted statuses in a stable order, so a refusal can print the enum without restating it and drifting from what is enforced.
func UnknownRefs ¶
UnknownRefs validates a proposed reference set for item selfID against known, the set of currently loadable item IDs, and reports — in refs' input order — every entry that is unusable as a citation: malformed (does not match IDRe), self-referential (equal to selfID, always a mistake), or absent from known. An entry failing more than one check is reported once.
UnknownRefs is deliberately not called from Parse: a work.md may legitimately cite an item that has since been archived out of work.md, and a parser that refused to load such a file would make a dangling citation unrecoverable. Validation belongs at the write path, which should call UnknownRefs before persisting and reject or warn on a non-empty result.
func Upsert ¶
Upsert writes an item block into its context's work.md (replacing an existing block with the same ID) and injects the schema frontmatter.
The whole read-modify-write (LoadWork through writeWork) runs under withWorkLock, not just the final write: two concurrent writers that both read work.md before either one wrote would still both compute their splice against the same stale slice and the later write would erase the earlier one's record, lock or no lock on writeWork alone (measured: B-01KYD5, 16 acknowledged drafts, as few as 10 surviving to disk).
func ValidStatus ¶ added in v0.8.2
ValidStatus accepts the four ADR statuses and empty, which is conventionally read as "proposed" (see Item.Status).
Types ¶
type Item ¶
type Item struct {
ID string
Kind string
State string
Title string
Dir string // context dir (repo-relative, "" = root)
Parent string
// Refs is a general citation set: item IDs this item cites, of any kind
// in either direction — research citing research, a proposal citing the
// research that produced it, an ADR naming the research that fed it.
// Order-preserving and duplicate-free (duplicates collapse on write).
// It differs from both other cross-item fields: Parent is a single
// structural owner (one task belongs to one proposal), Needs means
// blocked-on and drives the escalation exits, while Refs carries no
// lifecycle meaning at all — a plain citation the state machine never
// interprets. Shape and existence are validated at the write path (see
// UnknownRefs), never by Parse: a ref may legitimately point at an item
// archived out of work.md.
Refs []string
Created string // YYYY-MM-DD
Targets []string
Body string
// Feedback-loop / escalation fields (SDD orchestration v2).
Rounds int // done->active reopen count since the last reset (rescope/override-once)
Grilled string // most recent grill feedback (freeform, survives rescope)
Needs []string // IDs this item is blocked on (decision items minted by Escalate)
Override bool // override-once already spent — cannot be spent again
// ADR template fields (architecture decision records; kind=="adr").
// Always empty and omitted for every other kind — these are structured
// replacements for what used to be prose in Body, not general-purpose
// fields. Status follows the classic ADR convention proposed|accepted|
// superseded|deprecated; an empty Status on an adr item is conventionally
// read as "proposed".
Context string // the forces and constraints behind the decision
Decision string // the chosen option, verbatim
Consequences string // trade-offs and follow-on effects of the decision
Status string // proposed|accepted|superseded|deprecated
}
Item is one lifecycle item.