From 8beefa8e294df54207edc672e570f4386cc8025e Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Tue, 25 Aug 2026 07:48:18 -0400 Subject: [PATCH 1/2] docs: catch the guides up to three sessions of planning-model change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lookback prompted by the observation that development had outrun introspection. It had — in the doc layer specifically. CLAUDE.md is the file every agent session reads first, and it contained **zero** mentions of `task ac`, `audit finding`, criterion states, `tracked`, or `settled`. An agent following it would hand-edit markdown for exactly the fields the tool now owns, which is the practice all of this work retired. Worse, it documented `task complete` as an unconditional verb — it now REFUSES a task with an unexplained unmet criterion, so an agent scripting against the old contract would hit an exit 11 the guide gave no warning about. Both surfaces and both vocabularies are now in it. ARCHITECTURE.md gains the two patterns that are genuinely architectural rather than incidental: a word meaning the same thing in two places is spelled once (the shared resolution pool, the overlap-not-subset modelling, the delegation of glyphs), and a state the tool cannot write is a state nobody can be held to. ADR-0007 records the vocabulary decision itself. It had been settled properly — with alternatives weighed and written down — but only inside a completed task's body, which is not where anyone looks for a load-bearing format decision. It covers the checkbox-plus-suffix representation, the shared pool, `tracked` replacing `landed`, and the two write-time invariants, with the rejected alternatives. Also closes a gap the lookback found in the code: the TUI's completion refusal was claimed in a commit message and asserted by nothing. The shared seed repo's tasks carry no acceptance criteria, so no test ever exercised the path. It now has one, with its own fixture so the seed stays untouched, covering the refusal, the error flash, and the file being left alone. Coverage was checked and is not the problem: 82–90% across domain, core, store, cli, tui, theme, and wire. --- CLAUDE.md | 25 +++- docs/ARCHITECTURE.md | 24 +++- internal/tui/action_test.go | 43 +++++++ .../adrs/0007-planning-state-vocabularies.md | 116 ++++++++++++++++++ 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 planning/adrs/0007-planning-state-vocabularies.md 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`. From db4ccdd1e474ddfa35d41148083811efa59121fa Mon Sep 17 00:00:00 2001 From: Andy Eschbacher Date: Tue, 25 Aug 2026 07:55:19 -0400 Subject: [PATCH 2/2] chore: add renovate json to repo --- ...o-and-github-actions-dependency-updates.md | 44 +++++++++++++++++ renovate.json | 49 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 planning/tasks/6g3hg11twy28-adopt-renovate-for-automated-go-and-github-actions-dependency-updates.md create mode 100644 renovate.json 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" + } + ] +}