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
1 change: 1 addition & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ All features (CLI, MCP, and Rich TUI) are fully operational, tested, and integra

> **Active Monitors & Agent Skill (Completed):** Added `## Active Monitors` to the Dossier schema (`assets/guide.md`) for tracking live, mutable context (e.g., Slack threads, Jira tickets) separately from static archived references. We have also created a "Resumption Protocol" Skill (`assets/skill.md`) that is embedded and written out during `dossier init` to `~/.dossier/context/skill.md`. `init` also configures Claude Code's `customInstructions` to point to this skill so agents automatically know to poll these monitors upon resuming a dossier.
> **TUI catch-up to the session-id fix (2026-06-16) — later superseded:** The TUI's session/active presentation was updated to reflect real-session vs fallback (honest header banner + standalone footer warning). This was **superseded on 2026-06-17 by ADR 0004**, which removed the session/active concept from the TUI entirely (see the de-sessioned entry above). The honest-banner/footer work no longer exists.
> **Frontmatter backward compatibility (2026-06-25):** A store written by an older build (e.g. a three-level `importance: medium` from before the binary high/low schema) no longer hard-fails on load or edit. The invariant is *map toward attention, not away*: `Frontmatter.Normalize` (`internal/core/dossier.go`) coerces any value that is no longer a valid enum member, or a field absent from an older file, to its highest-attention valid value (`importance`/`urgency` → `high`, `status` → `active`). It is wired at three layers: (1) **read** — `CalculatePriorityScore` normalizes its copy so legacy values sort toward attention immediately; (2) **write** — `Service.Save` heals + persists + emits a `Warning` for every coercion; (3) **startup** — a version-gated one-time sweep (`Service.Migrate`, `config.schema_version` vs `core.CurrentSchemaVersion`) runs from `wire`, rewriting every stale Dossier once and logging to **stderr** (never stdout, so `mcp serve` stays clean). `doctor` reports legacy values as healable rather than fatal. The write-side source of `medium` was also closed (MCP tool schema, CLI `--importance/--urgency` mapping, SPEC §357). To extend for a future field: add the field's `Normalize` + one block in `Frontmatter.Normalize`, and bump `CurrentSchemaVersion`.



Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ dossier status <slug-or-id> <active|waiting|blocked|resolved|archived>
dossier lead <slug-or-id> "<lead-name>"
dossier next <slug-or-id> "<next action>"
dossier questions <slug-or-id> add|set|clear [...]
dossier priority <slug-or-id> --importance <h|m|l> --urgency <h|m|l> [--due <date>]
dossier priority <slug-or-id> --importance <h|l> --urgency <h|l> [--due <date>]
dossier active [--session <session-id>] [--json]
dossier switch <slug-or-id> [--session <session-id>] [--json]
dossier path [<slug-or-id>] [--json]
Expand Down
38 changes: 31 additions & 7 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -862,22 +862,25 @@ func NewRootCmd() *cobra.Command {
updates := make(map[string]any)
if importanceFlag != "" {
switch importanceFlag {
case "h":
case "h", "high", "m", "medium":
// importance/urgency are binary (high|low); "medium" and other
// non-low values map toward attention rather than being written
// as an invalid value the store would later reject.
updates["importance"] = "high"
case "l":
case "l", "low":
updates["importance"] = "low"
default:
updates["importance"] = importanceFlag
updates["importance"] = "high"
}
}
if urgencyFlag != "" {
switch urgencyFlag {
case "h":
case "h", "high", "m", "medium":
updates["urgency"] = "high"
case "l":
case "l", "low":
updates["urgency"] = "low"
default:
updates["urgency"] = urgencyFlag
updates["urgency"] = "high"
}
}
if dueFlag != "" {
Expand Down Expand Up @@ -1194,7 +1197,28 @@ func wire(dossierHome string) (*core.Service, error) {
hregAdapter := harness.NewRegistry(dossierHome)
clockAdapter := &realClock{}

return core.NewService(storeAdapter, searchAdapter, tokAdapter, hregAdapter, clockAdapter, cfg.ToCoreConfig()), nil
svc := core.NewService(storeAdapter, searchAdapter, tokAdapter, hregAdapter, clockAdapter, cfg.ToCoreConfig())

// One-time, version-gated migration: when the store was last touched by an
// older build, eagerly heal any frontmatter the current schema no longer
// accepts (e.g. a removed enum value, or a newly required field). The version
// gate makes this a cheap no-op on every subsequent launch. Output goes to
// stderr so it never corrupts the MCP stdio protocol on `mcp serve`.
if cfg.SchemaVersion < core.CurrentSchemaVersion {
if res, err := svc.Migrate(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "Warning: frontmatter migration skipped: %v\n", err)
} else {
for _, w := range res.Warnings {
fmt.Fprintf(os.Stderr, "%s\n", w)
}
cfg.SchemaVersion = core.CurrentSchemaVersion
if err := cfg.Save(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not record schema version (migration will re-run next launch): %v\n", err)
}
}
}

return svc, nil
}

func expandTilde(path string) string {
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import (
type Config struct {
DossierHome string `yaml:"dossier_home"`
TokenTarget int `yaml:"token_target"`
// SchemaVersion records the frontmatter schema the store was last migrated to.
// It is intentionally left at its zero value in Default so that a config file
// written by an older build (which lacks the key) is detected as stale and
// triggers the one-time migration sweep on the next launch.
SchemaVersion int `yaml:"schema_version"`
}

// Default returns the default configuration with standard paths.
Expand Down
70 changes: 70 additions & 0 deletions internal/core/dossier.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ func (s Status) IsValid() bool {
return false
}

// Normalize coerces an invalid or missing status toward attention.
// Backward-compatibility rule: a value that is no longer recognized (a removed
// enum member) or that is empty (a field added after the file was written) maps
// to the highest-attention valid value, so a stale Dossier surfaces rather than
// silently sinking. It returns the coerced value and whether a change was made;
// it is idempotent on already-valid values.
func (s Status) Normalize() (Status, bool) {
if s.IsValid() {
return s, false
}
return StatusActive, true
}

// Importance represents the priority dimension of importance.
type Importance string

Expand All @@ -42,6 +55,16 @@ func (i Importance) IsValid() bool {
return false
}

// Normalize coerces an invalid or missing importance toward attention.
// See Status.Normalize for the backward-compatibility rule; the highest-attention
// value for importance is "high".
func (i Importance) Normalize() (Importance, bool) {
if i.IsValid() {
return i, false
}
return ImportanceHigh, true
}

// Urgency represents the priority dimension of urgency.
type Urgency string

Expand All @@ -59,6 +82,16 @@ func (u Urgency) IsValid() bool {
return false
}

// Normalize coerces an invalid or missing urgency toward attention.
// See Status.Normalize for the backward-compatibility rule; the highest-attention
// value for urgency is "high".
func (u Urgency) Normalize() (Urgency, bool) {
if u.IsValid() {
return u, false
}
return UrgencyHigh, true
}

// Frontmatter represents the parsed metadata block of a Dossier.
// In conformance with BUILD-DECISIONS, base_revision is session-side, not in frontmatter.
type Frontmatter struct {
Expand All @@ -78,6 +111,43 @@ type Frontmatter struct {
TokenTarget int `yaml:"token_target,omitempty"`
}

// FrontmatterFix records a single backward-compatibility coercion applied by
// Normalize, so the change can be surfaced to the user rather than done silently.
type FrontmatterFix struct {
Field string
From string
To string
}

// Normalize brings frontmatter into conformance with the current schema for
// backward compatibility, mutating the receiver and returning the list of
// coercions made (empty if already canonical). It is the single, extensible
// place that heals Dossiers written by older builds:
//
// - a value that is no longer a valid enum member (e.g. a removed "medium")
// is mapped toward attention by the field's own Normalize;
// - a field added after the file was written is empty, and likewise resolves
// to its attention default.
//
// To support a new enum field, add its Normalize and one block here. The method
// is pure (no I/O) and idempotent.
func (f *Frontmatter) Normalize() []FrontmatterFix {
var fixes []FrontmatterFix
if v, changed := f.Status.Normalize(); changed {
fixes = append(fixes, FrontmatterFix{Field: "status", From: string(f.Status), To: string(v)})
f.Status = v
}
if v, changed := f.Importance.Normalize(); changed {
fixes = append(fixes, FrontmatterFix{Field: "importance", From: string(f.Importance), To: string(v)})
f.Importance = v
}
if v, changed := f.Urgency.Normalize(); changed {
fixes = append(fixes, FrontmatterFix{Field: "urgency", From: string(f.Urgency), To: string(v)})
f.Urgency = v
}
return fixes
}

// Validate ensures that all required fields are present and valid.
func (f *Frontmatter) Validate() error {
if f.ID == "" {
Expand Down
187 changes: 187 additions & 0 deletions internal/core/normalize_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package core

import (
"context"
"strings"
"testing"
"time"
)

func TestImportanceNormalize(t *testing.T) {
cases := []struct {
name string
in Importance
want Importance
changed bool
}{
{"valid high stays", ImportanceHigh, ImportanceHigh, false},
{"valid low stays", ImportanceLow, ImportanceLow, false},
{"removed value maps up", Importance("medium"), ImportanceHigh, true},
{"unknown value maps up", Importance("urgent"), ImportanceHigh, true},
{"missing value maps up", Importance(""), ImportanceHigh, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, changed := tc.in.Normalize()
if got != tc.want || changed != tc.changed {
t.Fatalf("Normalize(%q) = (%q, %v), want (%q, %v)", tc.in, got, changed, tc.want, tc.changed)
}
// Idempotent: normalizing the result is a no-op.
if got2, changed2 := got.Normalize(); got2 != got || changed2 {
t.Fatalf("not idempotent: %q -> (%q, %v)", got, got2, changed2)
}
})
}
}

func TestUrgencyNormalize(t *testing.T) {
if got, changed := Urgency("medium").Normalize(); got != UrgencyHigh || !changed {
t.Fatalf("medium urgency: got (%q, %v), want (high, true)", got, changed)
}
if got, changed := UrgencyLow.Normalize(); got != UrgencyLow || changed {
t.Fatalf("valid low urgency should be unchanged, got (%q, %v)", got, changed)
}
}

func TestStatusNormalize(t *testing.T) {
if got, changed := Status("stalled").Normalize(); got != StatusActive || !changed {
t.Fatalf("unknown status: got (%q, %v), want (active, true)", got, changed)
}
if got, changed := Status("").Normalize(); got != StatusActive || !changed {
t.Fatalf("missing status: got (%q, %v), want (active, true)", got, changed)
}
if got, changed := StatusResolved.Normalize(); got != StatusResolved || changed {
t.Fatalf("valid status should be unchanged, got (%q, %v)", got, changed)
}
}

func TestFrontmatterNormalizeReportsAndHeals(t *testing.T) {
fm := Frontmatter{
ID: "dos_x",
Name: "Legacy",
Slug: "legacy",
Status: "active",
Importance: "medium", // removed enum value
Urgency: "", // field absent in older build
}
fixes := fm.Normalize()

if fm.Importance != ImportanceHigh || fm.Urgency != UrgencyHigh {
t.Fatalf("expected coercion toward attention, got importance=%q urgency=%q", fm.Importance, fm.Urgency)
}
if len(fixes) != 2 {
t.Fatalf("expected 2 fixes, got %d: %+v", len(fixes), fixes)
}
byField := map[string]FrontmatterFix{}
for _, f := range fixes {
byField[f.Field] = f
}
if got := byField["importance"]; got.From != "medium" || got.To != "high" {
t.Fatalf("importance fix = %+v, want medium -> high", got)
}
if got := byField["urgency"]; got.From != "" || got.To != "high" {
t.Fatalf("urgency fix = %+v, want '' -> high", got)
}

// Idempotent: a second pass changes nothing.
if fixes2 := fm.Normalize(); len(fixes2) != 0 {
t.Fatalf("expected no fixes on second pass, got %+v", fixes2)
}
}

func TestPriorityScoreMapsLegacyValueTowardAttention(t *testing.T) {
now := time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)
fm := Frontmatter{Importance: "medium", Urgency: UrgencyHigh}
// medium importance must score as high importance (1: Do), not low (3: Delegate).
if got := CalculatePriorityScore(fm, now); got != 1 {
t.Fatalf("legacy medium importance scored %d, want 1 (toward attention)", got)
}
}

func TestSaveHealsLegacyFrontmatterAndWarns(t *testing.T) {
fakeStore := newLocalFakeStore()
svc := NewService(fakeStore, &mockSearcher{}, &mockTokenizer{}, &mockHarnessRegistry{}, &mockClock{now: time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)}, Config{TokenTarget: 100})
ctx := context.Background()

// Seed a dossier carrying a legacy "medium" importance directly in the store,
// simulating a file written by an older build.
fakeStore.dossiers["dos_fake_id"] = &Dossier{
Frontmatter: Frontmatter{
ID: "dos_fake_id",
Name: "Legacy",
Slug: "fake-slug",
Status: StatusActive,
Importance: "medium",
Urgency: UrgencyLow,
},
DistilledState: DistilledState{Body: "# Legacy"},
}
fakeStore.revisions["dos_fake_id"] = "rev_seed"

res, err := svc.Save(ctx, SaveReq{
ID: "dos_fake_id",
FrontmatterUpdates: map[string]any{"next_action": "touch"},
})
if err != nil {
t.Fatalf("save failed: %v", err)
}

// The healed value is persisted...
d, _, err := fakeStore.Read("dos_fake_id")
if err != nil {
t.Fatalf("read failed: %v", err)
}
if d.Frontmatter.Importance != ImportanceHigh {
t.Fatalf("expected importance healed to high, got %q", d.Frontmatter.Importance)
}

// ...and the coercion is surfaced, not silent.
var warned bool
for _, w := range res.Warnings {
if strings.Contains(string(w), "importance") && strings.Contains(string(w), "medium") {
warned = true
}
}
if !warned {
t.Fatalf("expected a warning about the importance coercion, got %v", res.Warnings)
}
}

func TestMigrateHealsStoreWideAndIsIdempotent(t *testing.T) {
fakeStore := newLocalFakeStore()
svc := NewService(fakeStore, &mockSearcher{}, &mockTokenizer{}, &mockHarnessRegistry{}, &mockClock{now: time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)}, Config{TokenTarget: 100})
ctx := context.Background()

// One legacy dossier and one already-canonical dossier.
fakeStore.dossiers["dos_legacy"] = &Dossier{
Frontmatter: Frontmatter{ID: "dos_legacy", Name: "Legacy", Slug: "legacy", Status: StatusActive, Importance: "medium", Urgency: UrgencyLow},
DistilledState: DistilledState{Body: "# Legacy"},
}
fakeStore.revisions["dos_legacy"] = "rev_legacy"
fakeStore.dossiers["dos_ok"] = &Dossier{
Frontmatter: Frontmatter{ID: "dos_ok", Name: "OK", Slug: "ok", Status: StatusActive, Importance: ImportanceLow, Urgency: UrgencyLow},
DistilledState: DistilledState{Body: "# OK"},
}
fakeStore.revisions["dos_ok"] = "rev_ok"

res, err := svc.Migrate(ctx)
if err != nil {
t.Fatalf("migrate failed: %v", err)
}
rep := res.Data.(MigrateReport)
if rep.DossiersScanned != 2 || rep.DossiersHealed != 1 {
t.Fatalf("expected scanned=2 healed=1, got %+v", rep)
}
if d, _, _ := fakeStore.Read("dos_legacy"); d.Frontmatter.Importance != ImportanceHigh {
t.Fatalf("legacy dossier not healed, importance=%q", d.Frontmatter.Importance)
}

// Idempotent: a second sweep heals nothing.
res2, err := svc.Migrate(ctx)
if err != nil {
t.Fatalf("second migrate failed: %v", err)
}
if rep2 := res2.Data.(MigrateReport); rep2.DossiersHealed != 0 {
t.Fatalf("expected no heals on second sweep, got %+v", rep2)
}
}
5 changes: 5 additions & 0 deletions internal/core/priority.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import (
// - 3: Low Importance, High Urgency ("3. Delegate")
// - 4: Low Importance, Low Urgency ("4. Delete")
func CalculatePriorityScore(fm Frontmatter, now time.Time) int {
// fm is passed by value; normalize the copy so legacy/invalid priority
// fields (e.g. a removed "medium") score toward attention rather than
// silently collapsing to low — matching how they will heal on next write.
fm.Normalize()

isHighImportance := fm.Importance == ImportanceHigh
isHighUrgency := fm.Urgency == UrgencyHigh

Expand Down
Loading
Loading