From 0dde13396de03e99b19e8d31edea932598c7a051 Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Sat, 11 Jul 2026 23:30:51 +0200 Subject: [PATCH 1/4] 165-spec-024-goal-phase-type --- CHANGELOG.md | 4 + pkg/domain/goal_phase.go | 61 +++++++ pkg/domain/goal_phase_test.go | 97 ++++++++++ .../completed/165-spec-024-goal-phase-type.md | 123 +++++++++++++ .../166-spec-024-goal-frontmatter-phase.md | 168 ++++++++++++++++++ specs/in-progress/024-goal-phase-field.md | 127 +++++++++++++ 6 files changed, 580 insertions(+) create mode 100644 pkg/domain/goal_phase.go create mode 100644 pkg/domain/goal_phase_test.go create mode 100644 prompts/completed/165-spec-024-goal-phase-type.md create mode 100644 prompts/in-progress/166-spec-024-goal-frontmatter-phase.md create mode 100644 specs/in-progress/024-goal-phase-field.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e43145d..0dff3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/). * MINOR version when you add functionality in a backwards-compatible manner, and * PATCH version when you make backwards-compatible bug fixes. +## Unreleased + +- feat(domain): add `GoalPhase` string-enum type with four canonical values (`todo`/`planning`/`execution`/`done`), mirroring `TaskPhase` but with no aliases — foundation for goal-phase validation in a later prompt + ## v0.99.2 - docs(help): document XDG config path (~/.config/vault-cli/config.yaml, legacy fallback ~/.vault-cli/config.yaml) in vault-cli --help output and README diff --git a/pkg/domain/goal_phase.go b/pkg/domain/goal_phase.go new file mode 100644 index 0000000..4e88a48 --- /dev/null +++ b/pkg/domain/goal_phase.go @@ -0,0 +1,61 @@ +// Copyright (c) 2025 Benjamin Borbe All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package domain + +import ( + "context" + + "github.com/bborbe/collection" + "github.com/bborbe/errors" + "github.com/bborbe/validation" +) + +// GoalPhase represents a phase in a goal's lifecycle. +type GoalPhase string + +const ( + // GoalPhaseTodo means the goal is ready to start but needs planning. + GoalPhaseTodo GoalPhase = "todo" + // GoalPhasePlanning means the approach is being designed. + GoalPhasePlanning GoalPhase = "planning" + // GoalPhaseExecution means active work is underway. + GoalPhaseExecution GoalPhase = "execution" + // GoalPhaseDone means the goal is ready to close. + GoalPhaseDone GoalPhase = "done" +) + +// AvailableGoalPhases lists all valid canonical goal phase values. +var AvailableGoalPhases = GoalPhases{ + GoalPhaseTodo, + GoalPhasePlanning, + GoalPhaseExecution, + GoalPhaseDone, +} + +// GoalPhases is a collection of GoalPhase values. +type GoalPhases []GoalPhase + +// Contains returns true if the collection contains the given phase. +func (g GoalPhases) Contains(phase GoalPhase) bool { + return collection.Contains(g, phase) +} + +// String returns the string representation of the phase. +func (g GoalPhase) String() string { + return string(g) +} + +// Validate returns an error if the phase is not a valid canonical value. +func (g GoalPhase) Validate(ctx context.Context) error { + if !AvailableGoalPhases.Contains(g) { + return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", g) + } + return nil +} + +// Ptr returns a pointer to a copy of the phase. +func (g GoalPhase) Ptr() *GoalPhase { + return &g +} diff --git a/pkg/domain/goal_phase_test.go b/pkg/domain/goal_phase_test.go new file mode 100644 index 0000000..58f0117 --- /dev/null +++ b/pkg/domain/goal_phase_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2025 Benjamin Borbe All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package domain_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/bborbe/vault-cli/pkg/domain" +) + +var _ = Describe("GoalPhase", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Describe("Validate", func() { + DescribeTable("valid phases", + func(phase domain.GoalPhase) { + Expect(phase.Validate(ctx)).To(BeNil()) + }, + Entry("todo", domain.GoalPhaseTodo), + Entry("planning", domain.GoalPhasePlanning), + Entry("execution", domain.GoalPhaseExecution), + Entry("done", domain.GoalPhaseDone), + ) + + Context("invalid phase", func() { + It("returns an error for unknown phase", func() { + phase := domain.GoalPhase("bogus") + err := phase.Validate(ctx) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unknown goal phase")) + }) + + It("returns an error for empty phase", func() { + phase := domain.GoalPhase("") + err := phase.Validate(ctx) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unknown goal phase")) + }) + + It("returns an error for task-only phase in_progress", func() { + phase := domain.GoalPhase("in_progress") + err := phase.Validate(ctx) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unknown goal phase")) + }) + }) + }) + + Describe("String", func() { + It("returns the string value", func() { + Expect(domain.GoalPhaseExecution.String()).To(Equal("execution")) + Expect(domain.GoalPhasePlanning.String()).To(Equal("planning")) + Expect(domain.GoalPhaseTodo.String()).To(Equal("todo")) + Expect(domain.GoalPhaseDone.String()).To(Equal("done")) + }) + }) + + Describe("Ptr", func() { + It("returns a non-nil pointer with the correct value", func() { + ptr := domain.GoalPhaseExecution.Ptr() + Expect(ptr).NotTo(BeNil()) + Expect(*ptr).To(Equal(domain.GoalPhaseExecution)) + }) + + It("returns independent copies", func() { + p1 := domain.GoalPhaseTodo.Ptr() + p2 := domain.GoalPhaseTodo.Ptr() + Expect(p1).NotTo(BeIdenticalTo(p2)) + }) + }) + + Describe("AvailableGoalPhases.Contains", func() { + It("returns true for valid phases", func() { + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseTodo)).To(BeTrue()) + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhasePlanning)).To(BeTrue()) + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseExecution)).To(BeTrue()) + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhaseDone)).To(BeTrue()) + }) + + It("returns false for invalid phases", func() { + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhase("invalid"))).To(BeFalse()) + Expect(domain.AvailableGoalPhases.Contains(domain.GoalPhase(""))).To(BeFalse()) + Expect( + domain.AvailableGoalPhases.Contains(domain.GoalPhase("in_progress")), + ).To(BeFalse()) + }) + }) +}) diff --git a/prompts/completed/165-spec-024-goal-phase-type.md b/prompts/completed/165-spec-024-goal-phase-type.md new file mode 100644 index 0000000..489e461 --- /dev/null +++ b/prompts/completed/165-spec-024-goal-phase-type.md @@ -0,0 +1,123 @@ +--- +status: completed +spec: [024-goal-phase-field] +summary: Created GoalPhase string-enum type in pkg/domain with four canonical values (todo/planning/execution/done) plus tests, mirroring TaskPhase shape without touching it +execution_id: vault-cli-goal-phase-exec-165-spec-024-goal-phase-type +dark-factory-version: v0.191.4 +created: "2026-07-11T21:30:00Z" +queued: "2026-07-11T21:28:32Z" +started: "2026-07-11T21:28:33Z" +completed: "2026-07-11T21:30:51Z" +branch: dark-factory/goal-phase-field +--- + + +- Introduces a dedicated goal-phase concept with exactly four allowed values: todo, planning, execution, done. +- Setting a goal phase to anything outside those four values will (in a later prompt) fail loudly; this prompt builds the validated type that enables it. +- The goal phase is a deliberate 4-value subset of the 7-value task phase — goals have no ai_review / human_review / in_progress. +- The new type mirrors the shape of the existing task-phase type (canonical constants, an available-set with a membership check, string conversion, validation, pointer helper) but is entirely separate — nothing about tasks changes. +- Ships a table-driven unit test proving each of the four canonical values validates and at least one non-canonical value is rejected. +- This is the data-layer foundation only; no goal command reads or writes the phase yet (that is prompt 2). + + + +Create a `GoalPhase` string-enum type in `pkg/domain` with the four canonical values `todo` / `planning` / `execution` / `done`, mirroring the shape of the existing `TaskPhase` type (constants, `AvailableGoalPhases` collection with `Contains`, `String()`, `Validate(ctx)`, `Ptr()`), plus a Ginkgo `DescribeTable` unit test. Do NOT touch, reuse, or extend the task-phase type. This is the enum foundation only — no frontmatter wiring in this prompt. + + + +Read CLAUDE.md for project conventions. +Read `/home/node/.claude/plugins/marketplaces/coding/docs/go-enum-type-pattern.md` — the canonical string-enum recipe (`Available*` collection, `Validate()`, plural collection type, `Contains()`). +Read `/home/node/.claude/plugins/marketplaces/coding/docs/go-testing-guide.md` — Ginkgo v2 / Gomega, `DescribeTable`/`Entry`, external `_test` package. +Read `/home/node/.claude/plugins/marketplaces/coding/docs/go-error-wrapping-guide.md` — `github.com/bborbe/errors` wrapping idiom and the `github.com/bborbe/validation` sentinel. + +Read these files before implementing — the new type must mirror them exactly: +- `pkg/domain/task_phase.go` — the reference implementation to mirror in shape. Copy the structure of the newtype, the `const` block, the `Available…` var, the plural collection type with `Contains`, `String()`, `Validate(ctx)`, and `Ptr()`. Note the exact imports it uses: `"context"`, `"github.com/bborbe/collection"`, `"github.com/bborbe/errors"`, `"github.com/bborbe/validation"`. Note `Validate` wraps `validation.Error`: `return errors.Wrapf(ctx, validation.Error, "unknown task phase '%s'", t)`. +- `pkg/domain/task_phase_test.go` — the reference test suite. Mirror the `Describe`/`DescribeTable`/`Entry` structure for `Validate`, `String`, `Ptr`, and `Available….Contains`. DROP every alias/`NormalizeTaskPhase`/`in_progress` case — the goal enum has NO aliases. + +Do NOT read the goal enum from training data — the `GoalPhase` type does not yet exist in the repo (`grep -rn "GoalPhase" pkg/` returns nothing). You are creating it. + + + +1. Create `pkg/domain/goal_phase.go` (package `domain`) that declares a `GoalPhase` string newtype mirroring `pkg/domain/task_phase.go` in shape, with EXACTLY these four canonical values — no more, no fewer: + ```go + // GoalPhase represents a phase in a goal's lifecycle. + type GoalPhase string + + const ( + // GoalPhaseTodo means the goal is ready to start but needs planning. + GoalPhaseTodo GoalPhase = "todo" + // GoalPhasePlanning means the approach is being designed. + GoalPhasePlanning GoalPhase = "planning" + // GoalPhaseExecution means active work is underway. + GoalPhaseExecution GoalPhase = "execution" + // GoalPhaseDone means the goal is ready to close. + GoalPhaseDone GoalPhase = "done" + ) + + // AvailableGoalPhases lists all valid canonical goal phase values. + var AvailableGoalPhases = GoalPhases{ + GoalPhaseTodo, + GoalPhasePlanning, + GoalPhaseExecution, + GoalPhaseDone, + } + + // GoalPhases is a collection of GoalPhase values. + type GoalPhases []GoalPhase + + // Contains returns true if the collection contains the given phase. + func (g GoalPhases) Contains(phase GoalPhase) bool { + return collection.Contains(g, phase) + } + + // String returns the string representation of the phase. + func (g GoalPhase) String() string { + return string(g) + } + + // Validate returns an error if the phase is not a valid canonical value. + func (g GoalPhase) Validate(ctx context.Context) error { + if !AvailableGoalPhases.Contains(g) { + return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", g) + } + return nil + } + + // Ptr returns a pointer to a copy of the phase. + func (g GoalPhase) Ptr() *GoalPhase { + return &g + } + ``` + Use imports `"context"`, `"github.com/bborbe/collection"`, `"github.com/bborbe/errors"`, `"github.com/bborbe/validation"`. Prepend the standard BSD license header (copy the 3-line header verbatim from the top of `pkg/domain/task_phase.go`). + +2. Do NOT add a `Normalize…`, alias map, `IsValid…`, `in_progress`, `ai_review`, or `human_review` value. The goal enum has no legacy/alias values (spec Non-goals — hard veto). Do NOT add a "default to todo" helper; a missing phase is empty, not `todo`. + +3. Create `pkg/domain/goal_phase_test.go` (package `domain_test`) — a Ginkgo suite mirroring the applicable Contexts of `pkg/domain/task_phase_test.go`: + - `Validate` → `DescribeTable` "valid phases" with one `Entry` per canonical value (`todo`, `planning`, `execution`, `done`), asserting `Expect(phase.Validate(ctx)).To(BeNil())`. + - `Validate` invalid: a `Context` asserting `domain.GoalPhase("bogus").Validate(ctx)` returns a non-nil error whose `.Error()` contains `"unknown goal phase"`. Also assert `domain.GoalPhase("").Validate(ctx)` returns an error (empty is not canonical). Also assert `domain.GoalPhase("in_progress").Validate(ctx)` returns an error (proves goal enum has no task aliases). + - `String` → asserts `domain.GoalPhaseExecution.String()` equals `"execution"`. + - `Ptr` → asserts a non-nil pointer with the correct value, and that two `Ptr()` calls return independent pointers (`Expect(p1).NotTo(BeIdenticalTo(p2))`). + - `AvailableGoalPhases.Contains` → true for each canonical value; false for `domain.GoalPhase("invalid")`, `domain.GoalPhase("")`, and `domain.GoalPhase("in_progress")`. + Import the domain package as `"github.com/bborbe/vault-cli/pkg/domain"` and the Ginkgo/Gomega dot-imports as in `task_phase_test.go`. The suite bootstrap `pkg/domain/domain_suite_test.go` already exists — do NOT add a new `RunSpecs`. + +4. Do NOT create, modify, or reference `pkg/domain/goal_frontmatter.go` in this prompt — the getter/setter/field-case wiring is prompt 2. If you find yourself editing `goal_frontmatter.go`, stop — that is out of scope here. + + + +- The task-side phase type (`pkg/domain/task_phase.go`), its constants, `NormalizeTaskPhase`, and its test suite (`pkg/domain/task_phase_test.go`) must be byte-for-byte unchanged. Frozen (spec Constraints — hard veto). Verify with `git diff --stat pkg/domain/task_phase.go` showing no changes. +- The goal phase enum has EXACTLY four values. Do NOT add `ai_review`, `human_review`, or `in_progress` — that 4-of-7 subset is intentional (spec Assumptions — hard veto). +- Do NOT add alias/normalize handling — no `in_progress` synonym, no migration map (spec Non-goals — hard veto). +- Error wrapping: use `github.com/bborbe/errors` with `ctx` (`errors.Wrapf(ctx, validation.Error, ...)`); never `fmt.Errorf`; never `context.Background()` in non-test code. +- Tests: Ginkgo v2 / Gomega, external `_test` package, Counterfeiter for mocks (none needed here). Coverage ≥80% for `pkg/domain` — the enum is fully exercised by the table test. +- Do NOT commit — dark-factory handles git. +- Existing tests must still pass. + + + +Run `make test` iteratively while developing (fast feedback). +Run `go test ./pkg/domain/...` — the new `GoalPhase` suite and the existing `TaskPhase` suite both pass (exit 0). +Run `grep -nE '"todo"|"planning"|"execution"|"done"' pkg/domain/goal_phase.go` — returns ≥4 lines (AC evidence). +Run `grep -nE 'in_progress|ai_review|human_review|Normalize' pkg/domain/goal_phase.go` — returns 0 lines (confirms the excluded values/aliases are absent). +Run `git diff --stat pkg/domain/task_phase.go` — shows no changes (task type frozen). +Run `make precommit` — must pass (lint + format + generate + test + version checks). + diff --git a/prompts/in-progress/166-spec-024-goal-frontmatter-phase.md b/prompts/in-progress/166-spec-024-goal-frontmatter-phase.md new file mode 100644 index 0000000..b671e74 --- /dev/null +++ b/prompts/in-progress/166-spec-024-goal-frontmatter-phase.md @@ -0,0 +1,168 @@ +--- +status: approved +spec: [024-goal-phase-field] +created: "2026-07-11T21:30:00Z" +queued: "2026-07-11T21:28:32Z" +branch: dark-factory/goal-phase-field +--- + + +- Goals can now carry a `phase` frontmatter field, set through the existing `goal set phase ` command — no new command is added. +- Setting a canonical phase (`todo` / `planning` / `execution` / `done`) writes `phase: ` into the goal file and it survives read-write cycles. +- Setting a non-canonical phase (e.g. `bogus`) fails with a non-zero exit and an error naming the offending value; the goal file is left untouched. +- Reading a goal through `goal show` surfaces the phase in both plain and `--output json` output when present. +- Goals that predate this field keep parsing, showing, and accepting unrelated edits with no error and no injected phase value — nothing is backfilled. +- A hand-typed legacy value (e.g. `phase: in_progress`) is tolerated on read/display but rejected on an explicit re-set. +- Adds a CHANGELOG entry and a behavioral verification pass against a scratch goal file. + + + +Wire the goal-phase field into the existing goal frontmatter so `goal set phase ` validates and persists a canonical `GoalPhase`, and `goal show ` (plain and `--output json`) surfaces it. Add a typed `Phase()` getter and `SetPhase(*GoalPhase)` setter on `GoalFrontmatter`, plus `phase` cases in `GetField`/`SetField`. No new command, no new ops/CLI code — this rides the existing generic goal set/show wiring. Then add a CHANGELOG entry. + + + +Read CLAUDE.md for project conventions. +Read `/home/node/.claude/plugins/marketplaces/coding/docs/go-testing-guide.md` — Ginkgo v2 / Gomega, `DescribeTable`, external `_test` package. +Read `/home/node/.claude/plugins/marketplaces/coding/docs/go-error-wrapping-guide.md` — `github.com/bborbe/errors` + `github.com/bborbe/validation` sentinel. +Read `/home/node/.claude/plugins/marketplaces/coding/docs/changelog-guide.md` — `## Unreleased` entry format and prefixes. + +Read these files before implementing: +- `pkg/domain/goal_phase.go` — created in prompt 1: `GoalPhase` newtype, `GoalPhaseTodo/Planning/Execution/Done`, `AvailableGoalPhases`, `GoalPhases.Contains`, `GoalPhase.Validate(ctx)`, `GoalPhase.Ptr()`. If this file does NOT exist yet, STOP and report `status: failed` with message "goal phase type not yet deployed (prompt 1)" — do NOT create it here. +- `pkg/domain/goal_frontmatter.go` — the file you extend. Note the existing `GetField(key string) string` switch (adds a `case "phase"`) and `SetField(ctx, key, value string) error` switch (adds a `case "phase"`). Note the setter pattern: existing `Set(...)`/`Delete(...)` come from the embedded `FrontmatterMap`. Note `GetString(key)` returns `""` for a missing key. +- `pkg/domain/task_frontmatter.go` — the reference for phase getter/setter/field-case shape: + - `Phase()` getter (lines ~88-96): reads `GetString("phase")`, returns `nil` on empty, else `&GoalPhase(raw)`. + - `SetPhase(p *TaskPhase)` (lines ~287-294): `nil` → `Delete("phase")`, else `Set("phase", string(*p))`. + - `GetField` `case "phase"` (lines ~346-351): returns `""` when the pointer is nil, else `string(*ph)` — RAW value, no validation on read. + - `setPhaseField` / `SetField` `case "phase"` (lines ~412-449): the task version normalizes aliases via `NormalizeTaskPhase`. The GOAL version must NOT normalize — it validates against `AvailableGoalPhases` directly and rejects anything non-canonical (goal enum has no aliases). +- `pkg/domain/goal_frontmatter_test.go` — the suite you extend. Mirror the existing `Describe("Status")` / `Describe("SetStatus")` / `GetField` / `SetField` block structure for the new phase behavior. External `package domain_test`. +- `pkg/ops/frontmatter_entity.go` — DO NOT MODIFY. Context only: `EntitySetOperation` for goals (`goalSetOperation.Execute`, ~line 100) calls `goal.SetField(ctx, key, value)` directly with no allowlist gate, so the new `SetField` `case "phase"` is automatically reachable via `goal set`. `EntityShowOperation.Execute` (~line 656) iterates over the goal's actual `Keys()` and calls `GetField(k)` per key, so a present `phase:` key is automatically surfaced in `--output json` (`Fields` map) and plain output once `GetField` handles it. `knownGoalScalarFields` (~line 326) only guards the tags list-mutation path; it does NOT need `phase` for set/show/JSON and MUST NOT be edited (spec Constraints: Domain-only footprint). +- `CHANGELOG.md` — top section is `## v0.99.2`. There is no `## Unreleased` yet; add one directly under the intro block (above `## v0.99.2`). + +Depends on prompt 1 (`GoalPhase` type). That prompt lands first. + + + +1. In `pkg/domain/goal_frontmatter.go`, add a typed getter `Phase()` mirroring `TaskFrontmatter.Phase()`. Place it near the other getters (e.g. after `Assignee()`): + ```go + // Phase reads "phase" key as string, returns *GoalPhase. + // Returns nil when the key is absent. The raw value is returned as-is + // (no validation, no default substitution) so legacy/hand-typed values survive display. + func (f GoalFrontmatter) Phase() *GoalPhase { + raw := f.GetString("phase") + if raw == "" { + return nil + } + p := GoalPhase(raw) + return &p + } + ``` + +2. In `pkg/domain/goal_frontmatter.go`, add a setter `SetPhase(p *GoalPhase)` mirroring `TaskFrontmatter.SetPhase`. Place it near the other setters (e.g. after `SetAssignee`): + ```go + // SetPhase stores the phase pointer in the map. Deletes the key if p is nil. + func (f *GoalFrontmatter) SetPhase(p *GoalPhase) { + if p == nil { + f.Delete("phase") + return + } + f.Set("phase", string(*p)) + } + ``` + +3. Add a private field-parse helper `setPhaseField(ctx, value string) error` to `pkg/domain/goal_frontmatter.go` that validates against the goal enum directly (NO alias normalization): + ```go + // setPhaseField validates the value against the goal phase enum and stores it, + // or clears the key on empty. Goal phases have no aliases — a non-canonical value is rejected. + func (f *GoalFrontmatter) setPhaseField(ctx context.Context, value string) error { + if value == "" { + f.SetPhase(nil) + return nil + } + phase := GoalPhase(value) + if err := phase.Validate(ctx); err != nil { + return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", value) + } + f.SetPhase(&phase) + return nil + } + ``` + This requires importing `"github.com/bborbe/validation"` in `goal_frontmatter.go` (the file already imports `"github.com/bborbe/errors"` and `"context"`; add the validation import to the existing import block). + +4. In the existing `GoalFrontmatter.GetField(key string) string` switch, add a `case "phase"` returning the raw value (no validation on read — mirrors task): + ```go + case "phase": + ph := f.Phase() + if ph == nil { + return "" + } + return string(*ph) + ``` + Place it among the existing cases (order does not affect behavior; group logically near `assignee`). + +5. In the existing `GoalFrontmatter.SetField(ctx context.Context, key, value string) error` switch, add a `case "phase"`: + ```go + case "phase": + return f.setPhaseField(ctx, value) + ``` + +6. Do NOT modify `pkg/ops/frontmatter_entity.go`, `pkg/ops/*goal*.go`, `pkg/cli/`, or any storage file. The generic `goal set` / `goal show` wiring already routes through `SetField` / `GetField` / `Keys()`. Do NOT add `phase` to `knownGoalScalarFields` — that map only guards the tags list-mutation path and editing it is outside the Domain-only footprint (spec Constraints). + +7. Extend `pkg/domain/goal_frontmatter_test.go` (package `domain_test`) with these cases. Mirror the existing block style: + - `Describe("Phase")`: + - missing key → `Expect(domain.NewGoalFrontmatter(nil).Phase()).To(BeNil())`. + - present canonical value → `fm := domain.NewGoalFrontmatter(map[string]any{"phase": "execution"})`; `Expect(fm.Phase()).NotTo(BeNil())`; `Expect(*fm.Phase()).To(Equal(domain.GoalPhaseExecution))`. + - present legacy/hand-typed value → `fm := domain.NewGoalFrontmatter(map[string]any{"phase": "in_progress"})`; `Expect(*fm.Phase()).To(Equal(domain.GoalPhase("in_progress")))` (read tolerates the raw value; no validation on read). + - `Describe("SetPhase")`: + - non-nil pointer stores the string: `SetPhase(domain.GoalPhaseDone.Ptr())` then `Get("phase")` equals `"done"` (assert via `fm.GetField("phase")` == `"done"`). + - nil pointer deletes the key: seed `{"phase":"todo"}`, call `SetPhase(nil)`, then `fm.GetField("phase")` == `""` and `phase` is absent from `fm.Keys()`. + - `Describe("SetField phase")` — the write-path validation (this is the load-bearing integration test crossing the validator boundary via the real `SetField` entry point that `goal set` calls): + - `DescribeTable` over the four canonical values: `fm.SetField(ctx, "phase", )` returns nil AND `fm.GetField("phase")` round-trips the same value. + - invalid value: `err := fm.SetField(ctx, "phase", "bogus")`; `Expect(err).NotTo(BeNil())`; `Expect(err.Error()).To(ContainSubstring("unknown goal phase"))`; AND `Expect(err.Error()).To(ContainSubstring("bogus"))` (error names the offending phase); AND the key was NOT written (`fm.GetField("phase")` == `""` when the fm started empty). + - legacy alias on explicit re-set is rejected: `fm.SetField(ctx, "phase", "in_progress")` returns a non-nil error containing `"unknown goal phase"` (goal enum has no aliases). + - empty value clears: seed `{"phase":"execution"}`, `fm.SetField(ctx, "phase", "")` returns nil, `fm.GetField("phase")` == `""`. + - `Describe("GetField phase")`: + - present → `fm := domain.NewGoalFrontmatter(map[string]any{"phase":"planning"})`; `Expect(fm.GetField("phase")).To(Equal("planning"))`. + - absent → `Expect(domain.NewGoalFrontmatter(nil).GetField("phase")).To(Equal(""))`. + - `Describe("legacy goal round-trip")` — a goal with unrelated fields and NO phase: seed `{"status":"active","theme":"x"}`, assert `GetField("phase")` == `""`, then `fm.SetField(ctx, "theme", "y")` (unrelated mutation) returns nil, and `"phase"` is still absent from `fm.Keys()` (no phase injected). + +8. Add a CHANGELOG entry. In `CHANGELOG.md`, add a `## Unreleased` section directly above `## v0.99.2` (below the intro/semver block) with a single bullet: + ``` + ## Unreleased + + - feat(goal): goals carry a validated `phase` frontmatter field (`todo` / `planning` / `execution` / `done`); set via `goal set phase `, surfaced by `goal show` (plain + `--output json`). Legacy goals without a phase are untouched; non-canonical values are rejected on write. Mirrors the task-phase shape without touching the task-phase type. + ``` + If a `## Unreleased` section already exists, append the bullet to it instead of creating a second one. + +9. Behavioral verification against a scratch goal (run after `make precommit` passes, using the freshly built binary — never the installed `vault-cli`). Build with `make build` (or `go build -o /tmp/vault-cli-024 .` if `make build` produces a differently named artifact — check the Makefile). Create a scratch vault with a `Goals/` dir containing a minimal goal markdown file that has frontmatter (`status: next`) and NO `phase:` line, then: + - `goal set phase execution` → exit 0; re-read the file and confirm it now contains a `phase: execution` line. + - `goal show --output json` → output contains `"phase":"execution"` under the fields map. + - `goal set phase bogus` → non-zero exit; stderr contains a message naming `bogus`; re-read the file and confirm the `phase:` line is UNCHANGED (still `execution`, not clobbered). + - On a second goal file with NO `phase:` line: `goal show --output json` → exit 0 and output contains no `phase` value; then `goal set theme foo` (unrelated) → exit 0 and the file still has no `phase:` line. + Capture the exit codes and the relevant output. If the CLI subcommand names or vault-path flags differ from `goal set` / `goal show`, discover them via `vault-cli goal --help` on the built binary and use the real flags — do NOT guess. + + + +- Domain-only footprint. Do NOT add a new `goal update`, `goal status`, `plan-goal`, `execute-goal`, or any phase-transition/gating command (spec Non-goals — hard veto). Phase rides the existing `goal set` / `goal show` exactly as task phase rides `task set` / `task show`. +- Do NOT modify, extend, or reuse the task-side `TaskPhase` type, its constants, or `NormalizeTaskPhase` (spec Non-goals — hard veto). +- Do NOT add alias handling (no `in_progress` synonym) — the goal phase enum has no legacy values (spec Non-goals — hard veto). +- Do NOT invent a "default to todo" read behavior — a missing phase reads as empty/nil, never `todo` (spec Non-goals — hard veto). +- Do NOT backfill, rewrite, or migrate existing goal files that lack a phase (spec Non-goals — hard veto). +- Do NOT edit `pkg/ops/frontmatter_entity.go`, `knownGoalScalarFields`, storage, or CLI — the generic set/show wiring is reused unchanged (spec Constraints). +- Frontmatter remains map-based (`FrontmatterMap`); unknown keys must continue to survive read-write cycles — no separate migration code (spec Constraints). +- Do NOT add or extend goal-specific status/phase mismatch lint rules (spec Non-goals — hard veto). Legacy goals (no phase) must remain lint-clean. +- Error wrapping: `github.com/bborbe/errors` with `ctx` and the `github.com/bborbe/validation` sentinel; never `fmt.Errorf`; never `context.Background()` in non-test code. +- Tests: Ginkgo v2 / Gomega, external `_test` package. Coverage ≥80% for changed code in `pkg/domain`; test every added path including the invalid-value and empty-value branches. +- Do NOT commit — dark-factory handles git. +- Existing tests must still pass; every task command behaves identically to before. + + + +Run `make test` iteratively while developing. +Run `go test ./pkg/domain/...` — the extended goal-frontmatter suite passes (exit 0). +Run `go test -coverprofile=/tmp/cover.out -mod=mod ./pkg/domain/... && go tool cover -func=/tmp/cover.out` — changed goal-frontmatter phase paths are covered (≥80%). +Run `grep -n "func (f GoalFrontmatter) Phase\|func (f \*GoalFrontmatter) SetPhase\|setPhaseField\|case \"phase\"" pkg/domain/goal_frontmatter.go` — returns the getter, setter, helper, and both switch cases. +Run `git diff --stat pkg/domain/task_phase.go pkg/domain/task_frontmatter.go pkg/ops/frontmatter_entity.go` — shows no changes (task type + ops frozen). +Run `grep -n 'goal phase\|phase.*frontmatter field' CHANGELOG.md` — returns ≥1 line (AC evidence). +Perform the scratch-goal behavioral checks from requirement 9 against the freshly built binary and confirm: valid phase writes `phase: execution`; JSON surfaces `"phase":"execution"`; invalid `bogus` exits non-zero and leaves the file unchanged; a no-phase goal shows/sets cleanly with no phase value. +Run `make precommit` — must pass in the repo root (lint + format + generate + test + version checks). Non-zero exit = report `status: failed`. + diff --git a/specs/in-progress/024-goal-phase-field.md b/specs/in-progress/024-goal-phase-field.md new file mode 100644 index 0000000..2ded985 --- /dev/null +++ b/specs/in-progress/024-goal-phase-field.md @@ -0,0 +1,127 @@ +--- +status: prompted +tags: + - dark-factory + - spec +approved: "2026-07-11T21:05:22Z" +generating: "2026-07-11T21:05:23Z" +prompted: "2026-07-11T21:12:18Z" +branch: dark-factory/goal-phase-field +--- + +## Summary + +- Goals gain a validated `phase` frontmatter field with the four values `todo`, `planning`, `execution`, `done`. +- The field mirrors the shape of the existing task-side `Phase` type (newtype, canonical constant set, `Available…`, `Validate`) without touching or reusing the task type's storage. +- Setting an invalid phase on a goal fails loudly; a valid phase is written to the goal file's frontmatter and survives read-write cycles. +- Goals that predate this field keep parsing and operating with no error — no file is backfilled. +- This is the data-layer foundation only. The plan-goal / execute-goal gate commands that will consume the phase are explicitly out of scope. + +## Problem + +Tasks in vault-cli already carry a lifecycle `phase` (`todo → planning → execution → done`) that gate commands read to enforce a plan-before-execute workflow. Goals have no equivalent field, so the "Phase-Gated Goal Flow" workflow has nothing to read or write at the goal level. Before any goal-level gating command can exist, the goal domain type must be able to hold, validate, and surface a phase value — and it must do so without disturbing the millions-of-files-be-damned reality that most existing goal files have no `phase:` line at all. + +## Goal + +After this work, a goal file can carry a `phase` frontmatter field constrained to `todo` / `planning` / `execution` / `done`. Setting the field through the existing goal field-mutation command validates the value against that enum and persists it; reading the goal through the existing goal show command surfaces the value in both plain and JSON output. Goals with no `phase` field continue to parse, show, and mutate exactly as they do today. The task-side phase type and every task command behave identically to before. + +## Non-goals + +- Do NOT add plan-goal / execute-goal / any phase-transition or gating command — those are separate direct-markdown work that consumes this field. +- Do NOT add a new `goal update` or `goal status` command. The repo's goal surface is `goal set ` (validated write) and `goal show ` (surface). Phase rides those existing commands, exactly as task phase rides `task set` / `task show`. +- Do NOT backfill, rewrite, or migrate existing goal files that lack a phase. +- Do NOT modify, extend, or reuse the task-side `TaskPhase` type, its constants, or `NormalizeTaskPhase`. +- Do NOT add alias handling (e.g. an `in_progress` synonym) — the goal phase enum has no legacy values; if a future consumer needs one, that is a separate spec. +- Do NOT invent a "default to todo" read behavior — the task side returns an empty/nil phase for a missing key and the goal side mirrors that; a missing phase is empty, not `todo`. +- Do NOT add or extend goal-specific status/phase mismatch lint rules in this work. + +## Desired Behavior + +1. A goal-phase enum type exists, mirroring the shape of the task-side phase type: a string newtype, one canonical constant per value (`todo`, `planning`, `execution`, `done`), an `Available…` collection with a `Contains` check, a `String()` method, a `Validate(ctx)` method returning a validation error for any non-canonical value, and a `Ptr()` helper. +2. The goal frontmatter exposes a typed `phase` getter that returns the parsed phase when the key is present and an empty/nil result when the key is absent — no default substitution. +3. Setting the goal `phase` field to a canonical value through the existing goal field-set command writes `phase: ` into the goal file's frontmatter and preserves it through a read-write cycle. +4. Setting the goal `phase` field to any non-canonical value through the goal field-set command fails with a non-zero exit and an error message naming the offending phase; the goal file is left unchanged. +5. The existing goal show command surfaces the `phase` value in both plain output and `--output json` when the field is present. +6. A goal file with no `phase` field parses, shows, and accepts unrelated field mutations with no error, and its output contains no phase value. + +## Suggested Decomposition + +Single-layer (Domain-only) footprint — one prompt suffices. If the generator splits, this ordering holds: + +| # | Prompt focus | Covers DBs | Covers ACs | Depends on | +|---|---|---|---|---| +| 1 | Goal-phase enum type (`goal_phase.go`) + `Validate` + `DescribeTable` unit test | #1 | #1, #2, #7 | — | +| 2 | Goal frontmatter typed getter + field-set case (`goal_frontmatter.go`) wiring into generic `goal set`/`goal show` | #2–#6 | #3, #4, #5, #6 | 1 | +| 3 | CHANGELOG `## Unreleased` entry + verification pass | — | #8 | 2 | + +## Security / Abuse Cases + +N/A — the only user input is a phase value validated against a closed 4-value enum before any write; the write target is an existing named goal file resolved through the standard vault path. No path-traversal, injection, or unvalidated-input surface introduced. + +## Constraints + +- The task-side phase type, its constants, `NormalizeTaskPhase`, and every task command must be byte-for-byte unchanged. Frozen: existing task-phase behavior and its test suite. +- Frontmatter remains map-based (`FrontmatterMap`); unknown keys must continue to survive read-write cycles. That map is the lazy-migration mechanism — no separate migration code. +- Follow the layered pattern in `docs/development-patterns.md` (Domain → Storage → Ops → CLI). The expected footprint is Domain-only (new goal-phase type + goal frontmatter getter/setter/field-case); Storage, Ops, and CLI reuse the existing generic goal set/show wiring and require no new command. +- The existing generic `status/phase mismatch` lint keys off the presence of a `phase:` line and will begin evaluating goals that carry a phase. Legacy goals (no phase) must remain lint-clean, and this work must not add new false-positive lint output for the four canonical goal phases on an otherwise-consistent goal. +- `make precommit` must pass in the repo root. +- A `## Unreleased` (or top-of-file dated) CHANGELOG entry describing the new goal phase field is required. + +## Assumptions + +- The caller's shorthand `goal update --phase ` maps to the repo's actual `goal set phase `; `goal status ` maps to `goal show `. The spec is written against the real command surface. +- The four goal phases are a deliberate subset of the seven task phases (goal has no `ai_review` / `human_review` / `in_progress`). This is intended, not an omission. +- Relevant coding guides are available in-container: `go-enum-type-pattern.md`, `go-parse-pattern.md`, `go-cli-guide.md`, `go-testing-guide.md`. + +## Failure Modes + +| Trigger | Expected behavior | Recovery | Detection | Reversibility | +|---------|-------------------|----------|-----------|---------------| +| `goal set phase bogus` | Non-zero exit; error names the invalid phase; file unchanged | Re-run with a canonical value | Command exit code + stderr message | Reversible (no write occurred) | +| Goal file has no `phase` key | Parses and operates normally; phase reads as empty | None needed | `goal show` exits 0 with no phase value | N/A | +| Goal file has a legacy/hand-typed `phase: in_progress` (not in the goal enum) | Reading tolerates the raw value in show output; validation on an explicit re-set rejects it | Set a canonical value | `goal show` displays raw value; `goal set` rejects | Reversible | +| Goal carries `phase: execution` with a terminal status | Existing generic status/phase mismatch lint may flag it; core get/set/show still function | Fix status or phase to a consistent pair | `goal lint` output | Reversible | +| Concurrent `goal set phase` on the same file | Last writer wins (existing whole-file write semantics; no new locking introduced) | Re-read and re-set if clobbered | File content after both writes | Reversible | + +## Acceptance Criteria + +- [ ] A goal-phase enum type declares canonical constants for `todo`, `planning`, `execution`, `done` and a matching `Available…` collection — evidence: `grep -nE '"todo"|"planning"|"execution"|"done"' pkg/domain/goal_phase.go` returns ≥4 lines. +- [ ] The goal-phase type rejects a non-canonical value via `Validate` and accepts each canonical value — evidence: a `DescribeTable` unit test covering the 4 canonical values plus ≥1 invalid value passes under `go test ./pkg/domain/...` (exit 0). +- [ ] `goal set phase execution` on a real goal file writes `phase: execution` to that file's frontmatter — evidence: `git diff` (or file read) of the goal file shows an added `phase: execution` line. +- [ ] `goal set phase bogus` exits non-zero and leaves the file unchanged — evidence: shell exit code ≠ 0 and stderr contains a message naming the invalid goal phase; `git status` shows the goal file unmodified. +- [ ] `goal show --output json` on a goal whose phase is set includes the phase value — evidence: JSON output contains `"phase":"execution"` (under the fields map). +- [ ] A goal file with no `phase` key runs `goal show` and an unrelated `goal set` cleanly — evidence: both commands exit 0; `goal show --output json` output contains no `phase` value; the round-tripped file still has no `phase:` line. +- [ ] The task-side phase type file is unchanged — evidence: `git diff --stat pkg/domain/task_phase.go` shows no changes. +- [ ] `make precommit` exits 0 in the repo root — evidence: exit code. +- [ ] A CHANGELOG entry for the goal phase field exists under `## Unreleased` (or the current top dated section) — evidence: `grep -n 'goal phase' CHANGELOG.md` returns ≥1 line. + +Scenario coverage: NO new scenario. Unit tests (domain enum + frontmatter getter/setter) plus integration-level exercise of the existing goal set/show commands reach every behavior; no real Docker / cluster / external tool is involved. + +## Verification + +Behavioral check against a temporary goal file, plus the standard build gate: + +``` +# 1. Build gate +make precommit + +# 2. Valid phase flips frontmatter (against a scratch vault/goal) +vault-cli goal set phase execution +# -> exit 0; goal file frontmatter now contains: phase: execution + +# 3. JSON surfaces the phase +vault-cli goal show --output json +# -> output includes "phase":"execution" + +# 4. Invalid phase is rejected, file untouched +vault-cli goal set phase bogus +# -> non-zero exit; stderr names the invalid goal phase; git status shows file unmodified + +# 5. Legacy goal (no phase) runs clean +vault-cli goal show --output json +# -> exit 0; no phase value in output +``` + +## Do-Nothing Option + +If we do nothing, the "Phase-Gated Goal Flow" workflow cannot begin — the gate commands would have no goal-level field to read or write, and would have to either invent an ad-hoc key (diverging from the task-phase convention) or track goal phase outside the vault files. The current state (goals with no phase concept) is acceptable only for as long as goal-level gating is not pursued; this spec is the minimal unblocking step. From ceb56c38d643c6d737a6de84c4fe3d66c3bfb7f2 Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Sat, 11 Jul 2026 23:34:15 +0200 Subject: [PATCH 2/4] 166-spec-024-goal-frontmatter-phase --- CHANGELOG.md | 1 + pkg/domain/goal_frontmatter.go | 45 ++++++++++ pkg/domain/goal_frontmatter_test.go | 90 +++++++++++++++++++ .../166-spec-024-goal-frontmatter-phase.md | 7 +- 4 files changed, 142 insertions(+), 1 deletion(-) rename prompts/{in-progress => completed}/166-spec-024-goal-frontmatter-phase.md (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dff3bc..e96256c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Please choose versions by [Semantic Versioning](http://semver.org/). ## Unreleased - feat(domain): add `GoalPhase` string-enum type with four canonical values (`todo`/`planning`/`execution`/`done`), mirroring `TaskPhase` but with no aliases — foundation for goal-phase validation in a later prompt +- feat(goal): goals carry a validated `phase` frontmatter field (`todo` / `planning` / `execution` / `done`); set via `goal set phase `, surfaced by `goal show` (plain + `--output json`). Legacy goals without a phase are untouched; non-canonical values are rejected on write. Mirrors the task-phase shape without touching the task-phase type. ## v0.99.2 diff --git a/pkg/domain/goal_frontmatter.go b/pkg/domain/goal_frontmatter.go index c18387a..b9b15e5 100644 --- a/pkg/domain/goal_frontmatter.go +++ b/pkg/domain/goal_frontmatter.go @@ -12,6 +12,7 @@ import ( "github.com/bborbe/errors" libtime "github.com/bborbe/time" + "github.com/bborbe/validation" ) // GoalFrontmatter holds the YAML frontmatter for a Goal. @@ -63,6 +64,18 @@ func (f GoalFrontmatter) Priority() Priority { // Assignee reads "assignee" key. func (f GoalFrontmatter) Assignee() string { return f.GetString("assignee") } +// Phase reads "phase" key as string, returns *GoalPhase. +// Returns nil when the key is absent. The raw value is returned as-is +// (no validation, no default substitution) so legacy/hand-typed values survive display. +func (f GoalFrontmatter) Phase() *GoalPhase { + raw := f.GetString("phase") + if raw == "" { + return nil + } + p := GoalPhase(raw) + return &p +} + // ClaudeSessionID reads "claude_session_id" key as string. func (f GoalFrontmatter) ClaudeSessionID() string { return f.GetString("claude_session_id") } @@ -151,6 +164,15 @@ func (f *GoalFrontmatter) SetPriority(ctx context.Context, p Priority) error { // SetAssignee stores the assignee in the map. func (f *GoalFrontmatter) SetAssignee(v string) { f.Set("assignee", v) } +// SetPhase stores the phase pointer in the map. Deletes the key if p is nil. +func (f *GoalFrontmatter) SetPhase(p *GoalPhase) { + if p == nil { + f.Delete("phase") + return + } + f.Set("phase", string(*p)) +} + // SetClaudeSessionID stores the claude_session_id in the map. func (f *GoalFrontmatter) SetClaudeSessionID(v string) { f.Set("claude_session_id", v) } @@ -219,6 +241,12 @@ func (f GoalFrontmatter) GetField(key string) string { return strconv.Itoa(int(p)) case "assignee": return f.Assignee() + case "phase": + ph := f.Phase() + if ph == nil { + return "" + } + return string(*ph) case "start_date": return dateFieldString(f.StartDate()) case "target_date": @@ -253,6 +281,8 @@ func (f *GoalFrontmatter) SetField(ctx context.Context, key, value string) error return f.setPriorityFromString(ctx, value) case "assignee": f.SetAssignee(value) + case "phase": + return f.setPhaseField(ctx, value) case "start_date": return setDateField(ctx, f.SetStartDate, value) case "target_date": @@ -304,6 +334,21 @@ func (f *GoalFrontmatter) setCompletedFromString(ctx context.Context, value stri return nil } +// setPhaseField validates the value against the goal phase enum and stores it, +// or clears the key on empty. Goal phases have no aliases — a non-canonical value is rejected. +func (f *GoalFrontmatter) setPhaseField(ctx context.Context, value string) error { + if value == "" { + f.SetPhase(nil) + return nil + } + phase := GoalPhase(value) + if err := phase.Validate(ctx); err != nil { + return errors.Wrapf(ctx, validation.Error, "unknown goal phase '%s'", value) + } + f.SetPhase(&phase) + return nil +} + func (f *GoalFrontmatter) setDeferDateFromString(ctx context.Context, value string) error { if value == "" { f.SetDeferDate(nil) diff --git a/pkg/domain/goal_frontmatter_test.go b/pkg/domain/goal_frontmatter_test.go index 35e2fa6..c3274a7 100644 --- a/pkg/domain/goal_frontmatter_test.go +++ b/pkg/domain/goal_frontmatter_test.go @@ -292,6 +292,96 @@ var _ = Describe("GoalFrontmatter", func() { }) }) + Describe("Phase", func() { + It("returns nil for missing key", func() { + Expect(domain.NewGoalFrontmatter(nil).Phase()).To(BeNil()) + }) + + It("returns pointer for canonical value", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"phase": "execution"}) + Expect(fm.Phase()).NotTo(BeNil()) + Expect(*fm.Phase()).To(Equal(domain.GoalPhaseExecution)) + }) + + It("returns pointer for legacy hand-typed value without validation", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"phase": "in_progress"}) + Expect(fm.Phase()).NotTo(BeNil()) + Expect(*fm.Phase()).To(Equal(domain.GoalPhase("in_progress"))) + }) + }) + + Describe("SetPhase", func() { + It("stores the phase string via pointer", func() { + fm := domain.NewGoalFrontmatter(nil) + fm.SetPhase(domain.GoalPhaseDone.Ptr()) + Expect(fm.GetField("phase")).To(Equal("done")) + }) + + It("nil pointer deletes the key", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"phase": "todo"}) + fm.SetPhase(nil) + Expect(fm.GetField("phase")).To(Equal("")) + Expect(fm.Keys()).NotTo(ContainElement("phase")) + }) + }) + + Describe("SetField phase", func() { + DescribeTable("accepts canonical values", + func(value string) { + fm := domain.NewGoalFrontmatter(nil) + Expect(fm.SetField(ctx, "phase", value)).To(Succeed()) + Expect(fm.GetField("phase")).To(Equal(value)) + }, + Entry("todo", "todo"), + Entry("planning", "planning"), + Entry("execution", "execution"), + Entry("done", "done"), + ) + + It("rejects invalid value with named error", func() { + fm := domain.NewGoalFrontmatter(nil) + err := fm.SetField(ctx, "phase", "bogus") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unknown goal phase")) + Expect(err.Error()).To(ContainSubstring("bogus")) + Expect(fm.GetField("phase")).To(Equal("")) + }) + + It("rejects legacy alias on explicit re-set", func() { + fm := domain.NewGoalFrontmatter(nil) + err := fm.SetField(ctx, "phase", "in_progress") + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(ContainSubstring("unknown goal phase")) + }) + + It("clears phase on empty value", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"phase": "execution"}) + Expect(fm.SetField(ctx, "phase", "")).To(Succeed()) + Expect(fm.GetField("phase")).To(Equal("")) + }) + }) + + Describe("GetField phase", func() { + It("returns value when present", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"phase": "planning"}) + Expect(fm.GetField("phase")).To(Equal("planning")) + }) + + It("returns empty when absent", func() { + Expect(domain.NewGoalFrontmatter(nil).GetField("phase")).To(Equal("")) + }) + }) + + Describe("legacy goal round-trip", func() { + It("does not inject phase on unrelated mutation", func() { + fm := domain.NewGoalFrontmatter(map[string]any{"status": "active", "theme": "x"}) + Expect(fm.GetField("phase")).To(Equal("")) + Expect(fm.SetField(ctx, "theme", "y")).To(Succeed()) + Expect(fm.GetField("phase")).To(Equal("")) + Expect(fm.Keys()).NotTo(ContainElement("phase")) + }) + }) + Describe("DeferDate", func() { It("returns nil for missing key", func() { Expect(fm.DeferDate()).To(BeNil()) diff --git a/prompts/in-progress/166-spec-024-goal-frontmatter-phase.md b/prompts/completed/166-spec-024-goal-frontmatter-phase.md similarity index 97% rename from prompts/in-progress/166-spec-024-goal-frontmatter-phase.md rename to prompts/completed/166-spec-024-goal-frontmatter-phase.md index b671e74..49fdc31 100644 --- a/prompts/in-progress/166-spec-024-goal-frontmatter-phase.md +++ b/prompts/completed/166-spec-024-goal-frontmatter-phase.md @@ -1,8 +1,13 @@ --- -status: approved +status: completed spec: [024-goal-phase-field] +summary: Wire GoalPhase into goal frontmatter with Phase()/SetPhase() getters/setters, case phase in GetField/SetField, private setPhaseField validator, plus tests and CHANGELOG entry +execution_id: vault-cli-goal-phase-exec-166-spec-024-goal-frontmatter-phase +dark-factory-version: v0.191.4 created: "2026-07-11T21:30:00Z" queued: "2026-07-11T21:28:32Z" +started: "2026-07-11T21:30:53Z" +completed: "2026-07-11T21:34:15Z" branch: dark-factory/goal-phase-field --- From 48f5f924cb570bd979aea83ee3cb75e1b2ac7a9d Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Sat, 11 Jul 2026 23:54:03 +0200 Subject: [PATCH 3/4] docs(changelog): reword to 'goal phase' to satisfy spec 024 AC9 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e96256c..a341fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Please choose versions by [Semantic Versioning](http://semver.org/). ## Unreleased -- feat(domain): add `GoalPhase` string-enum type with four canonical values (`todo`/`planning`/`execution`/`done`), mirroring `TaskPhase` but with no aliases — foundation for goal-phase validation in a later prompt +- feat(domain): add `GoalPhase` string-enum type with four canonical values (`todo`/`planning`/`execution`/`done`), mirroring `TaskPhase` but with no aliases — foundation for goal phase validation in a later prompt - feat(goal): goals carry a validated `phase` frontmatter field (`todo` / `planning` / `execution` / `done`); set via `goal set phase `, surfaced by `goal show` (plain + `--output json`). Legacy goals without a phase are untouched; non-canonical values are rejected on write. Mirrors the task-phase shape without touching the task-phase type. ## v0.99.2 From c1bb482b0c588153bf900ed7ec0b0dc7fc888302 Mon Sep 17 00:00:00 2001 From: Benjamin Borbe Date: Sat, 11 Jul 2026 23:55:53 +0200 Subject: [PATCH 4/4] chore(spec): mark 024 goal-phase-field complete --- specs/{in-progress => completed}/024-goal-phase-field.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename specs/{in-progress => completed}/024-goal-phase-field.md (99%) diff --git a/specs/in-progress/024-goal-phase-field.md b/specs/completed/024-goal-phase-field.md similarity index 99% rename from specs/in-progress/024-goal-phase-field.md rename to specs/completed/024-goal-phase-field.md index 2ded985..2003bbe 100644 --- a/specs/in-progress/024-goal-phase-field.md +++ b/specs/completed/024-goal-phase-field.md @@ -1,11 +1,13 @@ --- -status: prompted +status: completed tags: - dark-factory - spec approved: "2026-07-11T21:05:22Z" generating: "2026-07-11T21:05:23Z" prompted: "2026-07-11T21:12:18Z" +verifying: "2026-07-11T21:34:16Z" +completed: "2026-07-11T21:55:30Z" branch: dark-factory/goal-phase-field ---