diff --git a/CLAUDE.md b/CLAUDE.md index 71677ea5..64edf66e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,13 +35,34 @@ We dogfood: drive this repo's planning with the tool itself. - **Lifecycle:** `task start|next|ready|complete|defer|deprecate ...` (verbs name the destination status; `next`/`ready` replace the old promote/demote, which still work as hidden aliases). `defer` takes `--until ` (snooze). -- **Read/edit:** `task list|show|set|edit|append`, `epic list|show`, - `audit new|list|show|findings|lint|close|reopen|defer`, + **`complete` REFUSES** a task with an acceptance criterion that is unmet and gives no + reason — tick it, give it a state (below), or pass `--force`. Same rule as `audit + close` refusing while findings are open. +- **Read/edit:** `task list|show|set|edit|append|ac`, `epic list|show`, + `audit new|list|show|findings|finding|lint|close|reopen|defer`, `research new|list|show|path|set|edit|append` (no lifecycle verbs — research has no status). Two faces of mutation: **agent** (field-level `task set`; body via `task append` / `task set --body|--body-file`, all scriptable + atomic) vs **human** (`task edit` — $EDITOR on the whole file, re-validated on save). +- **Acceptance criteria — never hand-edit them; `task ac` owns them.** A criterion is + `- [x]` (met) or `- [ ]` (not met), and a not-met one may say WHY with a trailing + suffix: `- [ ] Ship the migration · **deferred:** waiting on the schema ADR`. States + are `deferred | wontfix | tracked | n/a`, each REQUIRING a reason. + `task ac ` lists them numbered; `--check/--uncheck ` flips one; + `--defer|--wontfix|--tracked|--na --reason ` sets a state; and + `--add ` / `--remove ` / `--replace --text ` change which criteria + exist. A criterion carrying a state has been DECIDED and does not block `complete`; + only a silently unticked box does. +- **Finding status — never hand-edit it; `audit finding` owns it.** + `audit finding --status [--pr N] [--note ]` writes the + `**Status:**` and the `**Resolution:**` paragraph in one validated, atomic edit. + Statuses: `open | in-progress | fixed | tracked | deferred | superseded | wontfix`. + **`tracked` means handed to a task and REQUIRES the destination** (`tracked by + `) — it counts toward the audit's done band, because the audit's interest + concludes when a finding is transferred. An audit's headline percent is the **settled** + share (everything with a terminal disposition), so 100% is exactly when it is ready to + close. - **Triage (agents, cheapest first):** lead with the terse path — `epic show ` for an epic's task roster, and `task list -o table -c slug,status,description` for a compact, byte-stable table. `--json` is compact diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 52654626..d4a1f6da 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -270,6 +270,22 @@ adapter capabilities rather than leaked persistence. configuration entities and is implemented by `configstore.FS`; CLI, focused TUI, full TUI, and a future served adapter all call `ConfigurationService` rather than importing filesystem configuration packages. +- **A word that means the same thing in two places is spelled once.** + `domain/resolution.go` holds the vocabulary an audit finding and an acceptance + criterion share — `deferred`, `wontfix`, `tracked` — and each entity declares its own + full set from that pool plus its own additions. Modelled as an OVERLAP, not a subset: + `met` is not a finding status and `superseded` is not a criterion state, and claiming + otherwise is exactly what lets the two drift. `theme.CriterionState` delegates the + shared words to `theme.FindingStatus` for the same reason, so one word renders as one + mark. Drift tests fail if either side diverges. This exists because it already went + wrong: `landed` was legal in code, absent from the docs, and contradicted by an + assertion (finding M3 of `2026-08-17-finding-status-surface`). +- **A state the tool cannot write is a state nobody can be held to.** Every + closed-vocabulary field has a validated, atomic write verb — `task ac` for criterion + state, `audit finding` for finding status and its resolution note — because the + alternative is hand-edited markdown, which is how a vocabulary drifts from its own + documentation. Reads stay tolerant so `lint` can REPORT malformed data already on + disk; writes refuse to create it. ## Why these boundaries (and why not collapse them) Reviews periodically suggest folding the packages together ("Go favors fewer @@ -420,7 +436,7 @@ The CLI also has **golden snapshots** of the byte-stable machine contract (the `./...`). The single subprocess smoke layer (real binary, exit codes, lifecycle) lives in `cmd/tskflwctl/main_test.go`. `just test` + `just lint`. -## Status (2026-08-22) +## Status (2026-08-25) The layered shape now has two substantial primary consumers and several reusable application seams; it is no longer architecture held in reserve for a hypothetical UI: @@ -441,6 +457,12 @@ application seams; it is no longer architecture held in reserve for a hypothetic - Writes use atomic replacement/exclusive create, a repo-wide lock, content-version CAS, bounded retry for agent mutations, and parse-before-accept editor loops. Machine output is a versioned `internal/wire` contract with generated JSON Schema and golden coverage. +- The planning model's closed vocabularies now have write verbs rather than only linters. + An acceptance criterion carries a state and a reason (`task ac`), an audit finding + carries a status and a resolution paragraph (`audit finding`), and the words the two + share are declared once in `domain/resolution.go`. Two invariants are enforced at write + time rather than reported after: `task complete` refuses a task with an unexplained + unmet criterion, mirroring `audit close` refusing while findings are open. The atlas decision has now activated the narrow reusable workspace-opening boundary: `core.WorkspaceService` and `internal/workspacestore` can open an explicit local entry diff --git a/internal/tui/action_test.go b/internal/tui/action_test.go index a0c3d233..368b6d99 100644 --- a/internal/tui/action_test.go +++ b/internal/tui/action_test.go @@ -6,7 +6,10 @@ import ( "github.com/charmbracelet/x/ansi" + "github.com/andy-esch/taskflow/internal/core" "github.com/andy-esch/taskflow/internal/domain" + "github.com/andy-esch/taskflow/internal/store" + "github.com/andy-esch/taskflow/internal/testutil" ) // cursorTo drives the action menu's cursor onto the given verb via j-presses (so @@ -416,3 +419,43 @@ func TestModel_ActionMenuFitsTerminal(t *testing.T) { } } } + +// The completion gate reaches the TUI too. moveTask passes force=false, so a task whose +// acceptance criteria are unticked and unexplained is refused here exactly as it is on the +// CLI, and the refusal has to be VISIBLE — an error flash, not a silent no-move. +// +// This was asserted in a commit message before it was asserted in a test: the shared seed +// repo's tasks carry no acceptance criteria, so nothing exercised the path. +func TestModel_CompleteRefusedOnUnexplainedCriteria(t *testing.T) { + r := testutil.NewRepo(t) + r.Task("in-progress", "gated.md", "---\nstatus: in-progress\nepic: 01-test\ndescription: d\n---\n"+ + "# Gated\n\n## Acceptance criteria\n\n- [x] done\n- [ ] silently unticked\n") + r.Epic("01-test.md", "---\nstatus: active\ndescription: a test epic\npriority: high\n---\n# Test epic\n") + svc := core.NewService(store.NewFS(r.Root)) + + msg := moveTask(svc, "gated", transition{to: string(domain.StatusCompleted)})() + errMsg, ok := msg.(actionErrMsg) + if !ok { + t.Fatalf("want the gate to refuse with actionErrMsg, got %T (%v)", msg, msg) + } + if !strings.Contains(errMsg.err.Error(), "no reason") { + t.Errorf("the refusal should say what is wrong: %v", errMsg.err) + } + + // …and it must surface as a RED flash the reader can see, not a quiet failure. + m := loaded(t, 120, 40) + tm, _ := m.Update(errMsg) + m = tm.(Model) + if m.flash == "" || !m.flashErr { + t.Errorf("a refused completion must set an error flash, got %q (err=%v)", m.flash, m.flashErr) + } + + // The task is still in-progress on disk — a refusal writes nothing. + tk, _, err := svc.ShowTask("gated") + if err != nil { + t.Fatal(err) + } + if tk.Status != domain.StatusInProgress { + t.Errorf("a refused completion must not move the task, got %q", tk.Status) + } +} diff --git a/planning/adrs/0007-planning-state-vocabularies.md b/planning/adrs/0007-planning-state-vocabularies.md new file mode 100644 index 00000000..d6aee58f --- /dev/null +++ b/planning/adrs/0007-planning-state-vocabularies.md @@ -0,0 +1,116 @@ +--- +status: accepted +date: "2026-08-25" +deciders: [andy-esch] +tags: [adr, domain, planning-model, cli, vocabulary] +supersedes: [] +superseded_by: null +--- + +# ADR-0007: Planning state vocabularies — shared words, written by the tool + +> Follows the ADR format established in [0001-adopt-adrs](0001-adopt-adrs.md). Sits beside +> [0003-stable-key-id-addressed-storage](0003-stable-key-id-addressed-storage.md), which +> governs where entities live and what is authoritative in frontmatter; this one governs the +> closed vocabularies inside a document's BODY and how they are written. + +## Context and Problem Statement + +Two body-level fields carry a closed vocabulary: an audit finding's `**Status:**` and, +until recently, an acceptance criterion's checkbox. Both were maintained by hand-editing +markdown, and both drifted. + +The finding vocabulary drifted first, and visibly. `landed` was legal in code, absent from +the consumer-facing table, and contradicted by an assertion in the very comment that claimed +to hold the two in sync (finding M3 of `2026-08-17-finding-status-surface`). No audit in the +corpus had ever used it. Meanwhile the corpus was improvising a word the vocabulary lacked: +7 of 13 `deferred` findings were not deferrals but handoffs, written in prose as +`deferred → tracked in task X` by two authors months apart. + +The criterion "vocabulary" was a single bit. An unchecked box meant *not yet*, *won't do*, +and *no longer applies* all at once, so a task could sit at 3/7 forever with no way to say +why the other four were not coming. + +Both problems have the same root: **a value the tool cannot write is a value nobody can be +held to.** Linting a field the tool only reads catches typos after they land; it cannot stop +the vocabulary and its documentation from parting ways. + +## Decision + +**1. Criterion state is a checkbox plus an optional suffix.** + +``` +- [x] Criterion that is done +- [ ] Criterion still to do +- [ ] Criterion parked · **deferred:** waiting on the schema ADR +- [ ] Criterion abandoned · **wontfix:** superseded by the table layout +- [ ] Criterion handed off · **tracked:** carried by 6g3ag8py12y9 +- [ ] Criterion that stopped applying · **n/a:** the tile grid was dropped +``` + +The bracket keeps its existing binary meaning and the suffix refines the NOT-MET case. +Chosen over a replacement marker specifically because it is **additive**: every criterion +ever authored still parses, no migration is needed, and a body written before the vocabulary +existed serialises unchanged. Each non-binary state REQUIRES a reason — a deferral with no +why is indistinguishable from an oversight, which is the defect the vocabulary exists to +remove. + +**2. Words that mean the same thing in two places are spelled once.** + +`domain/resolution.go` holds the shared pool — `deferred`, `wontfix`, `tracked` — and each +entity declares its own full set from that pool plus its own additions. It is modelled as an +**overlap, not a subset**: `met` is not a finding status and `superseded` is not a criterion +state, so claiming either set contains the other would be a lie, and the lie is precisely +what lets them diverge unnoticed. A test fails if a shared word is spelled differently in +either set, and `theme.CriterionState` delegates the shared words to `theme.FindingStatus` +so one word renders as one mark. + +**3. `tracked` replaces `landed`.** `landed` had zero corpus uses; `tracked` had seven +improvised ones. It means "transferred, not abandoned" and REQUIRES a destination +(`tracked by `) — a handoff with nowhere to follow is the improvisation it replaces. For +an audit it counts toward **done**, because the audit's interest concludes when a finding is +transferred; for a task it does NOT count as met, because routing a criterion elsewhere +splits the work rather than finishing it. That asymmetry is deliberate: an audit is a report, +a task is work. + +**4. Every closed vocabulary gets a validated, atomic write verb.** `task ac` owns criterion +state; `audit finding` owns finding status and its `**Resolution:**` paragraph. Reads stay +tolerant so `lint` can REPORT malformed data already on disk; writes refuse to create it. + +**5. Invariants are enforced at write time where a linter would only report them.** +`task complete` refuses a task with an unmet, unexplained criterion — the counterpart of +`audit close` refusing while findings are open. The gate is only tolerable BECAUSE of +decision 1: before it, "refuse on unmet criteria" meant "tick every box or never finish". +Now the gate blocks silence, not disagreement. + +## Consequences + +- Schema 1.45–1.48 carry the wire half. 1.47 is **not additive** — `landed` is no longer + accepted — though no audit ever used it. +- An audit's headline percent is the **settled** share, so 100% is exactly `ready to close`. + It previously counted only fixed findings, which let a closed, fully-resolved audit + display "77% fixed" on the same line as its own ready-to-close marker. +- The vocabularies are now derived into `schema`/`schema audit` rather than transcribed, so + the M3 failure cannot recur in the same shape. +- Hand-editing these fields still works and still lints. This ADR does not forbid it; it + says the tool owns the well-formed path and the docs point at it. + +## Alternatives considered + +- **A separate criterion enum overlapping the finding one.** Rejected: two tables free to + drift is exactly what M3 recorded. +- **A subset relationship.** Rejected as untrue in both directions. +- **A new marker instead of checkbox+suffix** (e.g. `- [~]`). Rejected: it would have needed + a migration and broken every existing renderer, for no gain over a suffix. +- **Requiring an id-shaped destination for `tracked`.** Rejected: a destination is + legitimately an epic, an ADR, or an external issue, and a Crockford regex would reject + `tracked by ADR-0003`. The check worth having is *resolution* — lint flagging a named id + that does not exist — which is not built. + +## Provenance + +Decided across `6g31g9f8x4cv` +(*let-an-acceptance-criterion-say-more-than-done-or-not-done*), whose body holds the +long-form reasoning and the rejected alternatives in more detail, and audits +`2026-08-17-finding-status-surface`, `2026-08-24-planning-state-vocabulary` (an adversarial +external review), and `2026-08-24-finding-note-and-vocabulary-selfreview`. diff --git a/planning/tasks/6g3hg11twy28-adopt-renovate-for-automated-go-and-github-actions-dependency-updates.md b/planning/tasks/6g3hg11twy28-adopt-renovate-for-automated-go-and-github-actions-dependency-updates.md new file mode 100644 index 00000000..8ab3ace4 --- /dev/null +++ b/planning/tasks/6g3hg11twy28-adopt-renovate-for-automated-go-and-github-actions-dependency-updates.md @@ -0,0 +1,44 @@ +--- +schema: 1 +id: 6g3hg11twy28 +status: completed +epic: 21-code-quality-architecture-hardening +description: Configure Renovate with gomodTidy, Charm ecosystem grouping, coordinated Go toolchain bumps, and Dependency Dashboard major version gates. +effort: S +tier: 3 +priority: medium +autonomy_level: 3 +tags: [ci, dependencies, infra, renovate] +created: "2026-08-25" +updated_at: "2026-08-25" +started_at: "2026-08-25" +completed_at: "2026-08-25" +--- +# Adopt Renovate for automated Go and GitHub Actions dependency updates + +## Objective + +Automate dependency updates and security patches across Go modules (`go.mod`/`go.sum`) and GitHub Actions workflows (`.github/workflows/*.yml`) using Renovate, mirroring the battle-tested configuration patterns from `desirelines`. + +`taskflow` has strict CI and architecture guards (e.g. `just tidy-check`, `just docs-check`, `golangci-lint` v2 schema, `govulncheck`), and depends on rapidly evolving Charm v2 beta libraries. Renovate configuration must ensure: +1. `postUpdateOptions: ["gomodTidy"]` is always run so `go mod tidy -diff` passes in CI. +2. Charm v2 ecosystem packages (`charm.land/*`, `github.com/charmbracelet/*`, `github.com/muesli/*`) bump together in a single coordinated PR to avoid peer-dependency mismatch breaks. +3. Go version updates are grouped across `go.mod`, `ci.yml`, and `release.yml`. +4. Major updates are held behind the Dependency Dashboard for human review. +5. Non-major updates are grouped by manager and batched with a 3-day minimum release age to protect against supply-chain churn. + +## Acceptance criteria + +- [x] `renovate.json` is created at the repository root extending `config:recommended`, `:dependencyDashboard`, `:semanticCommits`, and `group:monorepos`. +- [x] `postUpdateOptions: ["gomodTidy"]` is configured so `go.sum` and `go.mod` remain tidy on module updates. +- [x] Charm ecosystem packages (`charm.land/*`, `github.com/charmbracelet/*`, `github.com/muesli/*`) are grouped into a dedicated `charm` package rule. +- [x] Go version bumps across `go.mod`, `.github/workflows/ci.yml`, and `.github/workflows/release.yml` are grouped under a `go version` package rule. +- [x] GitHub Actions workflow updates are grouped into a `github actions` package rule. +- [x] Major version updates require manual approval via `dependencyDashboardApproval: true` with the `requires-review` label. +- [x] Non-major updates are grouped by manager with `minimumReleaseAge: "3 days"` and a weekly schedule (`before 6am on monday`). +- [x] `just tidy-check` and `just docs-check` pass cleanly with the new configuration in place. + +## Out of scope + +- Setting up automated merge (`automerge: true`) — all dependency PRs should go through normal CI and human review. +- Multi-language manager configs (npm, uv, terraform) — `taskflow` is a pure Go and GitHub Actions repo. diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..7c104796 --- /dev/null +++ b/renovate.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":dependencyDashboard", + ":semanticCommits", + "group:monorepos" + ], + "timezone": "America/New_York", + "schedule": ["before 6am on monday"], + "updateNotScheduled": false, + "rebaseWhen": "conflicted", + "minimumReleaseAge": "3 days", + "prConcurrentLimit": 3, + "prHourlyLimit": 2, + "postUpdateOptions": ["gomodTidy"], + "packageRules": [ + { + "description": "Group all non-major updates by manager to keep PR volume low while isolating majors for individual review", + "matchUpdateTypes": ["minor", "patch", "pin", "digest"], + "groupName": "all non-major ({{manager}})" + }, + { + "description": "Gate major version bumps behind the Dependency Dashboard for human review", + "matchUpdateTypes": ["major"], + "dependencyDashboardApproval": true, + "labels": ["requires-review"] + }, + { + "description": "Group the Charmbracelet ecosystem so interrelated Bubble Tea / Lip Gloss / Bubbles v2 packages bump together", + "matchPackageNames": [ + "/^charm\\.land\\//", + "/^github\\.com\\/charmbracelet\\//", + "/^github\\.com\\/muesli\\//" + ], + "groupName": "charm" + }, + { + "description": "Group Go toolchain and runtime version updates across go.mod and GitHub Actions workflows", + "matchDepNames": ["go", "golang"], + "groupName": "go version" + }, + { + "description": "Group GitHub Actions workflow updates into one PR", + "matchManagers": ["github-actions"], + "groupName": "github actions" + } + ] +}