diff --git a/assets/tui.gif b/assets/tui.gif index 216d546..1e7f0cf 100644 Binary files a/assets/tui.gif and b/assets/tui.gif differ diff --git a/assets/vhs/tui.tape b/assets/vhs/tui.tape index f3257d3..64091fd 100644 --- a/assets/vhs/tui.tape +++ b/assets/vhs/tui.tape @@ -34,5 +34,9 @@ Sleep 1s Enter Sleep 1.5s +# scroll down +Down 10 +Sleep 0.8s + Type "q" Sleep 750ms diff --git a/internal/cli/render/render.go b/internal/cli/render/render.go index 14f7d07..e772dad 100644 --- a/internal/cli/render/render.go +++ b/internal/cli/render/render.go @@ -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) } @@ -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 { @@ -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) @@ -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 diff --git a/internal/cli/render/render_test.go b/internal/cli/render/render_test.go index a4d3a50..64f2462 100644 --- a/internal/cli/render/render_test.go +++ b/internal/cli/render/render_test.go @@ -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. diff --git a/internal/cli/render/style.go b/internal/cli/render/style.go index 02cbd9f..8c42d2a 100644 --- a/internal/cli/render/style.go +++ b/internal/cli/render/style.go @@ -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) } diff --git a/internal/domain/audit.go b/internal/domain/audit.go index 1b2a4c9..0d7cfb8 100644 --- a/internal/domain/audit.go +++ b/internal/domain/audit.go @@ -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() } diff --git a/internal/domain/audit_settled_test.go b/internal/domain/audit_settled_test.go index a05a88b..19789cd 100644 --- a/internal/domain/audit_settled_test.go +++ b/internal/domain/audit_settled_test.go @@ -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) + } + } +} diff --git a/internal/theme/progress.go b/internal/theme/progress.go index 4353450..7c4d9ef 100644 --- a/internal/theme/progress.go +++ b/internal/theme/progress.go @@ -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) } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 8ec557e..c68d07c 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -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) diff --git a/internal/tui/item.go b/internal/tui/item.go index 95ce249..d9bece7 100644 --- a/internal/tui/item.go +++ b/internal/tui/item.go @@ -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) } diff --git a/internal/wire/dto.go b/internal/wire/dto.go index 0efc395..187a8f0 100644 --- a/internal/wire/dto.go +++ b/internal/wire/dto.go @@ -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(), } } diff --git a/planning/tasks/6fq9zy102rea-audit-progress-number-contradicts-its-own-bar.md b/planning/tasks/6fq9zy102rea-audit-progress-number-contradicts-its-own-bar.md new file mode 100644 index 0000000..6daa89a --- /dev/null +++ b/planning/tasks/6fq9zy102rea-audit-progress-number-contradicts-its-own-bar.md @@ -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. diff --git a/planning/tasks/6fq9zy126tas-invalid-non-crockford-entity-id-is-mishandled.md b/planning/tasks/6fq9zy126tas-invalid-non-crockford-entity-id-is-mishandled.md new file mode 100644 index 0000000..cbd2516 --- /dev/null +++ b/planning/tasks/6fq9zy126tas-invalid-non-crockford-entity-id-is-mishandled.md @@ -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) diff --git a/planning/tasks/6fq9zy13wkdc-audit-lint-fix-for-legacy-finding-status-debt.md b/planning/tasks/6fq9zy13wkdc-audit-lint-fix-for-legacy-finding-status-debt.md new file mode 100644 index 0000000..13f1402 --- /dev/null +++ b/planning/tasks/6fq9zy13wkdc-audit-lint-fix-for-legacy-finding-status-debt.md @@ -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) diff --git a/planning/tasks/6fq9zy15j5pz-task-new-json-expose-the-minted-id-as-its-own-field.md b/planning/tasks/6fq9zy15j5pz-task-new-json-expose-the-minted-id-as-its-own-field.md new file mode 100644 index 0000000..d601844 --- /dev/null +++ b/planning/tasks/6fq9zy15j5pz-task-new-json-expose-the-minted-id-as-its-own-field.md @@ -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/-.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) diff --git a/planning/tasks/6fq9zy172apg-hard-delete-for-a-mistaken-create-task-rm.md b/planning/tasks/6fq9zy172apg-hard-delete-for-a-mistaken-create-task-rm.md new file mode 100644 index 0000000..8210adb --- /dev/null +++ b/planning/tasks/6fq9zy172apg-hard-delete-for-a-mistaken-create-task-rm.md @@ -0,0 +1,30 @@ +--- +schema: 1 +id: 6fq9zy172apg +status: ready-to-start +epic: 20-cli-ux-and-ergonomics +description: No task rm/audit rm; undoing a mistaken create needs deprecate (tombstone) or a raw rm (bypasses the CLI). +effort: Unknown +tier: 3 +priority: low +autonomy_level: 3 +tags: [lifecycle] +created: "2026-07-18" +--- +# Hard-delete for a mistaken create (task rm / audit rm) + +## Objective + +A duplicate or mistaken `task new` can only be undone with `deprecate` (leaves a +tombstone) or by `rm`-ing the file (bypasses the CLI, against the "drive the +lifecycle via tskflwctl" principle). There is no hard-delete verb. Confirmed: no +`task rm`/`audit rm` today. + +## Acceptance criteria + +- [ ] A `task rm` (and `audit rm`) removes a never-committed mistake through the CLI +- [ ] Guard-railed so it's clearly for mistakes, not a routine lifecycle move (vs deprecate) + +## Notes + +- Source: https://github.com/andy-esch/taskflow/issues/105 (P5, Low)