Project-specific patterns for vault-cli. For general Go patterns see coding-guidelines.
main.go— CLI entry point, delegates topkg/clipkg/cli/— Cobra command definitions, output formattingpkg/config/— Config file parsing,Loaderinterface, vault resolutionpkg/domain/— Domain types (Goal, Task, Theme, Vision, Objective)pkg/ops/— Business operations (complete, defer, lint, list, search, update, workon)pkg/storage/— Markdown file read/write, frontmatter parsingmocks/— Counterfeiter-generated mocksintegration/— Integration tests
- Cobra CLI — all commands under
vault-cli <noun> <verb>pattern - Config via YAML —
~/.vault-cli/config.yaml,Loaderinterface for testability - Plain output default —
--output plain(default) or--output json - Factory functions are pure composition — no conditionals, no I/O, no
context.Background() - Map-based frontmatter — all entity frontmatter is stored in
map[string]any; unknown fields survive read-write cycles; known fields have typed accessors
Every new vault-cli <noun> <verb> command follows this layered approach:
- Domain (
pkg/domain/) — entity struct embedsXxxFrontmatter(aFrontmatterMap-backed typed wrapper),FileMetadata, andContent string. Typed getters/setters for known fields;GetField/SetField/ClearFieldfor arbitrary keys. Unknown fields survive read-write cycles. - Storage (
pkg/storage/markdown.go) — add methods toStorageinterface, implement onmarkdownStorage - Operation (
pkg/ops/) — interface + factory + struct, one file per operation - CLI (
pkg/cli/cli.go) — Cobra command wired into root, usesgetVaultsfor multi-vault
Each entity (Task, Goal, Theme, Objective, Vision) cleanly separates three concerns:
Frontmatter (pkg/domain/<entity>_frontmatter.go)
- Embeds
FrontmatterMap(amap[string]anywrapper) FrontmatterMapprovides the raw accessor family:Get,GetString,GetBool,GetTime,GetStringSlice,Set,Delete,Keys,RawMap. Getters coerce rather than type-assert —GetBoolaccepts a YAML bool and the stringstrue/yes/false/no(case-insensitive), returningfalsefor a missing key or an unrecognised value.- Typed getter methods for known fields (e.g.,
Status() TaskStatus,Priority() Priority) - Typed setter methods that validate known fields (e.g.,
SetStatus(TaskStatus) error) - Generic
GetField(key) string/SetField(ctx, key, value) error/ClearField(key)for arbitrary keys — unknown fields pass through without validation - All fields in the map (known and unknown) are preserved through read-write cycles
Filesystem metadata (pkg/domain/file_metadata.go)
FileMetadatastruct:Name,FilePath,ModifiedDate- Embedded in every entity; never stored in YAML
Markdown content
Content stringfield on the entity struct- The full markdown file content including the frontmatter block
- Used by the storage layer to extract the body on write
Decision — the one hybrid
domain.DecisionembedsFrontmatterMapdirectly (taggedyaml:"-") rather than aDecisionFrontmatterwrapper, and keeps typed struct fields for its six managed keys —needs_review,reviewed,reviewed_date,status,type,page_type— because those fields are the mutation surfacepkg/ops/decision_ack.gowrites.WriteDecisioncopies the preserved map and overlays those six last, so a managed value wins on a name collision and every other key round-trips untouched.
Storage (pkg/storage/)
parseToFrontmatterMapparses the YAML frontmatter block intomap[string]any- Bare-wikilink invariant:
[[X]]is valid YAML flow sequence syntax, so an unquoted frontmatter wikilink (related_task: [[X]]) unmarshals to a nested list and marshals back as- - X, silently destroying the link.parseToFrontmatterMaptherefore runs a raw-text quoting pass (quoteBareWikilinks) beforeyaml.Unmarshal, rewriting a value that is exactly a bare wikilink — as a mapping value or as a block-sequence entry — into a single-quoted scalar. Only quoting changes, never YAML shape. Already-quoted values, values that merely contain a wikilink, values with a trailing YAML comment, and block-scalar bodies are left byte-identical. All six entity types inherit this from the one shared chokepoint; a new read path that bypassesparseToFrontmatterMapreintroduces the bug. serializeMapAsFrontmattermarshals the map back to YAML; unknown fields are preserved- Entity-specific read helpers call
NewXxx(data, meta, content)constructors - Rendering caveat: a bare YAML date (
review_date: 2026-08-15) parses totime.Timeand re-serializes as RFC3339 (review_date: 2026-08-15T00:00:00Z). The instant is preserved; only the rendering is normalized. This applies to every entity —WriteGoalbehaves the same way today.
Operations (pkg/ops/)
- Inject
XxxStorageinterfaces (never file I/O directly) - Use entity accessor methods (
goal.Status(),task.SetField(ctx, key, val)) - No reflection; no hardcoded field switches
All commands except watch and rollup use getVaults() to resolve vaults:
-
--vault NAME→ single vault -
No flag → all configured vaults
-
watch --vault a,baccepts a comma-separated vault list and resolves it throughgetWatchVaults; every other command keepsgetVaultsand a single vault name. A value that yields no usable name is an error; the empty string means every configured vault. -
rollup weeklyresolves exactly one vault throughGetVault(the empty name falling back to the config'sdefault_vault) because its figures are properties of one task population, and an all-vaults aggregate would answer no question.
Commands iterate vaults and call operations per vault. For mutation commands (complete, defer, ack), try each vault until the item is found.
--output plain(default) — human-readable lines--output json— structured JSON viaPrintJSON(v)fromoutput.go- Never import
encoding/jsonin command files — use thePrintJSONhelper
- Inject
libtime.CurrentDateTimefor date/time (never calltime.Now()directly) - Inject
storage.Storageinterface (never read files directly in ops) - Factory functions are pure composition — no conditionals, no I/O, no
context.Background()
- Counterfeiter with
//counterfeiter:generatecomments on interfaces - Mocks go in
mocks/directory - Regenerate:
go generate ./...
- Operations:
<Noun><Verb>Operation(e.g.,DecisionListOperation,DecisionAckOperation) - Files:
pkg/ops/<noun>_<verb>.go+_test.go - CLI:
create<Noun>Commands()returns parent,create<Noun><Verb>Command()returns leaf