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
Binary file modified assets/tui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions assets/vhs/tui.tape
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,9 @@ Sleep 1s
Enter
Sleep 1.5s

# scroll down
Down 10
Sleep 0.8s

Type "q"
Sleep 750ms
37 changes: 31 additions & 6 deletions internal/cli/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,12 @@ func SummaryHuman(w io.Writer, st Style, s core.Summary) error {
fmt.Fprintf(w, "\n%s\n", st.Bold(fmt.Sprintf("Open audits (%d)", len(s.OpenAudits))))
rows := make([][]string, 0, len(s.OpenAudits))
for _, a := range s.OpenAudits {
bar := fmt.Sprintf("%s %s", st.SegmentBar(a.DoneFindings, a.ActiveFindings, a.DroppedFindings, a.Findings, 10), st.Percent(a.Percent()))
rows = append(rows, []string{" " + st.Bold(a.Slug), bar, theme.Counts(a.Resolved(), a.Findings), a.Area})
bar := fmt.Sprintf("%s %s", st.SegmentBar(a.DoneFindings, a.ActiveFindings, a.DroppedFindings, a.Findings, 10), st.AuditPercent(a.Percent()))
counts := theme.Counts(a.Resolved(), a.Findings)
if note := auditStateNote(st, a, false); note != "" {
counts += " " + note
}
rows = append(rows, []string{" " + st.Bold(a.Slug), bar, counts, a.Area})
}
writeTable(w, st.width, nil, rows)
}
Expand Down Expand Up @@ -560,6 +564,24 @@ func EpicShowHuman(w io.Writer, st Style, es core.EpicSummary, tasks []domain.Ta
return nil
}

// auditStateNote is the trailing call-to-action on an audit's progress line, kept
// in one place so the list / show / dashboard surfaces can't drift. An open,
// fully-triaged audit is flagged "✔ ready to close" (green) on every surface — the
// distinguisher that keeps a settled audit (full bar, 0% fixed) from reading like an
// untouched one. detail=true (single-audit views) additionally shows the routine
// "(N open)" while findings remain; list rows omit that to stay scannable. Returns
// "" (no leading space) when there is nothing to note.
func auditStateNote(st Style, a domain.Audit, detail bool) string {
switch {
case a.ReadyToClose():
return st.Green("✔ ready to close")
case detail && a.OpenFindings > 0:
return fmt.Sprintf("(%d open)", a.OpenFindings)
default:
return ""
}
}

