A local-first planning CLI over markdown+frontmatter. Design rationale lives in
planning/research/2026-06-06-* and planning/epics/17-pm-go-cli.md; this is
the one-screen orientation for contributors.
primary adapters core secondary adapter
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ cli (cobra) │ ───▶ │ core.Service │ ──port▶ │ store.FS │
│ tui (bubble) │ │ + domain (pure) │ │ markdown+yaml │
└──────────────┘ └──────────────────┘ └──────────────────┘
internal/domain— entities + invariants (Task,Status). No fs, no cobra logic (the one pragmatic concession:Task/Epic/Auditcarry aPaththe store stamps, so callers can locate the source file). Frontmatter is the authoritative status/bucket (ADR-0003 §4): tasks and audits are stored flat and id-led —tasks/<id>-<slug>.md,audits/<id>-<slug>.md— with no status/bucket directory to mirror or drift against (epics keepNN-<slug>). A file whose frontmatter status is missing or unrecognized is listed but flagged bylint(StatusFellBack; shown with a⚠intask list/show), never moved or dropped; a non-id-led.mdin a scanned dir is a loudFileProblem("move it tometa/") — exceptREADME.md, silently carved.meta/is the sanctioned home for non-entity files. Per-entity metadata — the top-level dir, authoring fields, conventions, and body scaffold fortask/epic/audit— lives in one registry (entity.go'sDescriptor);SchemaKinds/AuthoringFields/Conventions/BodyTemplateread that table instead of parallelswitch kindblocks, so a kind's schema/scaffold surface is a registry entry, not a per-layer edit. Honest remaining fan-out for a new entity (e.g. the scaffoldedprojects/), after the descriptor + the generic seams: a typeddomainstruct + aparse*, thin*Storeport methods (the scan is genericscanDir[T], resolution genericresolveID), itscore.Serviceuse cases, a cli command, and render + TUI display delegates (a Human/JSON formatter — column layout is the genericColumn[T]/WriteTablePlain— plus anentityTabentry + row delegate). That residual is the cost of a typed domain whose three entities have genuinely different shapes (tasks: status/tier/priority; epics: rollups; audits: findings/buckets); the generics remove the mechanics, not the per-entity shape. What IS collapsed: the metadata fan-out into the descriptor (M1), and TUI lifecycle (theamenu +:verbs) into each entity's transition table (M10), so an entity opts into close/move actions by declaring transitions, not by editing the reducer. A further data-driven persistence/render collapse isn't pursued — for three heterogeneous entities it trades clarity for machinery.internal/core— use cases (Service) + the ports it needs, defined here at the consumer.Store(composed ofTaskStore/EpicStore/AuditStore) is the use-case port theServicedepends on; the two fs/text operations that aren't use cases live in narrow sibling ports —Fixer(frontmatter repair) andLayout(watch-path layout) — so a secondStoreand the test fakes don't carry them. Pure; unit-testable without fs.internal/store— the secondary adapter: tasks as<root>/tasks/<id>-<slug>.md(flat, id-led). Splits frontmatter with a zero-dep byte scanner; parses YAML withgo.yaml.in/yaml/v3. One*FSsatisfies all three ports (var _ core.Store/Fixer/Layout = (*FS)(nil)): the Service gets the use-caseStore, the CLI'slint --fixand the TUI watcher get the narrowFixer/Layoutwired directly. It owns the layout knowledge —WatchPaths()hands the TUI watcher its dir set so the path convention isn't reconstructed outside the store. Concurrency is version-CAS (epic 24): every write, just before committing, re-resolves the file by its id and re-hashes it against the content read at the start of the op (verifyUnchangedincas.go— a strong whole-file SHA-256 computed on read, never stored), so a concurrent in-place edit isErrConflict(exit 14). A repo-wide advisoryflock(writeLock, unix; a no-op stub elsewhere) serializes the verify→write so that CAS is atomic — without it two writers both pass their verify before either renames and the later silently clobbers the earlier (the verify→rename window, widened by the temp fsync). The token is internal: scriptable mutations auto-retry it incore.Service(bounded + jittered, so agents don't reimplement the loop), the humaneditsurfaces the conflict (no retry, and the lock is held only for the write, never the editor session), and creates map the empty precondition ontocreateFileAtomic'sO_EXCL. Exposing it over HTTP (If-Match) is the web adapter's job (epic 19), not the FS store's.internal/cli— a primary adapter: the cobra tree.internal/tui— the second primary adapter (shipped): a Bubble Tea browser calling the samecore.Service, never the store/fs. See the TUI section below.internal/theme— dependency-free semantic tokens (status/bucket/priority → glyph + aColorenum), imported by bothcli/render(→ SGR: the theme's truecolor hue, or a 16-color slot fallback) andtui(→ lipgloss), so "in-progress is a yellow ●" is decided in one place.internal/design— the concrete-color layer one level belowtheme: aPalette(the semantic enum → truecolor + a 16-color ANSI slot, plus chrome tokens — accent/borders/match/gradient) and a named-Themeregistry (the neon default). Importstheme(keys its palette by the enum);thememust never importdesign. Consumed bycli/render,tui,cli/prompt, andprogressbar— the one place a hue is chosen, asthemeis for a glyph.internal/config— discovers the planning root (walk up for tasks/; terminates at a.git/root boundary).cmd/tskflwctl— thin entrypoint; the command tree and DI wiring live ininternal/cli(root.go), which it calls.
- DI via one
*cli.App, populated in rootPersistentPreRunE(the lazy shell — deps depend on flags). No package globals, nocmd.Context()for DI. - All output through injected
io.Writer(neverfmt.Println) → commands are testable in-process (seeinternal/cli/task_test.go). - Render is separate from logic: commands call the service, then
render.TasksHuman/TasksJSON.--jsonis a global flag; JSON carries a semverschema_versionand never emits ANSI. - The core never touches the fs or cobra.
Reviews periodically suggest folding the packages together ("Go favors fewer
packages / concrete types"). That advice evaluates this as a CLI, but it is a
multi-adapter system: the CLI and a Bubble Tea TUI both ship as primary
adapters over the same core. That single fact answers most of the critique —
the layering exists so the TUI reuses the use-cases without duplicating logic,
not for hypothetical future flexibility. The specifics:
- Cross-package exported types aren't "leakage."
domain.FileProblem,core.EpicSummary,core.NewTaskParams,render.MoveResultare the contract between layers. Everything lives underinternal/, so "exported" means "visible to sibling packages in this binary," never to the outside world — exactly what a layered design needs. core.Storeearns its keep today, not speculatively. The core's unit tests run against an in-memoryfakeStore(core/service_epic_test.go), so rollup/validation logic is tested with no filesystem. That's a real second implementation now, plus the shipped TUI is a second primary adapter over the same core. The port stays use-case-only:FixFrontmatterandWatchPaths(fs/text operations, not use cases) were split off into the narrowFixerandLayoutports the adapters wire to the FS directly, so theStorethe fakes implement carries no presentation-adjacent baggage.- Frontmatter logic is already cohesive.
frontmatter.go(parse + surgical write),fix.go(text repair),diagnose.go(error diagnosis) are all one package (store), split into files by concern — idiomatic Go.domain/ validate.gois semantic field rules (tier 1–5, priority enum), a domain concern, deliberately not coupled to the storage format. cli/renderis the one genuinely revisitable call. It's cli-only (the TUI renders via Bubble Tea views, not these text/JSON formatters) and importscorefor its read-side view-models — today five (Summary,StatusCount,EpicSummary,AuditFinding,LintResult), and growing roughly one per entity as stats/index/tags land, so this is a realcli→render→corediamond, not the "two types" an earlier draft claimed. It stays justified because render is the isolation seam the TUI doesn't touch: these are core results, not store internals, and render is where presentation is allowed to know them. The trend-reversal if the count ever bites is the patterntaskJSON/auditJSONalready use — map core results into render-owned DTOs at the call site rather than importing more core types. Keeping it a package buys isolation + therender.namespace; folding it intocliwould also be fine. Not dogma — collapse it if the boundary ever causes friction. (Note this is the opposite of dropping the core seam: render is presentation that the TUI replaces;coreis logic the TUI reuses.)
A Bubble Tea (Elm-architecture) browser, launched by tskflwctl ui. It is the
second primary adapter: every read goes through core.Service as a tea.Cmd
returning a custom tea.Msg — never I/O in Update/View, never the store.
Files split by concern:
model.go— the rootModel+ theUpdatereducer andView. Owns the tab set, focus (list ⇄ detail), window size, and key routing.entity.go— the entity registry: tasks/epics/audits as*entityTabs, each owning its ownlist.Model, cursor, loaders, list-scoped state (status view, sort, filter restore), and its lifecycle table (the transitions it offers + anapplyMove). Read/browse is keybinding-free; lifecycle is declared here per entity (tasks by status viaMove, audits by bucket viaMoveAudit, epics none), so adding Projects/ADRs later is a new registry entry — including anya-menu /:-verb actions — not a reducer edit.dashboard.go— the landing dashboard (tskflwctl uiopens here): a read-only composite of widgets over a singlecore.Summary, the in-app counterpart ofstatus. Deliberately not anentityTab— it has no list, filter, sort, or lifecycle, so it carries its own smalldashboardmodel plus aModel.onDashflag rather than joiningm.tabs(a-1entityDashboardsentinel gives it?-help/title context without a tab slot). It's a launch surface: each navigable row jumps to an item/view on a real tab viadashJump, never mutating. Rule of thumb for new screens: a browsable list ⇒ a newentityTab; a read-only orientation screen ⇒ the dashboard pattern.commands.go/messages.go— the async loadtea.Cmds and thetea.Msgtypes they return (list loads, lazy detail loads, reload, errors).detail.go/find.go/glamour.go— the right pane (aviewport): the field block + a markdown body rendered two ways (raw /glamour, both cached soRtoggles for free) + vim-like/nNfind-in-body over occurrences (ANSI-aware highlight that preserves the line's other colors; unicode-fold-safe).item.go— per-entitylist.ItemDelegates (the glyph rows) and thesortFields/FilterValueeach row exposes.sort.go/statusview.go/command.go/action.go/overlay.go— interactive sort (per-entity columns), the unified status-view table (:words +s/Scycle), the:command bar, thealifecycle action menu, and the modal registry. The action menu and:verbs are registry-driven: both read the active tab's transition table +applyMove, so tasks move by status and audits by bucket (close/reopen/defer, in-TUI now) through one entity-agnostic path —movedMsg.tois a plain string the closure interprets. Overlays (help, action, follow, edit) satisfy a smallmodalinterface and live in an ordered stack the reducer loops; ForceQuit is handled once ahead of the loop, so a new overlay is one entry, not a newhandleKeyguard block +bodyViewcase.edit.go— theeinline field editor: the human face oftask set. A form modal listing the typed editable fields (description / priority / tags / effort / tier) with their meanings (from the entity descriptor), the active field's widget inline (enum cursor, single-line input, or a wrappedtextareafor description). Apply writes throughcore.Service.SetFieldsas atea.Cmd; a core validation error stays on the field (shown inline) for an in-place fix, success returns to the picker. Task-only; status stays in the action menu. No new validation path — core re-validates, the same astask set.nav.go— S6 cross-link navigation:ffollows structured references (a task's epic; an epic's tasks via a picker modal),ctrl+opops the back-stack; hidden targets escalate the tasks view to:allrather than fail.watch.go—fsnotifylive reload: a self-perpetuating listenerCmdfeedsfsEventMsg; a generation-guardedtea.Tickdebounce (200ms) coalesces save-storms into one reload of every loaded tab, cursor preserved by id. The watched dir set comes from thecore.Layoutport (WatchPaths(), the FS injected by the CLI), not from a root the TUI reconstructs — layout knowledge stays in the store.help.go— the?keybinding overlay (helpSectionsis the runtime source of truth for keys) composited over the body withansi.Cut.style.go/keys.go— the per-Modelstylesbundle (the active palette plus every chrome lipgloss style and the color helpers, built bynewStylesfromdesign/theme; the root Model holds a*stylesthe delegates share, repopulated once inRunafter background detection) and thekey.Bindingmap.
Layout discipline is load-bearing (a clipped-top-border class of bug):
subtract the border frame before sizing children, guard View before the first
WindowSizeMsg, truncate (never wrap) anything fed to a Join, and clamp the
composed view to the terminal. TestModel_ViewFitsTerminal locks the invariant
(View height == terminal height; no line wider than the terminal). The full
checklist is in planning/research/2026-06-10-tui-design-decisions.md.
Three layers for the CLI/core: pure domain/core units (incl. a fakeStore for
the core), store round-trips against t.TempDir(), and in-process CLI tests that
execute NewRootCmd with a captured buffer. The hand-rolled byte parsers in store
have fuzz targets (store/fuzz_test.go). The TUI is tested by message
injection (build the model, send tea.Msgs to Update, assert on state /
View() substrings) plus a few x/teatest full-program tests and the layout
invariant; fs-event behavior uses synthetic messages, not real fsnotify timing.
The CLI also has golden snapshots of the byte-stable machine contract (the
--json envelopes, csv, and schema --json-schema) under
internal/cli/testdata/golden/, run in-process against the committed
testdata/planning/ fixture; regenerate them with go test ./internal/cli -update (the -update flag is cli-package-scoped, so target that package, not
./...). The single subprocess smoke layer (real binary, exit codes, lifecycle)
lives in cmd/tskflwctl/main_test.go. just test + just lint.
Substantially functional — the full create→update→move→lint loop runs without the Python prototype:
init,completion(command/flag/slug, status-aware),lint(+--fix/--dry-run)task new|list|show|set|edit|append|move|start|next|ready|complete|defer|deprecateepic new|list|show,audit list|show|findings|lint|close|reopen|deferui— the Bubble Tea browser (epic 18): a landing dashboard (the in-app counterpart ofstatus— in-progress, due-for-revisit, epic rollups, needs-attention, navigational) plus two-pane read-only browse of tasks/epics/audits,:jump,/filter, sort, status views, detail find,?help,fsnotifylive reload, lifecycle mutations (amenu +:verbs), and glamour markdown with anRraw/pretty toggle (S0–S5 shipped; cross-link is the remaining sprint).
Throughout: explicit noun-verb, semantic exit codes (10 not-found · 11
validation · 13 ambiguous · 14 conflict), atomic
writes (writeFileAtomic overwrite, createFileAtomic exclusive) + surgical
yaml.v3 edits, --json everywhere (schema_version), resilient reads with
actionable frontmatter errors, agent safety annotations.
Remaining (see planning/): adr/project groups, the audit finding-write
surface (audit finding --status/sync; the read surface — audit findings
query + audit lint — shipped), reporting views (stats/index/tags),
track, schema --type cli, interactive init wizard. Out of
scope by a long shot: MCP / semantic engine / pgvector.