Seal the plan as a specification instead of four bullet lists

The plan artifact was Changes{Target,Intent} plus three string lists, every
entry capped at 500 single-line characters. That bound makes a specification
impossible: a phase cannot carry a code block, a paragraph of reasoning, or a
verification command with its own argument list. renderSealed then flattened
what little survived through collapse(), so an implement session received a
summary of a summary.

plan.md replaces it. Markdown, 128 KiB, no per-line cap, sealed through the
existing path under the existing PlanRef. The parser enforces the structure the
brief states: required sections, phases numbered from 1 with no gaps, Files,
Changes and Verification per phase, and at least one automated or manual check,
because a phase nobody can verify can never be established as done. Automated
entries are JSON argv arrays, so a pipe is a literal argument rather than an
operator. Headings inside fenced blocks are content, so a plan may show
markdown without parsing its own example.

Citations resolve at seal time against the accepted research, on the
coordinator, which is the only party holding ResearchRef. A plan resting on a
finding nobody recorded fails on the planner while its session is still alive
to be told.

The plan now renders byte for byte into the implement launch, and a rotated
successor receives the same complete document. That is the property the whole
change exists for. collapse() stays for research findings, which really are
short claims.

DecodeStoredPlan reads pre-markdown refs and renders them into the same type,
labelled, so nothing downstream branches on which era a plan came from. A
legacy plan carries no phases, which is honest: the old artifact never named an
executable unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-28 11:36:45 +04:00
parent 822f086451
commit 57c028f94f
22 changed files with 1070 additions and 145 deletions
+160 -2
View File
@@ -337,7 +337,7 @@ func TestPhaseBriefNamesTheRequestFile(t *testing.T) {
func TestPhaseBriefNamesTheArtifactToSeal(t *testing.T) {
for phase, file := range map[domain.WorkPhase]string{
domain.WorkPhaseResearch: "research.json",
domain.WorkPhasePlan: "plan.json",
domain.WorkPhasePlan: "plan.md",
} {
out, err := Build(Input{
Task: domain.Task{ID: "t1", Title: "demo"},
@@ -371,7 +371,18 @@ func TestPhaseSealSchemasDecode(t *testing.T) {
case domain.WorkPhaseResearch:
_, decErr = workphase.DecodeResearch([]byte(schema))
case domain.WorkPhasePlan:
_, decErr = workphase.DecodePlan([]byte(schema))
// The plan brief is a markdown outline with placeholders, so it
// cannot itself be a valid plan. What has to stay true is that
// every section the parser requires is named in the brief: F38
// was a planner guessing a shape nobody had described.
for _, required := range []string{"## Overview", "## Current state", "## Desired end state",
"## Non-goals", "## Approach", "## Phase 1:", "### Files", "### Changes",
"### Verification", "#### Automated", "#### Manual", "## Testing strategy",
"## Risks and edge cases", "## Migration", "## References", "- run:"} {
if !strings.Contains(schema, required) {
t.Fatalf("plan brief never states %q, which the seal requires", required)
}
}
default:
t.Fatalf("%s has a documented shape with nothing to decode it", phase)
}
@@ -408,3 +419,150 @@ func TestTerminalPhaseNamesTheCompletionSignal(t *testing.T) {
}
}
}
// The plan reaches the implementer whole, or the plan machinery is decoration.
// Everything else in this file guards a rule; this guards the one property a
// smaller local model depends on: the specification for phase three is in the
// launch text, not a bullet-line summary of it.
func TestAcceptedPlanRendersVerbatim(t *testing.T) {
doc, err := workphase.ParsePlan([]byte(planFixture))
if err != nil {
t.Fatalf("fixture: %v", err)
}
out, err := Build(Input{
Task: domain.Task{ID: "t1", Title: "demo"},
Phase: domain.WorkPhaseImplement,
Git: GitState{Worktree: "/w", Branch: "orchestra/t1"},
Plan: &doc,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.Task, planFixture) {
t.Fatalf("the sealed plan was not rendered byte for byte:\n%s", out.Task)
}
// The specifics a summary would have destroyed.
for _, want := range []string{
"## Phase 3: Wire the reducer",
`- run: ["go", "test", "./internal/store/..."]`,
"```go",
"func reduce(",
} {
if !strings.Contains(out.Task, want) {
t.Fatalf("rendered plan lost %q", want)
}
}
}
// A rotated successor is a different session with none of the predecessor's
// context. It receives the same complete plan, whatever the handoff says.
func TestRotatedSuccessorReceivesTheWholePlan(t *testing.T) {
doc, err := workphase.ParsePlan([]byte(planFixture))
if err != nil {
t.Fatal(err)
}
base := Input{
Task: domain.Task{ID: "t1", Title: "demo"},
Phase: domain.WorkPhaseImplement,
Git: GitState{Worktree: "/w", Branch: "orchestra/t1"},
Plan: &doc,
}
first, err := Build(base)
if err != nil {
t.Fatal(err)
}
resumed := base
resumed.Handoff = &continuity.Handoff{
Meta: continuity.Meta{ID: "h1", Reason: "threshold"},
Anchor: continuity.Anchor{GitSHA: "18ccaf", Branch: "orchestra/t1"},
Action: "continue phase 2",
Remaining: []string{"phase 3"},
}
second, err := Build(resumed)
if err != nil {
t.Fatal(err)
}
for name, out := range map[string]string{"launch": first.Task, "resumed": second.Task} {
if !strings.Contains(out, planFixture) {
t.Fatalf("%s context does not carry the complete plan", name)
}
}
}
const planFixture = "# Reducer implementation plan\n" + `
## Overview
Wire the reducer.
## Current state
Nothing reduces the event, per research:r1.
## Desired end state
The event reduces into a task field.
## Non-goals
No new event type.
## Approach
Extend the existing switch.
## Phase 1: Define the field
### Files
- internal/domain/domain.go
### Changes
Add the field.
### Verification
#### Automated
- run: ["go", "build", "./..."]
## Phase 2: Emit the event
### Files
- internal/operations/plan.go
### Changes
Append the event.
### Verification
#### Automated
- run: ["go", "test", "./internal/operations/..."]
## Phase 3: Wire the reducer
### Files
- internal/store/store.go
### Changes
Add the case to the reducer switch:
` + "```go" + `
func reduce(t domain.Task, e domain.Event) domain.Task {
// one arm per event type
return t
}
` + "```" + `
### Verification
#### Automated
- run: ["go", "test", "./internal/store/..."]
#### Manual
- Replay the log and confirm the field is populated.
## Testing strategy
Package tests per phase.
## Risks and edge cases
A replay of an old log must not panic.
## Migration
None.
## References
- research:r1
`