// AuditsHuman writes a table of audits with finding counts.
func AuditsHuman(w io.Writer, st Style, audits []domain.Audit) error {
if len(audits) == 0 {
Expand All @@ -568,7 +590,10 @@ func AuditsHuman(w io.Writer, st Style, audits []domain.Audit) error {
rows := make([][]string, 0, len(audits))
for _, a := range audits {
bar := st.SegmentBar(a.DoneFindings, a.ActiveFindings, a.DroppedFindings, a.Findings, 8)
progress := fmt.Sprintf("%s %s %s", bar, st.Percent(a.Percent()), theme.Counts(a.Resolved(), a.Findings))
progress := fmt.Sprintf("%s %s %s", bar, st.AuditPercent(a.Percent()), theme.Counts(a.Resolved(), a.Findings))
if note := auditStateNote(st, a, false); note != "" {
progress += " " + note
}
rows = append(rows, []string{st.Bucket(string(a.Bucket)), st.Bold(a.Slug), progress, a.Area})
}
writeTable(w, st.width, []string{st.Dim("BUCKET"), st.Dim("AUDIT"), st.Dim("PROGRESS"), st.Dim("AREA")}, rows)
Expand Down Expand Up @@ -610,9 +635,9 @@ func AuditShowHuman(w io.Writer, st Style, a domain.Audit, findings []domain.Fin
field("updated", a.Updated)
}
bar := st.SegmentBar(a.DoneFindings, a.ActiveFindings, a.DroppedFindings, a.Findings, 10)
progress := fmt.Sprintf("%s %s %s", bar, st.Percent(a.Percent()), theme.Counts(a.Resolved(), a.Findings))
if a.OpenFindings > 0 {
progress += fmt.Sprintf(" (%d open)", a.OpenFindings)
progress := fmt.Sprintf("%s %s %s", bar, st.AuditPercent(a.Percent()), theme.Counts(a.Resolved(), a.Findings))
if note := auditStateNote(st, a, true); note != "" {
progress += " " + note
}
field("findings", progress)
// A status-grouped finding tree (glyph + code + title), mirroring epic show's
Expand Down
50 changes: 50 additions & 0 deletions internal/cli/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,56 @@ func TestAuditsJSONAndHuman(t *testing.T) {
}
}

// TestAuditProgressDistinguishesTriaged: the headline number must tell one story
// with the bar. A fully-triaged open audit (all superseded, 0 fixed) reads
// "0% fixed" like an untouched one, so the "ready to close" marker is what sets
// them apart; an untouched audit shows neither the marker nor a fixed share.
func TestAuditProgressDistinguishesTriaged(t *testing.T) {
triaged := domain.Audit{Slug: "2026-06-01-triaged", Bucket: domain.AuditOpen, Area: "store", Findings: 2, DroppedFindings: 2}
untouched := domain.Audit{Slug: "2026-06-01-untouched", Bucket: domain.AuditOpen, Area: "store", Findings: 2, OpenFindings: 2}

var out bytes.Buffer
if err := AuditsHuman(&out, NewStyle(false), []domain.Audit{triaged, untouched}); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
var triagedLine, untouchedLine string
for _, ln := range lines {
switch {
case strings.Contains(ln, "triaged"):
triagedLine = ln
case strings.Contains(ln, "untouched"):
untouchedLine = ln
}
}
if !strings.Contains(triagedLine, "0% fixed") {
t.Errorf("triaged audit should label its share %q:\n%s", "0% fixed", triagedLine)
}
if !strings.Contains(triagedLine, "ready to close") {
t.Errorf("fully-triaged open audit should be flagged ready to close:\n%s", triagedLine)
}
if strings.Contains(untouchedLine, "ready to close") {
t.Errorf("untouched audit must NOT be flagged ready to close:\n%s", untouchedLine)
}

// audit show: an open audit with open findings keeps the "(N open)" note; a
// settled one gets the ready-to-close marker instead.
out.Reset()
if err := AuditShowHuman(&out, NewStyle(false), untouched, nil, ""); err != nil {
t.Fatal(err)
}
if s := out.String(); !strings.Contains(s, "(2 open)") || strings.Contains(s, "ready to close") {
t.Errorf("audit show for an untouched audit should note (2 open), not ready to close:\n%s", s)
}
out.Reset()
if err := AuditShowHuman(&out, NewStyle(false), triaged, nil, ""); err != nil {
t.Fatal(err)
}
if s := out.String(); !strings.Contains(s, "ready to close") || strings.Contains(s, "open)") {
t.Errorf("audit show for a triaged audit should be ready to close, no open note:\n%s", s)
}
}

// TestAuditShowHuman_FindingTree: audit show renders meta + a status-grouped
// finding tree (lifecycle order, glyph-coded) + the body, mirroring epic show.
// A finding with no status must land in a trailing group, not vanish.
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/render/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ func (s Style) Percent(pct int) string {
return s.wrap(s.colorSeq(theme.Percent(pct)), theme.PercentLabel(pct))
}

// AuditPercent colors an audit's fixed-share percent on the same gray/yellow/green
// tiers as Percent, but labels it "N% fixed" (theme.AuditPercentLabel) so the
// number reads as the fixed share, not overall triage.
func (s Style) AuditPercent(pct int) string {
return s.wrap(s.colorSeq(theme.Percent(pct)), theme.AuditPercentLabel(pct))
}

// Green / Red / Warn style status glyphs and success/error/warning text.
func (s Style) Green(t string) string { return s.wrap(s.colorSeq(theme.ColorGreen), t) }
func (s Style) Red(t string) string { return s.wrap(s.colorSeq(theme.ColorRed), t) }
Expand Down
6 changes: 6 additions & 0 deletions internal/domain/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,9 @@ func (a Audit) Percent() int {
func (a Audit) Settled() bool {
return a.Findings > 0 && a.DoneFindings+a.DroppedFindings == a.Findings
}

// ReadyToClose is the call-to-action shared by the --json envelope (ready_to_close)
// and the human progress line: an OPEN audit that is Settled has no findings left
// to work and can be closed. A closed/deferred audit is not "ready to close" (it is
// already off the open board), so this is false there regardless of Settled.
func (a Audit) ReadyToClose() bool { return a.Bucket == AuditOpen && a.Settled() }
19 changes: 19 additions & 0 deletions internal/domain/audit_settled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,22 @@ func TestAuditSettled(t *testing.T) {
}
}
}

func TestAuditReadyToClose(t *testing.T) {
for _, c := range []struct {
name string
a Audit
want bool
}{
{"open + all done", Audit{Bucket: AuditOpen, Findings: 3, DoneFindings: 3}, true},
{"open + all dropped (triaged)", Audit{Bucket: AuditOpen, Findings: 2, DroppedFindings: 2}, true},
{"open + still has open", Audit{Bucket: AuditOpen, Findings: 2, OpenFindings: 1, DoneFindings: 1}, false},
{"open + no findings", Audit{Bucket: AuditOpen, Findings: 0}, false},
{"closed but settled (already off the board)", Audit{Bucket: AuditClosed, Findings: 1, DoneFindings: 1}, false},
{"deferred but settled", Audit{Bucket: AuditDeferred, Findings: 2, DroppedFindings: 2}, false},
} {
if got := c.a.ReadyToClose(); got != c.want {
t.Errorf("%s: ReadyToClose() = %v, want %v", c.name, got, c.want)
}
}
}
10 changes: 10 additions & 0 deletions internal/theme/progress.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ func PercentLabel(pct int) string { return fmt.Sprintf("%d%%", pct) }
// "100%") so it aligns in hand-laid-out columns / rows.
func PercentLabelPadded(pct int) string { return fmt.Sprintf("%3d%%", pct) }

// AuditPercentLabel qualifies an audit's percent as the FIXED share — "0% fixed"
// — so a bare number can't be misread as overall progress. An epic's percent is
// unambiguous (done/total), but an audit bands four dispositions: a fully-triaged
// audit is legitimately 0% fixed yet ready to close, so its number names its unit.
func AuditPercentLabel(pct int) string { return fmt.Sprintf("%d%% fixed", pct) }

// AuditPercentLabelPadded is AuditPercentLabel right-justified to 3 percent digits
// (" 0% fixed", "100% fixed") so audit list rows align.
func AuditPercentLabelPadded(pct int) string { return fmt.Sprintf("%3d%% fixed", pct) }

// Counts renders a done/total rollup ("7/12"). Width-justification for aligned
// columns is the caller's concern (CLI tables pad cells; the TUI measures + pads).
func Counts(done, total int) string { return fmt.Sprintf("%d/%d", done, total) }
7 changes: 5 additions & 2 deletions internal/tui/detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,11 @@ func renderAuditMeta(a domain.Audit, body string, width int, s *styles) string {
pct := a.Percent()
progress := fmt.Sprintf("%s %s %s",
s.segBar(a.DoneFindings, a.ActiveFindings, a.DroppedFindings, a.Findings, 12),
s.fg(theme.Percent(pct), theme.PercentLabel(pct)), theme.Counts(a.Resolved(), a.Findings))
if a.OpenFindings > 0 {
s.fg(theme.Percent(pct), theme.AuditPercentLabel(pct)), theme.Counts(a.Resolved(), a.Findings))
switch {
case a.ReadyToClose():
progress += " " + s.fg(theme.ColorGreen, "✔ ready to close")
case a.OpenFindings > 0:
progress += fmt.Sprintf(" (%d open)", a.OpenFindings)
}
detailField(&b, "audit", a.Slug, s)
Expand Down
10 changes: 7 additions & 3 deletions internal/tui/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,12 @@ func (d auditDelegate) Render(w io.Writer, m list.Model, index int, item list.It
tok := theme.Bucket(it.a.Bucket)
pct := it.a.Percent()
bar := st.segBar(it.a.DoneFindings, it.a.ActiveFindings, it.a.DroppedFindings, it.a.Findings, 8)
pctStr := st.fg(theme.Percent(pct), theme.PercentLabelPadded(pct))
pctStr := st.fg(theme.Percent(pct), theme.AuditPercentLabelPadded(pct))
counts := rollupCounts(it.a.Resolved(), it.a.Findings, it.countsW)
row(w, m, index, fmt.Sprintf("%s %s %s %s %s %s",
st.fg(tok.Color, tok.Glyph), bar, pctStr, counts, it.a.Slug, st.dim(it.a.Area)), st)
line := fmt.Sprintf("%s %s %s %s %s %s",
st.fg(tok.Color, tok.Glyph), bar, pctStr, counts, it.a.Slug, st.dim(it.a.Area))
if it.a.ReadyToClose() {
line += " " + st.fg(theme.ColorGreen, "✔ ready to close")
}
row(w, m, index, line, st)
}
2 changes: 1 addition & 1 deletion internal/wire/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ func ToAuditJSON(a domain.Audit) AuditJSON {
ID: a.ID, Slug: a.Slug, Bucket: string(a.Bucket), Area: a.Area, Date: a.Date, Updated: a.Updated,
Findings: a.Findings, OpenFindings: a.OpenFindings,
InProgressFindings: a.ActiveFindings, DoneFindings: a.DoneFindings, DroppedFindings: a.DroppedFindings,
ReadyToClose: a.Bucket == domain.AuditOpen && a.Settled(),
ReadyToClose: a.ReadyToClose(),
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
schema: 1
id: 6fq9zy102rea
status: completed
epic: 20-cli-ux-and-ergonomics
description: audit show/list % counts only fixed+landed while the bar bands 4 states, so a fully-superseded (triaged) audit reads 0%.
effort: Unknown
tier: 3
priority: high
autonomy_level: 3
tags: [audit, ux]
created: "2026-07-18"
updated_at: "2026-07-21"
started_at: "2026-07-21"
completed_at: "2026-07-21"
---
# Audit progress number contradicts its own bar

## Objective

`audit show` / `audit list` show a headline `N%` that counts only
fixed + landed (`FindingTally.Done`, internal/domain/finding.go:146), but the
segmented bar bands by four dispositions (open / in-progress / done / dropped).
So the number and the bar disagree in one widget: an audit whose findings are all
`superseded` (fully triaged into tasks, correctly closeable) shows `0%` —
indistinguishable *at the number* from an untouched audit. Confirmed: two
superseded findings render `▒▒▒▒▒▒▒▒▒▒ 0% 0/2`.

## Acceptance criteria

- [ ] The headline number and the bar tell one consistent story
- [ ] A fully-triaged audit (0 open, all superseded) is distinguishable from an untouched one
- [ ] The `audit list` `open` column reconciles with that same notion of progress

## Notes

- Options from the report: label it (`0% resolved`), show a stacked count
(`2 superseded · 2 in-progress · 0 fixed`), or make the number track the bar's gradient.
- Loci: `TallyFindings`/`FindingTally` (internal/domain/finding.go:143), the
headline `%` (`Percent`/`SegmentBar` in internal/cli/render/style.go).
- Source: https://github.com/andy-esch/taskflow/issues/105 (P1, High)

## Resolution (2026-07-21)

Kept the headline % meaning **fixed share** (never over-claims) and surfaced the
already-computed settled state as a green `✔ ready to close` marker — the
distinguisher between a triaged audit (full bar, 0% fixed) and an untouched one.

- `domain.Audit.ReadyToClose()` centralizes the open+Settled call-to-action (deduped from the JSON DTO); JSON `ready_to_close` unchanged.
- `theme.AuditPercentLabel`/`…Padded` label the audit number as `N% fixed` (epics keep the bare %).
- One `auditStateNote` helper drives `audit show`/`list`/dashboard; TUI list row + detail mirror it, so no surface drifts.
- `audit show`: `(N open)` while work remains, else `✔ ready to close`; list rows show only the ready marker to stay scannable.

All three acceptance criteria met: number+bar tell one story; triaged vs untouched distinguishable; `open` column reconciles (0 open ⟺ ready to close). Tests + lint green.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
schema: 1
id: 6fq9zy126tas
status: ready-to-start
epic: 24-data-model-evolution-stable-key-storage-read-model-content-occ
description: A non-Crockford id is misreported as 'no leading id', aborts the whole command (exit 11), and lint --fix can't repair it.
effort: Unknown
tier: 3
priority: high
autonomy_level: 3
tags: [id, store]
created: "2026-07-18"
---
# Invalid (non-Crockford) entity id is mishandled

## Objective

An entity file whose leading id contains a non-Crockford char (Crockford base32
excludes i/l/o/u) is misdiagnosed and unrepairable. `splitFlatName` calls
`id.Valid` (internal/store/flatname.go:32); a bad char fails it, so the file is
reported as *not id-led* even though a 12-char id is right there. Reproduced on
current code with an `l` in the id.

## Acceptance criteria

- [ ] Error names the offending char / rule (e.g. `id "…" contains non-Crockford char 'l'`) instead of "has no leading id"
- [ ] One bad file no longer forces exit 11 for the whole command — skip-with-warning (or gate the abort behind `--strict`) so unrelated queries succeed with a trustworthy exit code
- [ ] `lint --fix` repairs/re-mints an invalid id, not only a missing one
- [ ] Ids are validated at write time so a bad id can't persist

## Notes

- Today: `audit list` prints good rows then `error: … 1 file(s) with unreadable
frontmatter` (exit 11); `lint --fix` "fixes 1 file" but leaves the bad-id file untouched.
- Manual fix that worked: swap the illegal char for its Crockford decode-alias
(l→1, i→1, o→0) in both filename and `id:` — same decoded value, canonical, keeps sort order.
- Loci: internal/store/flatname.go, internal/id/id.go (Valid/decode), scanDir error surfacing.
- Source: https://github.com/andy-esch/taskflow/issues/105 (P2, High)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
schema: 1
id: 6fq9zy13wkdc
status: ready-to-start
epic: 20-cli-ux-and-ergonomics
description: No audit lint --fix to normalize legacy finding statuses (emoji/legacy words); schema audit omits the status vocabulary.
effort: Unknown
tier: 3
priority: medium
autonomy_level: 3
tags: [audit, lint]
created: "2026-07-18"
---
# audit lint --fix for legacy finding-status debt + document the vocabulary

## Objective

`audit lint` enforces a strict finding-status vocabulary
(deferred/fixed/in-progress/landed/open/superseded/wontfix) but there is no
`audit lint --fix` to normalize older audits (emoji ✅/⏳/⛔, legacy words like
`tracked`/`declined`, or pre-`Status:` findings). Separately, `schema audit`
documents the Status-line *format* but not the allowed *vocabulary* (only the
top-level `schema` lists it).

## Acceptance criteria

- [ ] `audit lint --fix` normalizes finding statuses (strip emoji, map declined→wontfix / tracked→superseded, backfill missing)
- [ ] `schema audit` lists the finding-status vocabulary

## Notes

- Was masked in the wild by the P2 abort — whole-tree `audit lint` never
completed until the invalid-id files were fixed, hiding ~10 audits of debt.
- Confirmed: no `--fix` on `audit lint`; `schema audit` omits the vocab.
- Source: https://github.com/andy-esch/taskflow/issues/105 (P3, Medium)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
schema: 1
id: 6fq9zy15j5pz
status: ready-to-start
epic: 20-cli-ux-and-ergonomics
description: task new --json created.id is the slug; the minted id is only inside created.path.
effort: Unknown
tier: 3
priority: low
autonomy_level: 3
tags: [json, id]
created: "2026-07-18"
---
# task new --json: expose the minted id as its own field

## Objective

`task new --json` returns `created.id` = the **slug**; the real minted id is
only embedded in `created.path` (`tasks/<minted-id>-<slug>.md`). Scripting
`created.id` expecting the id yields the slug. Confirmed:
`{"created":{"id":"zzz-probe","path":"tasks/6fq9xy66fehv-zzz-probe.md"}}`.

## Acceptance criteria

- [ ] The minted id is a distinct top-level field on the created object
- [ ] `id` vs `slug` are unambiguous in the envelope (and consistent across new for task/epic/audit)

## Notes

- Loci: the create envelope in internal/wire (created DTO) + the CLI `new` handlers.
- Source: https://github.com/andy-esch/taskflow/issues/105 (P4, Low)
Loading
Loading