diff --git a/.abcd/development/decisions/adrs/0032-issue-ledger-is-working-tier-data.md b/.abcd/development/decisions/adrs/0032-issue-ledger-is-working-tier-data.md new file mode 100644 index 00000000..2843a2db --- /dev/null +++ b/.abcd/development/decisions/adrs/0032-issue-ledger-is-working-tier-data.md @@ -0,0 +1,77 @@ +--- +id: adr-32 +slug: issue-ledger-is-working-tier-data +status: accepted +date: 2026-07-08 +supersedes: null +superseded_by: null +related_intents: [itd-4, itd-36] +related_rfcs: [] +related_adrs: [adr-3, adr-5, adr-26, adr-30] +--- + +# ADR-32: The issue ledger is working-tier data, not authored record + +## Context + +The capture ledger (itd-4) writes schema-checked `iss-N` files with folder-as-status +(`open/`, `resolved/`, `wontfix/`). It was sited at `.abcd/development/activity/issues/` — +inside the durable design-record tree that `record-lint` governs. + +Dogfooding surfaced a shipped-vs-shipped contradiction: `capture`'s issue schema *requires* +a `created:` field, while `record-lint`'s `no_git_metadata` rule *bans* `created:` anywhere +under `.abcd/development` (git log/blame is canonical). Running `capture` therefore broke +`record-lint` — two shipped components mutually incompatible, the moment the tool is used on +its own repo. The finding is itself in the ledger as `iss-15`. + +The root cause is a category error: the ledger is generated, schema-governed *data* that +opens and resolves, but it was living in the tree meant for authored, git-canonical *prose*. + +## Decision + +1. **Tier.** The issue ledger lives in the **work tier** — `.abcd/work/issues/` — not the + design record. It is committed, shared working state (folder-as-status per adr-3), not + durable design record. This takes it out of `record-lint`'s root and dissolves the conflict + at its source. + +2. **No git-inferable timestamps.** `created` and `updated` are dropped from the schema; git + is the canonical source of time (consistent with `no_git_metadata` and the + derive-don't-store principle). The reader **tolerates** legacy `created`/`updated` on read + (accept, then drop) so an older ledger degrades gracefully rather than being rejected. + +3. **Priority is derived, never stored.** Issues carry a one-direction `blocked_by: [iss-N]` + dependency edge; the inverse and the ranking are computed. `capture list`/status order + *unblocked-first, then by severity*, annotating blocked rows `[blocked-by iss-N]`. This + reuses the spec engine's dependency-graph model (adr-26, the run-seam selector) rather than + inventing a parallel priority label. + +4. **README contracts by category.** Per-folder READMEs stay for the authored record — they + are local membership contracts. A generated-data store gets **one** README at its root; its + state leaves are self-evident. The tier boundary keeps `directory_coverage` aimed only at + prose, so the lint needs no change. + +## Alternatives Considered + +- **Exempt `.abcd/development/activity/` in `record-lint`.** The first-pass fix, briefly + recommended and then reversed. Rejected as symptom suppression: it carves the lint around a + category error rather than fixing it, and leaves the ledger in the wrong tier. The reversal + is the methodology-over-local-fixes principle in action — recorded so the reasoning is not + re-litigated. +- **A stored `priority` field.** Rejected: priority is volatile and contextual; a stored label + is the hand-maintained-status drift the roadmap dashboard and adr-5 already avoid. +- **Blanket-exempt all of `.abcd/` from READMEs.** Rejected: it discards the local contracts + the authored record relies on, to fix a problem that only exists for generated data. +- **Keep the timestamps as portable data.** Rejected: the ledger is read in-repo with git + present; git derives creation and the lifecycle moves, and the store is not bundled in the + release artefact. + +## Consequences + +- The `capture` ⊥ `record-lint` conflict is gone at the root; running `capture` no longer + breaks the gate. +- Design-record references to the old path (e.g. `build-sequence.md`) are now drift to + reconcile, tracked as ledger issues. +- Future generated stores (logbook, lifeboat) inherit the rule: working/generated data lives + outside the record tier and carries no git-inferable metadata. +- A self-hosting datapoint: abcd's first real contradiction was found by running abcd on abcd + (`iss-15`) — the dogfooding thesis paying out in practice. diff --git a/.abcd/development/decisions/adrs/README.md b/.abcd/development/decisions/adrs/README.md index bf855cdc..89b9d525 100644 --- a/.abcd/development/decisions/adrs/README.md +++ b/.abcd/development/decisions/adrs/README.md @@ -133,3 +133,4 @@ The intent lint (a Go implementation) extends to verify these reciprocally. | [adr-29](0029-native-transcript-corpus.md) | A native local redacted transcript corpus | accepted | 2026-07-06 | | [adr-30](0030-record-information-architecture.md) | Design-record information architecture — flat artefact-type folders | accepted | 2026-07-06 | | [adr-31](0031-derived-versioning-from-intents.md) | The release version is derived from the intents in it, never authored (extends adr-19, adr-20) | accepted | 2026-07-07 | +| [adr-32](0032-issue-ledger-is-working-tier-data.md) | The issue ledger is working-tier data, not authored record — move to `.abcd/work/issues/`, drop git-inferable timestamps, derive priority | accepted | 2026-07-08 | diff --git a/.abcd/work/issues/README.md b/.abcd/work/issues/README.md new file mode 100644 index 00000000..770f8cf2 --- /dev/null +++ b/.abcd/work/issues/README.md @@ -0,0 +1,88 @@ +# Issue ledger + +The per-repo issue ledger: abcd's structured replacement for a free-form +`issues.md`. It lives here, under the shared working tier (`.abcd/work/`), so it +is committed and travels with the repository. Each issue is a single +YAML-frontmatter + Markdown-body file named `iss--.md`, with an +unpadded, per-repo `iss-N` id namespace. + +This document is the store contract. The write side is +`internal/core/capture`; the front door is `abcd capture`. + +## The three states + +An issue's status is its folder — there is no `status:` frontmatter field. +Membership of one of these three directories *is* the status signal: + +- `open/` — live, unresolved issues. +- `resolved/` — issues closed by an action; each carries a non-empty + `resolution` note. +- `wontfix/` — issues closed by an explicit decision not to act; each carries a + non-empty `wontfix_reason`. + +An issue moves between states by being relocated between these folders, never by +editing a field. Do not add `README.md` files inside `open/`, `resolved/`, or +`wontfix/`: only genuine `iss-N` files belong there (stray markdown is ignored +by the scanner, but keeping the folders clean keeps the contract honest). + +## Schema fields + +Frontmatter is validated strictly (unknown keys are rejected). The reader +handles `schema_version: 1`. + +Required: + +- `schema_version` — integer, currently `1`. +- `id` — `iss-N` (matches the filename's id). +- `slug` — kebab-case summary (matches the filename's slug). +- `severity` — one of `critical`, `major`, `minor`, `nitpick`. +- `category` — the loose taxonomy (`bug`, `documentation`, `drift`, + `inconsistency`, `tech-debt`, `security`, `ux`, `process`, + `architectural-insight`, `future-work-seed`, `observation`). +- `source` — the surfacing channel (`plan-review`, `impl-review`, + `manual-test`, `review-followup`, `agent-finding`, `user-observation`, + `drift-detection`, `memory-curation`). +- `found_during` — non-empty session or command context. + +Optional: + +- `found_at` — repo-relative path or conceptual location. +- `related_intents` — list of `itd-N` ids. +- `related_specs` — list of `fn-N` ids. +- `related_issues` — list of `iss-N` ids. +- `blocked_by` — list of `iss-N` ids this issue depends on (see below). +- `promoted_to` — the `itd-N` this issue graduated into. +- `resolution` — required and non-empty in `resolved/`; forbidden elsewhere. +- `wontfix_reason` — required and non-empty in `wontfix/`; forbidden elsewhere. +- `resolved_by` — optional pointer object (`intent`, `spec`, `commit`). +- `details`, `suggested_fix`, `synthesis_clusters` — free-form provenance. + +There is no `created` or `updated` field. Git is the canonical source of an +issue's timeline; the ledger does not duplicate it. + +## The capture verb + +`abcd capture ""` appends a new issue to `open/`, allocating the next +`iss-N`. Flags refine the frontmatter — `--severity`, `--category`, `--source`, +`--slug`, `--found-during`, `--found-at`, and `--blocked-by` (a comma-separated +list of `iss-N` ids). Bare `abcd capture` renders a read-only status board; +`abcd capture list` filters by state; `abcd capture resolve` and +`abcd capture wontfix` move an open issue to its closed folder with a note. + +## Derived priority + +`blocked_by` records a typed dependency edge in one direction only: the +dependent issue names the issues it waits on. There is no stored priority field. + +Priority is a read-time projection computed by `list` and the status board: + +1. **Unblocked issues come first.** An issue is *blocked* if any of its + `blocked_by` targets is still in `open/`; once every target has moved to + `resolved/` or `wontfix/`, the issue is unblocked again. +2. **Within each group, higher severity comes first** + (`critical` > `major` > `minor` > `nitpick`). + +Blocked rows are annotated with the still-open targets, for example +`[blocked-by iss-3,iss-7]`. Because the projection is derived, resolving a +blocker automatically re-prioritises everything that depended on it — nothing is +stored, so nothing goes stale. diff --git a/CHANGELOG.md b/CHANGELOG.md index 798998a7..88569ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ called out in a **Breaking** section. ## [Unreleased] +### Added + +- `abcd capture --blocked-by ` records typed dependency edges on a new + issue, and `capture list` / the status board now render a derived-priority + view: unblocked issues first, then by severity, with blocked rows annotated + `[blocked-by iss-N,…]`. There is no stored priority — the ordering is a + read-time projection, so resolving a blocker re-prioritises its dependents + automatically. +- A store-contract README for the issue ledger (`.abcd/work/issues/README.md`). + +### Changed + +- The issue ledger moved from `.abcd/development/activity/issues` to + `.abcd/work/issues` (the committed shared-working tier). + +### Removed + +- The `created` and `updated` frontmatter fields on issues. Git is the canonical + source of an issue's timeline; the ledger no longer duplicates it. + ## [v0.1.0] - 2026-07-07 First tagged milestone: the Go rebuild through Phase 2. abcd is a single, diff --git a/commands/abcd/capture.md b/commands/abcd/capture.md index 4519bc21..4c9e60a1 100644 --- a/commands/abcd/capture.md +++ b/commands/abcd/capture.md @@ -7,9 +7,9 @@ argument-hint: "[text] | list --open|--resolved|--wontfix|--all | resolve /.abcd/development/activity/issues with three +// The ledger lives at /.abcd/work/issues with three // status directories (open/, resolved/, wontfix/) whose folder membership IS // the status signal — there is no status: frontmatter field. Each issue is a // YAML-frontmatter + Markdown-body file named iss--.md with an @@ -20,7 +20,7 @@ import ( ) // LedgerRelPath is the ledger root relative to the repo worktree. -const LedgerRelPath = ".abcd/development/activity/issues" +const LedgerRelPath = ".abcd/work/issues" // Enumerated field types (validated at the boundary; values mirror // scripts/abcd/schemas/issue.schema.json). @@ -85,11 +85,10 @@ type Issue struct { Source Source `json:"source"` FoundDuring string `json:"found_during"` FoundAt string `json:"found_at,omitempty"` - Created string `json:"created"` // YYYY-MM-DD - Updated string `json:"updated,omitempty"` RelatedIntents []string `json:"related_intents,omitempty"` RelatedSpecs []string `json:"related_specs,omitempty"` RelatedIssues []string `json:"related_issues,omitempty"` + BlockedBy []string `json:"blocked_by,omitempty"` // iss-N dependency edges PromotedTo string `json:"promoted_to,omitempty"` Resolution string `json:"resolution,omitempty"` WontfixReason string `json:"wontfix_reason,omitempty"` @@ -97,6 +96,10 @@ type Issue struct { Status State `json:"status"` // derived from folder Path string `json:"path"` // absolute Body string `json:"body"` + // BlockedByOpen is the derived subset of BlockedBy whose targets are still in + // open/ (the priority projection populated by List/Status). Not a stored + // field: an empty slice means the issue is unblocked. + BlockedByOpen []string `json:"blocked_by_open,omitempty"` } // CaptureRequest is the input to Capture (append a new issue). @@ -112,7 +115,8 @@ type CaptureRequest struct { FoundAt string // optional; "" omits the field RelatedIntents []string RelatedSpecs []string - ForceID string // migrator-only; "" = auto-allocate + BlockedBy []string // iss-N dependency edges; each must match ^iss-[0-9]+$ + ForceID string // migrator-only; "" = auto-allocate } // CaptureResult is the outcome of a successful Capture. @@ -214,7 +218,6 @@ var ( reItdID = regexp.MustCompile(`^itd-[0-9]+$`) reFnID = regexp.MustCompile(`^fn-[0-9]+$`) reSlug = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) - reCreated = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) reFilenameID = regexp.MustCompile(`^(iss-[0-9]+)(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?\.md$`) reAbcdListID = regexp.MustCompile(`^(itd|fn|iss)-[0-9]+$`) reSortIssID = regexp.MustCompile(`^iss-([0-9]+)(-|$|\.)`) diff --git a/internal/core/capture/roots.go b/internal/core/capture/roots.go index 51043ef1..7787cc07 100644 --- a/internal/core/capture/roots.go +++ b/internal/core/capture/roots.go @@ -9,12 +9,8 @@ import ( "path/filepath" "regexp" "strings" - "time" ) -// nowDate returns today's date as YYYY-MM-DD. A package var so tests can pin it. -var nowDate = func() string { return time.Now().Format("2006-01-02") } - // resolveRoots resolves (repoRoot, issuesRoot) from the request fields plus git // discovery, mirroring _issue_lib._resolve_roots (contracts A–D). repoRoot is // canonicalised; issuesRoot is made absolute without following symlinks so the diff --git a/internal/core/capture/validate.go b/internal/core/capture/validate.go index 568649b7..20fe900f 100644 --- a/internal/core/capture/validate.go +++ b/internal/core/capture/validate.go @@ -15,12 +15,17 @@ var knownFields = map[string]bool{ "details": true, "suggested_fix": true, "related_intents": true, "promoted_to": true, "related_specs": true, "related_issues": true, "synthesis_clusters": true, "wontfix_reason": true, "resolution": true, - "resolved_by": true, "created": true, "updated": true, + "resolved_by": true, "blocked_by": true, + // created/updated are no longer written, but legacy ledgers still carry + // them. Tolerate (accept, then drop) them on read so an existing committed + // ledger is not rejected as an unknown property; the reader ignores their + // values entirely. + "created": true, "updated": true, } // uniqueItemsFields are the array properties issue.schema.json flags // uniqueItems:true. -var uniqueItemsFields = []string{"related_intents", "related_specs", "related_issues", "synthesis_clusters"} +var uniqueItemsFields = []string{"related_intents", "related_specs", "related_issues", "synthesis_clusters", "blocked_by"} // validateStrict validates a frontmatter map against the issue schema. It // special-cases schema_version first (mirrors _validate_strict) and rejects @@ -41,7 +46,7 @@ func validateStrict(fm map[string]any) error { } // Required strings. - for _, req := range []string{"id", "slug", "severity", "category", "source", "found_during", "created"} { + for _, req := range []string{"id", "slug", "severity", "category", "source", "found_during"} { v, present := fm[req] if !present { return fmt.Errorf("%w: missing required property %q", ErrMissingRequiredField, req) @@ -70,23 +75,15 @@ func validateStrict(fm map[string]any) error { if strings.TrimSpace(fm["found_during"].(string)) == "" { return fmt.Errorf("%w: found_during must be non-empty", ErrMalformedFrontmatter) } - if !reCreated.MatchString(fm["created"].(string)) { - return fmt.Errorf("%w: created %q is not YYYY-MM-DD", ErrMalformedFrontmatter, fm["created"]) - } // Optional scalar strings. - for _, opt := range []string{"found_at", "details", "suggested_fix", "updated", "wontfix_reason", "resolution", "promoted_to"} { + for _, opt := range []string{"found_at", "details", "suggested_fix", "wontfix_reason", "resolution", "promoted_to"} { if v, present := fm[opt]; present { if _, isStr := v.(string); !isStr { return fmt.Errorf("%w: %q must be a string", ErrMalformedFrontmatter, opt) } } } - if v, present := fm["updated"]; present { - if !reCreated.MatchString(v.(string)) { - return fmt.Errorf("%w: updated %q is not YYYY-MM-DD", ErrMalformedFrontmatter, v) - } - } if v, present := fm["promoted_to"]; present { if !reItdID.MatchString(v.(string)) { return fmt.Errorf("%w: promoted_to %q does not match ^itd-[0-9]+$", ErrMalformedFrontmatter, v) @@ -102,6 +99,7 @@ func validateStrict(fm map[string]any) error { {"related_intents", reItdID, "itd-N"}, {"related_specs", reFnID, "fn-N"}, {"related_issues", reIssID, "iss-N"}, + {"blocked_by", reIssID, "iss-N"}, } for _, f := range idListFields { v, present := fm[f.field] @@ -223,8 +221,6 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is Source: Source(asString(fm["source"])), FoundDuring: asString(fm["found_during"]), FoundAt: asString(fm["found_at"]), - Created: asString(fm["created"]), - Updated: asString(fm["updated"]), PromotedTo: asString(fm["promoted_to"]), Resolution: asString(fm["resolution"]), WontfixReason: asString(fm["wontfix_reason"]), @@ -235,6 +231,7 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is iss.RelatedIntents = asStrList(fm["related_intents"]) iss.RelatedSpecs = asStrList(fm["related_specs"]) iss.RelatedIssues = asStrList(fm["related_issues"]) + iss.BlockedBy = asStrList(fm["blocked_by"]) if rb, ok := fm["resolved_by"].(map[string]any); ok { iss.ResolvedBy = &ResolvedBy{ Intent: asString(rb["intent"]), diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 8e42246e..5da14259 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -54,7 +54,6 @@ func Capture(req CaptureRequest) (CaptureResult, error) { } func commitCapture(req CaptureRequest, issID, slug, placeholder string) (CaptureResult, error) { - created := nowDate() fields := []kv{ {"schema_version", 1}, {"id", issID}, @@ -63,7 +62,6 @@ func commitCapture(req CaptureRequest, issID, slug, placeholder string) (Capture {"category", string(req.Category)}, {"source", string(req.Source)}, {"found_during", req.FoundDuring}, - {"created", created}, } fm := map[string]any{ "schema_version": 1, @@ -73,7 +71,6 @@ func commitCapture(req CaptureRequest, issID, slug, placeholder string) (Capture "category": string(req.Category), "source": string(req.Source), "found_during": req.FoundDuring, - "created": created, } if req.FoundAt != "" { fields = append(fields, kv{"found_at", req.FoundAt}) @@ -87,6 +84,10 @@ func commitCapture(req CaptureRequest, issID, slug, placeholder string) (Capture fields = append(fields, kv{"related_specs", req.RelatedSpecs}) fm["related_specs"] = req.RelatedSpecs } + if req.BlockedBy != nil { + fields = append(fields, kv{"blocked_by", req.BlockedBy}) + fm["blocked_by"] = req.BlockedBy + } if err := validateStrict(fm); err != nil { return CaptureResult{}, err @@ -115,12 +116,12 @@ func commitCapture(req CaptureRequest, issID, slug, placeholder string) (Capture return CaptureResult{ID: issID, Slug: slug, Path: placeholder, Status: StateOpen}, nil } -// Resolve moves an open issue to resolved/, writing resolution + updated. +// Resolve moves an open issue to resolved/, writing the resolution note. func Resolve(req ResolveRequest) (TransitionResult, error) { return transition(req.RepoRoot, req.IssuesRoot, req.ID, "resolution", req.Resolution, StateResolved) } -// Wontfix moves an open issue to wontfix/, writing wontfix_reason + updated. +// Wontfix moves an open issue to wontfix/, writing the wontfix_reason note. func Wontfix(req WontfixRequest) (TransitionResult, error) { return transition(req.RepoRoot, req.IssuesRoot, req.ID, "wontfix_reason", req.Reason, StateWontfix) } @@ -153,11 +154,7 @@ func transition(repoRoot, issuesRoot, issID, field, note string, target State) ( if err != nil { return TransitionResult{}, err } - withField, err := setScalarField(content, field, note) - if err != nil { - return TransitionResult{}, err - } - newContent, err := setScalarField(withField, "updated", nowDate()) + newContent, err := setScalarField(content, field, note) if err != nil { return TransitionResult{}, err } @@ -220,6 +217,7 @@ func List(req ListRequest) (ListResult, error) { } issues, skipped := scanLedger(ir, state) sortIssues(issues) + prioritise(issues, openIDSet(ir)) return ListResult{Issues: issues, Skipped: skipped}, nil } @@ -239,15 +237,65 @@ func Status(req StatusRequest) (StatusResult, error) { res.WontfixCount = len(wontfix) res.Skipped = append(append(append([]SkipRecord{}, skOpen...), skRes...), skWf...) + openIDs := idSet(open) // Newest first: higher N is newer (ids are monotonic with creation). sort.SliceStable(open, func(i, j int) bool { return issNumber(open[i].ID) > issNumber(open[j].ID) }) if len(open) > 10 { open = open[:10] } + // Derived-priority view over the recent slice: unblocked first, then severity. + prioritise(open, openIDs) res.RecentOpen = open return res, nil } +// severityRank orders severities for the derived-priority view: higher rank +// sorts earlier (critical is most urgent, nitpick least). +var severityRank = map[Severity]int{ + SeverityCritical: 4, SeverityMajor: 3, SeverityMinor: 2, SeverityNitpick: 1, +} + +// prioritise applies the read-time priority projection in place: it fills each +// issue's BlockedByOpen with the blocked_by targets still in open/ (openIDs), +// then stably orders unblocked issues ahead of blocked ones and, within each +// group, higher severity first. There is no stored priority — this is a derived +// view so the CLI and any future front door share one ordering. The caller is +// expected to have pre-sorted issues into a deterministic tiebreak order. +func prioritise(issues []Issue, openIDs map[string]bool) { + for i := range issues { + var stillOpen []string + for _, dep := range issues[i].BlockedBy { + if openIDs[dep] { + stillOpen = append(stillOpen, dep) + } + } + issues[i].BlockedByOpen = stillOpen + } + sort.SliceStable(issues, func(i, j int) bool { + bi, bj := len(issues[i].BlockedByOpen) > 0, len(issues[j].BlockedByOpen) > 0 + if bi != bj { + return !bi // unblocked (false) sorts first + } + return severityRank[issues[i].Severity] > severityRank[issues[j].Severity] + }) +} + +// openIDSet returns the set of ids currently in open/ — the predicate a +// blocked_by target must satisfy to still count as blocking. Read-only. +func openIDSet(issuesRoot string) map[string]bool { + open, _ := scanLedger(issuesRoot, StateOpen) + return idSet(open) +} + +// idSet collects the ids of a slice of issues into a set. +func idSet(issues []Issue) map[string]bool { + set := make(map[string]bool, len(issues)) + for _, iss := range issues { + set[iss.ID] = true + } + return set +} + // scanLedger reads issues from the requested state(s). Stray/non-matching .md // files are silently ignored; corrupt matching files go into Skipped. func scanLedger(issuesRoot string, state State) ([]Issue, []SkipRecord) { diff --git a/internal/core/capture/workflow_test.go b/internal/core/capture/workflow_test.go index 90ec5f9a..9a206675 100644 --- a/internal/core/capture/workflow_test.go +++ b/internal/core/capture/workflow_test.go @@ -17,15 +17,7 @@ func ledger(t *testing.T) (string, string) { return repo, filepath.Join(repo, LedgerRelPath) } -func pinDate(t *testing.T, d string) { - t.Helper() - prev := nowDate - nowDate = func() string { return d } - t.Cleanup(func() { nowDate = prev }) -} - func TestCaptureAppendAndReadBack(t *testing.T) { - pinDate(t, "2026-07-07") tests := []struct { name string req CaptureRequest @@ -41,8 +33,8 @@ func TestCaptureAppendAndReadBack(t *testing.T) { want: Issue{ SchemaVersion: 1, ID: "iss-1", Slug: "something-off", Severity: SeverityMinor, Category: "bug", Source: "manual-test", - FoundDuring: "manual smoke", Created: "2026-07-07", - Status: StateOpen, Body: "Something is off.\n", + FoundDuring: "manual smoke", + Status: StateOpen, Body: "Something is off.\n", }, }, { @@ -57,8 +49,8 @@ func TestCaptureAppendAndReadBack(t *testing.T) { SchemaVersion: 1, ID: "iss-1", Slug: "drifted", Severity: SeverityMajor, Category: "drift", Source: "agent-finding", FoundDuring: "fn-3 review", FoundAt: "internal/x.go", - Created: "2026-07-07", RelatedIntents: []string{"itd-4"}, - RelatedSpecs: []string{"fn-12"}, Status: StateOpen, Body: "b", + RelatedIntents: []string{"itd-4"}, + RelatedSpecs: []string{"fn-12"}, Status: StateOpen, Body: "b", }, }, } @@ -97,16 +89,16 @@ func TestCaptureAppendAndReadBack(t *testing.T) { func issueEqual(a, b Issue) bool { return a.SchemaVersion == b.SchemaVersion && a.ID == b.ID && a.Slug == b.Slug && a.Severity == b.Severity && a.Category == b.Category && a.Source == b.Source && - a.FoundDuring == b.FoundDuring && a.FoundAt == b.FoundAt && a.Created == b.Created && - a.Updated == b.Updated && a.PromotedTo == b.PromotedTo && a.Resolution == b.Resolution && + a.FoundDuring == b.FoundDuring && a.FoundAt == b.FoundAt && + a.PromotedTo == b.PromotedTo && a.Resolution == b.Resolution && a.WontfixReason == b.WontfixReason && a.Status == b.Status && a.Body == b.Body && strings.Join(a.RelatedIntents, ",") == strings.Join(b.RelatedIntents, ",") && strings.Join(a.RelatedSpecs, ",") == strings.Join(b.RelatedSpecs, ",") && + strings.Join(a.BlockedBy, ",") == strings.Join(b.BlockedBy, ",") && a.Path == b.Path } func TestCaptureAllocatesIncrementingIDs(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) for i := 1; i <= 3; i++ { res, err := Capture(CaptureRequest{ @@ -123,7 +115,6 @@ func TestCaptureAllocatesIncrementingIDs(t *testing.T) { } func TestCaptureForceIDAndDuplicate(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) base := CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "x", Severity: SeverityMinor, @@ -160,7 +151,6 @@ func TestCaptureRejectsEmptyFoundDuring(t *testing.T) { } func TestCaptureRejectsBadEnumAndSweepsPlaceholder(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) _, err := Capture(CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "x", Severity: "bogus", @@ -178,7 +168,6 @@ func TestCaptureRejectsBadEnumAndSweepsPlaceholder(t *testing.T) { } func TestResolveTransition(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) res, err := Capture(CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "body", Severity: SeverityMajor, @@ -198,13 +187,12 @@ func TestResolveTransition(t *testing.T) { t.Errorf("source still present at %s", res.Path) } lr, _ := List(ListRequest{RepoRoot: repo, IssuesRoot: ir, State: StateResolved}) - if len(lr.Issues) != 1 || lr.Issues[0].Resolution != "patched in fn-9" || lr.Issues[0].Updated != "2026-07-07" { + if len(lr.Issues) != 1 || lr.Issues[0].Resolution != "patched in fn-9" { t.Fatalf("resolved issue = %+v (skipped=%v)", lr.Issues, lr.Skipped) } } func TestResolveConflictAndUnknown(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) res, _ := Capture(CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, @@ -224,7 +212,6 @@ func TestResolveConflictAndUnknown(t *testing.T) { } func TestWontfixTransition(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) res, _ := Capture(CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, @@ -240,7 +227,6 @@ func TestWontfixTransition(t *testing.T) { } func TestListSortsNumericallyAndAll(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) // Force ids out of lexical order: iss-2, iss-10, iss-1. for _, id := range []string{"iss-2", "iss-10", "iss-1"} { @@ -279,7 +265,6 @@ func TestListToleratesVirginLedgerAndStrayFiles(t *testing.T) { t.Errorf("List must not create the ledger dir") } // Stray README is ignored; corrupt iss file is surfaced in Skipped. - pinDate(t, "2026-07-07") if _, err := Capture(CaptureRequest{ RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor, Category: "bug", Source: "manual-test", Slug: "ok", FoundDuring: "t", @@ -302,7 +287,6 @@ func TestListToleratesVirginLedgerAndStrayFiles(t *testing.T) { } func TestStatusCountsAndRecentOpen(t *testing.T) { - pinDate(t, "2026-07-07") repo, ir := ledger(t) var ids []string for i := 0; i < 3; i++ { @@ -329,6 +313,67 @@ func TestStatusCountsAndRecentOpen(t *testing.T) { } } +// TestStatusRecentOpenDerivedPriority proves the status board applies the same +// derived-priority projection as List over its recent-open slice: unblocked +// issues first (highest severity first), blocked ones last regardless of +// severity, each annotated with its still-open blockers. The seed's newest-first +// pre-sort order differs from the priority order, so removing the prioritise() +// call in Status would leave this test red. +func TestStatusRecentOpenDerivedPriority(t *testing.T) { + repo, ir := ledger(t) + seed := []struct { + id string + sev Severity + by []string + }{ + {"iss-1", SeverityMinor, nil}, // unblocked, blocker target + {"iss-2", SeverityCritical, []string{"iss-1"}}, // blocked by open iss-1 + {"iss-3", SeverityMajor, nil}, // unblocked + {"iss-4", SeverityNitpick, nil}, // unblocked + } + for _, s := range seed { + if _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: s.sev, + Category: "bug", Source: "manual-test", Slug: "s", FoundDuring: "t", + ForceID: s.id, BlockedBy: s.by, + }); err != nil { + t.Fatalf("seed %s: %v", s.id, err) + } + } + st, err := Status(StatusRequest{RepoRoot: repo, IssuesRoot: ir}) + if err != nil { + t.Fatal(err) + } + // Unblocked by severity desc (iss-3 major, iss-1 minor, iss-4 nitpick), then + // the blocked iss-2 last despite its critical severity. A pure newest-first + // ordering would be iss-4, iss-3, iss-2, iss-1. + want := []string{"iss-3", "iss-1", "iss-4", "iss-2"} + if got := recentIDs(st.RecentOpen); !equalStrs(got, want) { + t.Fatalf("recent-open order = %v want %v", got, want) + } + for _, iss := range st.RecentOpen { + if iss.ID == "iss-2" { + if strings.Join(iss.BlockedByOpen, ",") != "iss-1" { + t.Fatalf("iss-2 blocked_by_open = %v want [iss-1]", iss.BlockedByOpen) + } + } else if len(iss.BlockedByOpen) != 0 { + t.Fatalf("%s wrongly blocked: %v", iss.ID, iss.BlockedByOpen) + } + } +} + +func equalStrs(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func recentIDs(issues []Issue) []string { out := make([]string, len(issues)) for i, is := range issues { @@ -348,7 +393,6 @@ func TestStatusAndListAreReadOnly(t *testing.T) { } func TestPathUnsafeSymlinkedLedger(t *testing.T) { - pinDate(t, "2026-07-07") repo := t.TempDir() real := filepath.Join(repo, "real-issues") if err := os.MkdirAll(real, 0o755); err != nil { @@ -366,3 +410,109 @@ func TestPathUnsafeSymlinkedLedger(t *testing.T) { t.Fatalf("want ErrPathUnsafe, got %v", err) } } + +func TestCaptureWritesBlockedByAndReadsBack(t *testing.T) { + repo, ir := ledger(t) + if _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "root cause", Severity: SeverityMinor, + Category: "bug", Source: "manual-test", Slug: "root", FoundDuring: "t", + }); err != nil { + t.Fatal(err) + } + res, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "dependent", Severity: SeverityMajor, + Category: "bug", Source: "manual-test", Slug: "dep", FoundDuring: "t", + BlockedBy: []string{"iss-1"}, + }) + if err != nil { + t.Fatalf("Capture with blocked_by: %v", err) + } + if res.ID != "iss-2" { + t.Fatalf("id = %s want iss-2", res.ID) + } + lr, err := List(ListRequest{RepoRoot: repo, IssuesRoot: ir, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + var dep *Issue + for i := range lr.Issues { + if lr.Issues[i].ID == "iss-2" { + dep = &lr.Issues[i] + } + } + if dep == nil { + t.Fatalf("iss-2 not read back: %+v", lr.Issues) + } + if strings.Join(dep.BlockedBy, ",") != "iss-1" { + t.Fatalf("blocked_by = %v want [iss-1]", dep.BlockedBy) + } +} + +// TestDerivedPriorityUnblockedFirstThenSeverity proves the read-time projection: +// List orders unblocked issues (highest severity first) ahead of blocked ones, +// annotates each blocked row with its still-open blockers, and re-derives once a +// blocker is resolved out of open/. +func TestDerivedPriorityUnblockedFirstThenSeverity(t *testing.T) { + repo, ir := ledger(t) + seed := []struct { + id string + sev Severity + by []string + }{ + {"iss-1", SeverityMinor, nil}, // blocker target, unblocked + {"iss-2", SeverityCritical, []string{"iss-1"}}, // blocked by open iss-1 + {"iss-3", SeverityMajor, nil}, // unblocked + {"iss-4", SeverityNitpick, nil}, // unblocked + } + for _, s := range seed { + if _, err := Capture(CaptureRequest{ + RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: s.sev, + Category: "bug", Source: "manual-test", Slug: "s", FoundDuring: "t", + ForceID: s.id, BlockedBy: s.by, + }); err != nil { + t.Fatalf("seed %s: %v", s.id, err) + } + } + + lr, err := List(ListRequest{RepoRoot: repo, IssuesRoot: ir, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + // Unblocked by severity desc (iss-3 major, iss-1 minor, iss-4 nitpick), then + // the blocked iss-2 last despite its critical severity. + wantOrder := []string{"iss-3", "iss-1", "iss-4", "iss-2"} + gotOrder := recentIDs(lr.Issues) + for i := range wantOrder { + if gotOrder[i] != wantOrder[i] { + t.Fatalf("order = %v want %v", gotOrder, wantOrder) + } + } + // iss-2 is annotated with its still-open blocker; the rest are clear. + for _, iss := range lr.Issues { + if iss.ID == "iss-2" { + if strings.Join(iss.BlockedByOpen, ",") != "iss-1" { + t.Fatalf("iss-2 blocked_by_open = %v want [iss-1]", iss.BlockedByOpen) + } + } else if len(iss.BlockedByOpen) != 0 { + t.Fatalf("%s wrongly blocked: %v", iss.ID, iss.BlockedByOpen) + } + } + + // Resolve the blocker: iss-2 becomes unblocked and sorts to the front by its + // critical severity. + if _, err := Resolve(ResolveRequest{RepoRoot: repo, IssuesRoot: ir, ID: "iss-1", Resolution: "fixed"}); err != nil { + t.Fatal(err) + } + lr2, err := List(ListRequest{RepoRoot: repo, IssuesRoot: ir, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + if got := recentIDs(lr2.Issues); got[0] != "iss-2" { + t.Fatalf("after resolve, head = %v want iss-2 first", got) + } + for _, iss := range lr2.Issues { + if iss.ID == "iss-2" && len(iss.BlockedByOpen) != 0 { + t.Fatalf("iss-2 still blocked after resolving iss-1: %v", iss.BlockedByOpen) + } + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 427a4ee6..edfce2a2 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -447,7 +447,7 @@ func (p *stdinPrompter) Prompt(key string, choices []string, def string) string // appends an issue; list/resolve/wontfix are thin consumers of capture core. // (promote is skill-orchestrated, never a CLI sub-verb — brief 04-surfaces/06.) func newCaptureCommand(asJSON *bool) *cobra.Command { - var severity, category, source, slug, foundDuring, foundAt string + var severity, category, source, slug, foundDuring, foundAt, blockedBy string captureCmd := &cobra.Command{ Use: "capture [text]", @@ -470,7 +470,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if len(st.RecentOpen) > 0 { fmt.Fprintf(w, "recent open:\n") for _, iss := range st.RecentOpen { - fmt.Fprintf(w, " %s %s %s\n", iss.ID, iss.Severity, iss.Slug) + fmt.Fprintf(w, " %s %s %s%s\n", iss.ID, iss.Severity, iss.Slug, blockedNote(iss)) } } }) @@ -481,6 +481,10 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { if sl == "" { sl = deriveSlug(text) } + blocked, err := parseBlockedBy(blockedBy) + if err != nil { + return err + } req := capture.CaptureRequest{ RepoRoot: cwd, Text: text, @@ -490,6 +494,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { Slug: sl, FoundDuring: orDefault(foundDuring, "manual-capture"), FoundAt: foundAt, + BlockedBy: blocked, } res, err := capture.Capture(req) if err != nil { @@ -506,6 +511,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { captureCmd.Flags().StringVar(&slug, "slug", "", "override the slug derived from the text") captureCmd.Flags().StringVar(&foundDuring, "found-during", "", "session/command context (default manual-capture)") captureCmd.Flags().StringVar(&foundAt, "found-at", "", "optional repo-relative path or conceptual location") + captureCmd.Flags().StringVar(&blockedBy, "blocked-by", "", "comma-separated iss-ids this issue is blocked by") // list — the earned SD001 exception: a filter flag is REQUIRED. var lsOpen, lsResolved, lsWontfix, lsAll bool @@ -528,7 +534,7 @@ func newCaptureCommand(asJSON *bool) *cobra.Command { } return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { for _, iss := range res.Issues { - fmt.Fprintf(w, "%s %s %s %s\n", iss.ID, iss.Status, iss.Severity, iss.Slug) + fmt.Fprintf(w, "%s %s %s %s%s\n", iss.ID, iss.Status, iss.Severity, iss.Slug, blockedNote(iss)) } for _, sk := range res.Skipped { fmt.Fprintf(w, " skipped %s: %s\n", sk.Path, sk.Error) @@ -625,6 +631,40 @@ func deriveSlug(text string) string { var slugNonAlnumRe = regexp.MustCompile(`[^a-z0-9]+`) +// issIDRe validates a --blocked-by token at the CLI boundary (mirrors the core +// ^iss-[0-9]+$ schema constraint). +var issIDRe = regexp.MustCompile(`^iss-[0-9]+$`) + +// parseBlockedBy splits the comma-separated --blocked-by value into iss-ids, +// dropping blanks and rejecting any token that is not ^iss-[0-9]+$. An empty +// input yields a nil slice (the field is omitted). +func parseBlockedBy(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var ids []string + for _, tok := range strings.Split(raw, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + if !issIDRe.MatchString(tok) { + return nil, fmt.Errorf("capture: --blocked-by token %q must match iss-N", tok) + } + ids = append(ids, tok) + } + return ids, nil +} + +// blockedNote renders the derived-priority annotation for a row: when the issue +// has blocked_by targets still open, " [blocked-by iss-1,iss-2]"; otherwise "". +func blockedNote(iss capture.Issue) string { + if len(iss.BlockedByOpen) == 0 { + return "" + } + return " [blocked-by " + strings.Join(iss.BlockedByOpen, ",") + "]" +} + func orDefault(v, def string) string { if v == "" { return def diff --git a/internal/surface/cli/cli_test.go b/internal/surface/cli/cli_test.go index 36d1af7d..11e304e4 100644 --- a/internal/surface/cli/cli_test.go +++ b/internal/surface/cli/cli_test.go @@ -222,6 +222,66 @@ func TestHistoryCaptureWiredAndRedacts(t *testing.T) { } } +// TestCaptureBlockedByWiredAndAnnotated proves the --blocked-by flag reaches +// capture.Capture from the CLI (writing the dependency edge), that an invalid +// token is rejected at the boundary, and that the derived-priority view renders +// unblocked-first with a [blocked-by …] annotation on the blocked row. +func TestCaptureBlockedByWiredAndAnnotated(t *testing.T) { + repo := t.TempDir() + t.Chdir(repo) + + // iss-1: the blocker target (minor, unblocked). + out := runCLI(t, "capture", "root cause", "--slug", "root", "--json") + var r1 struct { + ID string `json:"id"` + Path string `json:"path"` + } + if err := json.Unmarshal(out, &r1); err != nil { + t.Fatalf("capture output not JSON: %v\n%s", err, out) + } + if r1.ID != "iss-1" { + t.Fatalf("first id = %q want iss-1", r1.ID) + } + + // iss-2: critical but blocked by the still-open iss-1. + out2 := runCLI(t, "capture", "dependent thing", "--slug", "dep", + "--severity", "critical", "--blocked-by", "iss-1", "--json") + var r2 struct { + ID string `json:"id"` + Path string `json:"path"` + } + if err := json.Unmarshal(out2, &r2); err != nil { + t.Fatalf("blocked capture output not JSON: %v\n%s", err, out2) + } + if r2.ID != "iss-2" { + t.Fatalf("second id = %q want iss-2", r2.ID) + } + // The edge reached disk. + body, err := os.ReadFile(r2.Path) + if err != nil { + t.Fatalf("iss-2 unreadable: %v", err) + } + if !strings.Contains(string(body), "blocked_by: [iss-1]") { + t.Fatalf("blocked_by not written to iss-2:\n%s", body) + } + + // Derived view: unblocked iss-1 ahead of the blocked, annotated iss-2. + list := string(runCLI(t, "capture", "list", "--open")) + i1 := strings.Index(list, "iss-1") + i2 := strings.Index(list, "iss-2") + if i1 < 0 || i2 < 0 || i1 > i2 { + t.Fatalf("expected iss-1 before iss-2 (unblocked-first):\n%s", list) + } + if !strings.Contains(list, "[blocked-by iss-1]") { + t.Fatalf("expected [blocked-by iss-1] annotation:\n%s", list) + } + + // An invalid --blocked-by token is rejected at the boundary. + if _, err := runCLIErr(t, "capture", "bad edge", "--blocked-by", "bogus"); err == nil { + t.Fatalf("expected error for invalid --blocked-by token") + } +} + // runCLIErr executes the command tree and returns its stdout/stderr plus the // error, so a gate's non-zero exit can be asserted rather than fataled on. func runCLIErr(t *testing.T, args ...string) ([]byte, error) {