Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,34 @@ We dogfood: drive this repo's planning with the tool itself.
- **Lifecycle:** `task start|next|ready|complete|defer|deprecate <slug>...` (verbs
name the destination status; `next`/`ready` replace the old promote/demote, which
still work as hidden aliases). `defer` takes `--until <YYYY-MM-DD>` (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 <slug>` lists them numbered; `--check/--uncheck <n>` flips one;
`--defer|--wontfix|--tracked|--na <n> --reason <why>` sets a state; and
`--add <text>` / `--remove <n>` / `--replace <n> --text <new>` 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 <audit> <code> --status <v> [--pr N] [--note <text>]` 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
<task-id>`) — 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
<id>` for an epic's task roster, and `task list -o table -c
slug,status,description` for a compact, byte-stable table. `--json` is compact
Expand Down
24 changes: 23 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions internal/tui/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
116 changes: 116 additions & 0 deletions planning/adrs/0007-planning-state-vocabularies.md
Original file line number Diff line number Diff line change
@@ -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 <id>`) — 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`.
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions renovate.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Loading