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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ require (
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark v1.7.17 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/net v0.56.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
Expand Down
85 changes: 63 additions & 22 deletions internal/domain/fields.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,73 @@
package domain

// The canonical task-frontmatter field-type registry. This is the ONE place
// that knows which fields are ints, which are lists, and which exist at all —
// the core's `--set` coercion and the store's diagnose/fix paths all read it,
// so they can't drift apart (previously each kept its own copy, and `task set`
// wrote forms that the project's own `lint --fix` then rewrote).
// The canonical task-frontmatter field registry. taskFields (below) is the ONE
// place that declares which fields exist and their YAML storage type; the
// known-field set and the per-type maps (int/list/date) are all DERIVED from it,
// so they can no longer drift out of sync — previously knownTaskFields was a
// separate hand-list that had to be kept in lockstep with intFields/listFields/
// dateFields by eye. The core's `--set` coercion, the store's diagnose/fix paths,
// lint, and `schema` all read the accessors below.
//
// Keep in sync with the domain.Task yaml tags + the stamped date fields.
// Keep taskFields in sync with the domain.Task yaml tags; TestTaskFieldsMatchStruct
// pins that every modelled frontmatter tag (except the store-managed id) is here.

// intFields are frontmatter keys stored as YAML ints.
var intFields = map[string]bool{"tier": true, "autonomy_level": true}
// taskField declares one task frontmatter field and its YAML storage type
// ("string" | "int" | "list" | "date").
type taskField struct {
Name string
Type string
}

// taskFields is the single source of truth for task frontmatter fields — every
// field the tool reads or writes, with the type the writer produces. Order is
// cosmetic (all consumers are sets or sort).
var taskFields = []taskField{
{"status", "string"},
{"epic", "string"},
{"description", "string"},
{"effort", "string"},
{"priority", "string"},
{"tier", "int"},
{"autonomy_level", "int"},
{"tags", "list"},
{"related_tasks", "list"},
{"dependencies", "list"},
{"blocks", "list"},
{"blocked_by", "list"},
{"audit_sources", "list"},
{"projects", "list"},
{"created", "date"},
{"updated_at", "date"},
{"started_at", "date"},
{"revisit_at", "date"},
{"completed_at", "date"},
{"deprecated_at", "date"},
{"deferred_at", "date"},
{"audited", "date"},
}

// listFields are frontmatter keys stored as YAML lists.
var listFields = map[string]bool{
"tags": true, "related_tasks": true, "dependencies": true,
"blocks": true, "blocked_by": true, "audit_sources": true, "projects": true,
// taskFieldSet returns the set of task field names whose type == typ; typ == ""
// returns every known field.
func taskFieldSet(typ string) map[string]bool {
m := make(map[string]bool, len(taskFields))
for _, f := range taskFields {
if typ == "" || f.Type == typ {
m[f.Name] = true
}
}
return m
}

// The derived registries. Deriving them from taskFields means a new field is one
// table row, not four literals kept in lockstep. dateFields' other reader is
// ValidateField (validate.go) and FieldType (schema.go).
var (
knownTaskFields = taskFieldSet("")
intFields = taskFieldSet("int")
listFields = taskFieldSet("list")
dateFields = taskFieldSet("date")
)

// IsIntField reports whether a frontmatter key is stored as a YAML int, and
// IsListField whether it's stored as a YAML list. They are accessors over the
// unexported registries so no sibling package can mutate the canonical type map
Expand All @@ -25,16 +76,6 @@ var listFields = map[string]bool{
func IsIntField(f string) bool { return intFields[f] }
func IsListField(f string) bool { return listFields[f] }

// knownTaskFields is every frontmatter key tskflwctl itself reads or writes.
var knownTaskFields = map[string]bool{
"status": true, "epic": true, "description": true, "effort": true,
"tier": true, "priority": true, "autonomy_level": true, "tags": true,
"created": true, "updated_at": true, "started_at": true, "revisit_at": true,
"completed_at": true, "deprecated_at": true, "deferred_at": true, "audited": true,
"related_tasks": true, "dependencies": true, "blocks": true,
"blocked_by": true, "audit_sources": true, "projects": true,
}

// KnownTaskField reports whether a frontmatter key is one the tool knows.
// `task set --set` rejects unknown keys unless forced — a typo'd field name
// must not silently persist (decided 2026-06-12).
Expand Down
19 changes: 19 additions & 0 deletions internal/domain/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ func TestAuditAuthoringFieldsMatchStruct(t *testing.T) {
}
}

// TestTaskFieldsMatchStruct is the no-drift guard fields.go asks for: every
// modelled task frontmatter field (a domain.Task yaml tag) except the store-managed
// `id` must be a known task field, so adding a struct field forces a taskFields
// registry row. The reverse deliberately does NOT hold — many known fields
// (completed_at, related_tasks, projects, …) are preserved but not modelled on the
// struct, so this only checks struct ⊆ registry.
func TestTaskFieldsMatchStruct(t *testing.T) {
rt := reflect.TypeOf(Task{})
for i := range rt.NumField() {
name, _, _ := strings.Cut(rt.Field(i).Tag.Get("yaml"), ",")
if name == "" || name == "-" || name == "id" {
continue // derived fields and the store-managed id aren't settable known fields
}
if !KnownTaskField(name) {
t.Errorf("Task struct frontmatter field %q is not in the taskFields registry", name)
}
}
}

func TestFieldType(t *testing.T) {
for name, want := range map[string]string{
"tier": "int", "autonomy_level": "int",
Expand Down
6 changes: 2 additions & 4 deletions internal/domain/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,8 @@ func ValidateDate(value string) error {
return nil
}

var dateFields = map[string]bool{
"created": true, "updated_at": true, "started_at": true, "revisit_at": true,
"completed_at": true, "deprecated_at": true, "deferred_at": true, "audited": true,
}
// dateFields (the YAML "date" typed keys) is derived from the taskFields registry
// in fields.go, alongside the other per-type sets.

// IsRevisitDue reports whether a deferred task's revisit ("snooze until") date
// has arrived: revisitAt is on or before now's calendar day. An empty or invalid
Expand Down
40 changes: 40 additions & 0 deletions planning/epics/28-first-class-entities-new-planning-nouns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
schema: 1
status: active
description: 'Home for new first-class entities beyond task/epic/audit (routines, projects, ADRs): fields, lifecycle, storage, and CLI surface for each new noun — the ''which nouns exist'' axis.'
priority: low
tags: []
created: "2026-07-07"
---
# First-class entities — new planning nouns

Home for adding new first-class entities BEYOND task/epic/audit — the "which
nouns exist" axis of the model. Distinct from epic 24's storage-model evolution
(stable-key layout / read-model / OCC), which is about HOW entities are stored;
this epic is about WHICH nouns exist and what they mean.

## Charter

Each new noun that earns first-class status is defined through the same machinery
the existing entities use:

- its frontmatter fields + lifecycle (status/bucket vocabulary, if any),
- its on-disk layout (flat, id-led — per ADR-0003),
- its CLI surface (`<noun> new|list|show|…`) over `core.Service`,
- how it relates to existing entities (cross-references, rollups).

## Candidate tenants

- **routines** — a first-class entity tracking the routine↔audit lineage (audit
gets a `routine:` field; `audit list --routine`); complements Claude Code's
scheduler. Tenant #1 (the spike moved here from epic 24).
- **projects** — a grouping above/beside epics (TBD).
- **ADRs as tracked entities** — decisions as first-class, queryable records
rather than freeform docs (TBD).

## Relationship to other epics

- Epic 24 (data-model evolution) — provides the storage foundation (flat id-led
layout, OCC, projection) every new noun rides on.
- Epic 26 (frontmatter schema) — a new noun's fields should be declared through
the same field registry, once that lands.
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
schema: 1
id: 6fjw1d2jm6e9
status: ready-to-start
epic: 24-data-model-evolution-stable-key-storage-read-model-content-occ
epic: 28-first-class-entities-new-planning-nouns
description: 'Explore routines as a first-class entity tracking the routine<->audit lineage (audit gets a routine: field; audit list --routine). Complements Claude Code''s scheduler; maybe its own epic.'
effort: Unknown
tier: 3
priority: low
autonomy_level: 3
tags: [core, design]
created: "2026-07-04"
updated_at: "2026-07-07"
---

# Spike: routines as a first-class entity + routine↔audit lineage
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
schema: 1
id: 6fkkz41cax80
status: next-up
epic: 26-frontmatter-schema-declared-validation-contract
description: Design-first ADR closing epic 26's policy questions (strictness, unknown fields, schema location, severities, rollout) so one field registry drives lint, schema guidance, and the --json contract.
effort: Unknown
tier: 3
priority: medium
autonomy_level: 3
tags: [validation, schema, adr]
created: "2026-07-07"
---
# ADR — declared frontmatter-schema contract: close the policy questions

## Objective

Epic 26 is design-first: before any validator is written, close the open policy
questions so a *single declared field registry* can generate lint rules, `schema
<entity>` authoring guidance, and a frontmatter JSON-schema from one source of
truth (replacing today's triplicated, drift-prone split). Output: an ADR (the
next number after 0003/0004) recording the decisions, plus this epic's open
questions resolved or explicitly deferred.

## Acceptance criteria

- [ ] ADR drafted covering the load-bearing questions: per-status strictness
matrix (Q1), unknown/custom-field policy (Q2), where the schema lives and
its relation to `schema --json-schema` (Q4), severity levels (Q6), and
backward-compat/rollout (Q8).
- [ ] Each of epic 26's 12 open questions is either decided or explicitly parked
with a reason.
- [ ] The "fail loud, don't over-fix" contract is preserved (non-goal: turning
`--fix` into a general repairer).
- [ ] The ADR-0003 carveout amendment (what makes a file an entity) is folded in.

## Out of scope

- Implementing the field registry / validator (a follow-on task once decided).
- A runtime/plugin schema language — start code-declared and fixed.

## Related

- Epic [26-frontmatter-schema-declared-validation-contract](../epics/26-frontmatter-schema-declared-validation-contract.md) — the 12 open questions live in its body.
- Prior art: `domain.LintTask`, `domain.MissingIDIssue`, `store.parseTask`'s loud
missing-frontmatter failure, `schema --json-schema`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
schema: 1
id: 6fkm17nh3t6y
status: completed
epic: 26-frontmatter-schema-declared-validation-contract
description: Derive knownTaskFields + int/list/date maps from one declarative task-field table (not parallel hand-kept maps); add a Task-struct-tag sync test. Behavior-preserving groundwork for the field registry.
effort: Unknown
tier: 3
priority: medium
autonomy_level: 3
tags: [schema, validation, core]
created: "2026-07-07"
started_at: "2026-07-07"
updated_at: "2026-07-07"
completed_at: "2026-07-07"
---
# Unify task field metadata into one declarative table

First implementation slice of epic 26's Direction #1 ("one declarative field
registry as the single source of truth"). Behavior-preserving; needs none of the
open policy decisions, so it can land ahead of the design ADR.

## Objective

Today the task field metadata is split across parallel, hand-kept maps that must
stay in lockstep by eye: `knownTaskFields` (fields.go), `intFields`/`listFields`
(fields.go), and `dateFields` (validate.go). Add a field and you must remember to
touch every one. Collapse them into a single declarative `taskFields` table
(name → YAML type) that all four maps derive from, so they cannot drift.

## Acceptance criteria

- [ ] `knownTaskFields`, `intFields`, `listFields`, `dateFields` are all derived
from one `taskFields` table; no parallel hand-kept literals remain.
- [ ] Accessors (`KnownTaskField`, `IsIntField`, `IsListField`) and `FieldType`
are unchanged in behavior; existing tests stay green.
- [ ] New sync test: every `domain.Task` frontmatter yaml tag except `id` is a
known task field (closes the "keep in sync with the Task yaml tags" gap
that was previously eyeballed).

## Out of scope

- The open policy questions (per-status strictness, severities, rollout) — those
wait on the ADR.
- Folding LintTask's hand-written checks or the `--json` envelope schema into the
registry — later slices.

## Related

- Epic [26-frontmatter-schema-declared-validation-contract](../epics/26-frontmatter-schema-declared-validation-contract.md)
- Prior art: the `entity.go` descriptor registry, `schema.go` FieldType.
Loading