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
+69 -19
View File
@@ -53,7 +53,7 @@ type Input struct {
// Research and Plan are the sealed outputs of earlier phases. The
// implementation phase receives these, never the sessions that wrote them.
Research *workphase.Research
Plan *workphase.Plan
Plan *workphase.PlanDoc
// Evidence is the verified diff and quality-gate result a reviewing
// session works from. Orchestra verifies every field; none of it is the
// implementer's account of what it did.
@@ -156,7 +156,7 @@ var completionPhase = map[domain.WorkPhase]bool{domain.WorkPhaseReview: true}
// and the worker needs to check.
var phaseSealFile = map[domain.WorkPhase]string{
domain.WorkPhaseResearch: "research.json",
domain.WorkPhasePlan: "plan.json",
domain.WorkPhasePlan: "plan.md",
}
// phaseSealSchema is the shape of each sealed artifact, written out for the
@@ -171,12 +171,7 @@ var phaseSealSchema = map[domain.WorkPhase]string{
"dead_ends": [{"tried": "", "why_failed": ""}],
"unknowns": [""]
}`,
domain.WorkPhasePlan: ` {
"changes": [{"target": "", "intent": ""}],
"verification": [""],
"risks": [""],
"human_decisions_needed": [""]
}`,
domain.WorkPhasePlan: planSchema,
}
// askingBrief narrows step 4 per phase. The bar is not the same everywhere: a
@@ -452,18 +447,15 @@ func renderSealed(in Input) string {
}
}
if plan != nil {
// Verbatim, never collapsed. The plan is the execution map an
// implement session works from, and a rotated successor has to receive
// the same one: a phase specification flattened to a bullet line is a
// summary, and nobody can implement a summary. collapse() stays for
// research findings, which really are short claims.
b.WriteString("\n## Accepted plan\n\n")
for _, c := range plan.Changes {
fmt.Fprintf(&b, "- %s: %s\n", collapse(c.Target), collapse(c.Intent))
}
for _, v := range plan.Verification {
fmt.Fprintf(&b, "- verify: %s\n", collapse(v))
}
for _, r := range plan.Risks {
fmt.Fprintf(&b, "- risk: %s\n", collapse(r))
}
for _, d := range plan.DecisionsNeeded {
fmt.Fprintf(&b, "- needs a human decision: %s\n", collapse(d))
b.WriteString(plan.Markdown)
if !strings.HasSuffix(plan.Markdown, "\n") {
b.WriteString("\n")
}
}
return b.String()
@@ -548,3 +540,61 @@ func short(sha string) string {
}
return sha
}
// planSchema is the plan document's required outline, given to the planning
// session verbatim. Run 5 proved the planner follows a stated shape (F38), so
// this block is the delivery mechanism for the structure the seal enforces.
//
// It is markdown rather than JSON because a specification needs paragraphs,
// lists and fenced code, and the old artifact's 500-character single-line rule
// made all three impossible. What a phase needs is not "target and intent"
// but enough for a different session, with none of this one's context, to do
// the work and know when it is done.
const planSchema = "```markdown\n" + `# <what this plan implements>
## Overview
## Current state
## Desired end state
## Non-goals
## Approach
## Phase 1: <name>
### Files
- <path each change touches>
### Changes
<what changes in those files, and why>
### Verification
#### Automated
- run: ["go", "test", "./internal/foo/..."]
#### Manual
- <a step a human performs to confirm the phase>
## Phase 2: <name>
<same four subsections>
## Testing strategy
## Risks and edge cases
## Migration
## References
- research:r1 — <why this phase rests on it>
` + "```" + `
Rules the seal enforces, so a plan that breaks one is refused:
- Every section above is required, spelled exactly.
- Phases are numbered from 1 with no gaps, and each carries Files, Changes and
Verification.
- Every phase declares at least one automated or one manual check. A phase
nobody can verify can never be established as done.
- Each "- run:" line is a JSON array of arguments, not a shell command line.
There is no shell, so a pipe or a redirection would be a literal argument.
The project decides which commands may run; a command outside its policy is
refused when you seal, not later.
- Every "research:<id>" you cite must exist in the accepted research above.
- The whole document is at most 128 KiB. There is no per-line limit: write
paragraphs, code blocks and lists as the content needs.`
+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
`