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:
@@ -400,7 +400,7 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
if artErr != nil {
|
||||
return fmt.Errorf("research artifact: %w", artErr)
|
||||
}
|
||||
r, decErr := workphase.DecodeResearch(b)
|
||||
r, decErr := workphase.DecodeStoredResearch(b)
|
||||
if decErr != nil {
|
||||
return fmt.Errorf("research artifact: %w", decErr)
|
||||
}
|
||||
@@ -411,7 +411,7 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
if artErr != nil {
|
||||
return fmt.Errorf("plan artifact: %w", artErr)
|
||||
}
|
||||
pl, decErr := workphase.DecodePlan(b)
|
||||
pl, decErr := workphase.DecodeStoredPlan(b)
|
||||
if decErr != nil {
|
||||
return fmt.Errorf("plan artifact: %w", decErr)
|
||||
}
|
||||
@@ -1839,7 +1839,7 @@ type phaseRequest struct {
|
||||
// be left. Phases absent from this table seal nothing.
|
||||
var phaseArtifact = map[domain.WorkPhase]string{
|
||||
domain.WorkPhaseResearch: "research.json",
|
||||
domain.WorkPhasePlan: "plan.json",
|
||||
domain.WorkPhasePlan: "plan.md",
|
||||
}
|
||||
|
||||
func currentPhase(t domain.Task) domain.WorkPhase {
|
||||
@@ -1946,7 +1946,10 @@ func (w *worker) requestPhase(ctx context.Context, id string, s herdr.Session) b
|
||||
case domain.WorkPhaseResearch:
|
||||
_, decErr = workphase.DecodeResearch(artifact)
|
||||
case domain.WorkPhasePlan:
|
||||
_, decErr = workphase.DecodePlan(artifact)
|
||||
// A new seal is markdown. The coordinator re-parses and also
|
||||
// resolves research citations, which the worker cannot check
|
||||
// because it does not hold the accepted research.
|
||||
_, decErr = workphase.ParsePlan(artifact)
|
||||
}
|
||||
if decErr != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: %s artifact: %w", id, req.From, decErr))
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
@@ -145,7 +145,7 @@ func (h HTTP) Account(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials):
|
||||
http.Error(w, "current password is incorrect", http.StatusUnauthorized)
|
||||
http.Error(w, "current password is incorrect", http.StatusForbidden)
|
||||
case errors.Is(err, ErrUsernameExists):
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
default:
|
||||
|
||||
@@ -92,3 +92,60 @@ func TestHTTPAccountUpdateRevokesExistingSessions(t *testing.T) {
|
||||
t.Fatalf("updated login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAccountRejectsWrongCurrentPasswordWithoutEndingSession(t *testing.T) {
|
||||
users, _ := openTestStore(t)
|
||||
if _, _, err := users.SetPassword("operator", "original password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions := &authz.Sessions{}
|
||||
h := HTTP{Users: users, Sessions: sessions}
|
||||
value, err := sessions.IssueFor("operator")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPut, "/v1/ui/account", bytes.NewBufferString(`{"current_password":"incorrect password","username":"operator","new_password":"replacement password"}`))
|
||||
req.AddCookie(&http.Cookie{Name: authz.SessionCookie, Value: value})
|
||||
w := httptest.NewRecorder()
|
||||
h.Account(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
if !sessions.Valid(value) {
|
||||
t.Fatal("a rejected credential update ended the valid browser session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserAuthHandlersComposeWithSessionMiddleware(t *testing.T) {
|
||||
users, _ := openTestStore(t)
|
||||
if _, _, err := users.SetPassword("operator", "correct horse battery"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessions := &authz.Sessions{}
|
||||
h := HTTP{Users: users, Sessions: sessions}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(authz.SessionPath, h.Session)
|
||||
mux.HandleFunc("/v1/ui/account", h.Account)
|
||||
server := authz.HTTPWithSessions(nil, sessions, mux)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
server.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/ui/account", nil))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("uncredentialed account status=%d", w.Code)
|
||||
}
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
server.ServeHTTP(w, httptest.NewRequest(http.MethodPost, authz.SessionPath, bytes.NewBufferString(`{"username":"operator","password":"correct horse battery"}`)))
|
||||
if w.Code != http.StatusOK || len(w.Result().Cookies()) != 1 {
|
||||
t.Fatalf("login status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/ui/account", nil)
|
||||
request.AddCookie(cookie)
|
||||
w = httptest.NewRecorder()
|
||||
server.ServeHTTP(w, request)
|
||||
if w.Code != http.StatusOK || !bytes.Contains(w.Body.Bytes(), []byte(`"username":"operator"`)) {
|
||||
t.Fatalf("authenticated account status=%d body=%s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func ValidateUsername(username string) error {
|
||||
}
|
||||
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < MinimumPassword {
|
||||
if utf8.RuneCountInString(password) < MinimumPassword {
|
||||
return fmt.Errorf("password must be at least %d characters", MinimumPassword)
|
||||
}
|
||||
if len([]byte(password)) > maximumPassword {
|
||||
|
||||
@@ -2,6 +2,7 @@ package authn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -42,6 +43,13 @@ func TestPasswordRecordPersistsAndAuthenticates(t *testing.T) {
|
||||
if _, err := reopened.Authenticate("kami", "correct horse battery"); err != nil {
|
||||
t.Fatalf("persisted authentication: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != databaseFileMode {
|
||||
t.Fatalf("auth database permissions = %o, want %o", got, databaseFileMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRequiresCurrentPasswordAndMovesUsername(t *testing.T) {
|
||||
|
||||
@@ -176,7 +176,7 @@ func buildFor(t *testing.T, s *store.Store, task domain.Task) string {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := workphase.DecodePlan(b)
|
||||
p, err := workphase.DecodeStoredPlan(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -241,16 +241,58 @@ func sealedResearch(t *testing.T) []byte {
|
||||
|
||||
func sealedPlan(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b, err := workphase.Encode(workphase.Plan{
|
||||
Changes: []workphase.Change{{Target: "internal/attr/attr.go", Intent: "add the cache"}},
|
||||
Verification: []string{"go test ./internal/attr/"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
b := []byte(planMarkdown)
|
||||
if _, err := workphase.ParsePlan(b); err != nil {
|
||||
t.Fatalf("the fixture plan does not satisfy the seal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// planMarkdown is a real sealed specification, not a fixture shaped to pass.
|
||||
// The assertions below read its content back out of the rendered context,
|
||||
// which is what proves the plan survives a phase boundary intact.
|
||||
const planMarkdown = "# Attribution cache implementation plan\n" + `
|
||||
## Overview
|
||||
Add the cache so attribution stops recomputing per figure.
|
||||
|
||||
## Current state
|
||||
Attribution runs per figure, per research:r1.
|
||||
|
||||
## Desired end state
|
||||
internal/attr/attr.go: add the cache, keyed per person.
|
||||
|
||||
## Non-goals
|
||||
No change to identity semantics.
|
||||
|
||||
## Approach
|
||||
Memoise inside the existing aggregation loop.
|
||||
|
||||
## Phase 1: Add the cache
|
||||
|
||||
### Files
|
||||
- internal/attr/attr.go
|
||||
|
||||
### Changes
|
||||
internal/attr/attr.go: add the cache, keyed per person.
|
||||
|
||||
### Verification
|
||||
|
||||
#### Automated
|
||||
- run: ["go", "test", "./internal/attr/"]
|
||||
|
||||
## Testing strategy
|
||||
The package test covers aggregation.
|
||||
|
||||
## Risks and edge cases
|
||||
A stale entry would misattribute.
|
||||
|
||||
## Migration
|
||||
None.
|
||||
|
||||
## References
|
||||
- research:r1
|
||||
`
|
||||
|
||||
func assertContains(t *testing.T, phase, ctx string, want ...string) {
|
||||
t.Helper()
|
||||
for _, w := range want {
|
||||
@@ -334,7 +376,7 @@ func TestTrajectoryGateCorrectionOutranksTheSealedPlan(t *testing.T) {
|
||||
"## Current human decisions",
|
||||
"add the index instead",
|
||||
"## Accepted plan",
|
||||
"internal/attr/attr.go: add the cache",
|
||||
"internal/attr/attr.go: add the cache, keyed per person.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -405,7 +447,7 @@ func TestBlockingQuestionResumesWithTheAnswerOnTop(t *testing.T) {
|
||||
// The resumed context: the answer above the sealed plan, and the question
|
||||
// gone because it is answered.
|
||||
ctx := buildFor(t, s, resumed)
|
||||
assertContains(t, "resumed", ctx, "break compatibility", "## Accepted plan", "internal/attr/attr.go: add the cache")
|
||||
assertContains(t, "resumed", ctx, "break compatibility", "## Accepted plan", "internal/attr/attr.go: add the cache, keyed per person.")
|
||||
if strings.Contains(ctx, "## Human decision required") {
|
||||
t.Fatalf("an answered question is still being asked:\n%s", ctx)
|
||||
}
|
||||
@@ -414,7 +456,7 @@ func TestBlockingQuestionResumesWithTheAnswerOnTop(t *testing.T) {
|
||||
"break compatibility",
|
||||
"## Accepted research",
|
||||
"## Accepted plan",
|
||||
"internal/attr/attr.go: add the cache",
|
||||
"internal/attr/attr.go: add the cache, keyed per person.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -488,7 +530,7 @@ func TestReviewSessionIsIndependent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealedPlanValue, err := workphase.DecodePlan(planArtifact)
|
||||
sealedPlanValue, err := workphase.DecodeStoredPlan(planArtifact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestRequestWorkPhaseAdvancesAndSealsEachArtifact(t *testing.T) {
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-4", domain.WorkPhasePlan, domain.WorkPhaseImplement, nil); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("leaving plan unsealed must fail, got %v", err)
|
||||
}
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-5", domain.WorkPhasePlan, domain.WorkPhaseImplement, sealed(t, plan)); err != nil {
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-5", domain.WorkPhasePlan, domain.WorkPhaseImplement, planDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = s.Task(id)
|
||||
|
||||
@@ -34,7 +34,7 @@ func atImplement(t *testing.T, project registry.Project) (*store.Store, string)
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, planDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, id
|
||||
|
||||
@@ -122,30 +122,15 @@ func TrajectoryGatePacket(s *store.Store, t domain.Task, from, to domain.WorkPha
|
||||
}
|
||||
}
|
||||
if len(planned) > 0 {
|
||||
{
|
||||
if p, err := workphase.DecodePlan(planned); err == nil {
|
||||
b.WriteString("\nProposed changes:\n")
|
||||
for _, c := range p.Changes {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", oneLine(c.Target), oneLine(c.Intent))
|
||||
}
|
||||
if len(p.Verification) > 0 {
|
||||
b.WriteString("\nVerification:\n")
|
||||
for _, v := range p.Verification {
|
||||
fmt.Fprintf(&b, "- %s\n", oneLine(v))
|
||||
}
|
||||
}
|
||||
if len(p.Risks) > 0 {
|
||||
b.WriteString("\nRisks:\n")
|
||||
for _, r := range p.Risks {
|
||||
fmt.Fprintf(&b, "- %s\n", oneLine(r))
|
||||
}
|
||||
}
|
||||
if len(p.DecisionsNeeded) > 0 {
|
||||
b.WriteString("\nOpen decisions for you:\n")
|
||||
for _, d := range p.DecisionsNeeded {
|
||||
fmt.Fprintf(&b, "- %s\n", oneLine(d))
|
||||
}
|
||||
}
|
||||
// The plan reaches the human as the document that was sealed. A
|
||||
// trajectory gate asks whether this direction is right, and a
|
||||
// flattened summary is not the thing being approved. maxPacketBytes
|
||||
// below bounds what a notification surface actually carries.
|
||||
if p, err := workphase.DecodeStoredPlan(planned); err == nil {
|
||||
b.WriteString("\nProposed plan:\n\n")
|
||||
b.WriteString(p.Markdown)
|
||||
if !strings.HasSuffix(p.Markdown, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
"orchestra/internal/workphase"
|
||||
)
|
||||
|
||||
func gatedProject() registry.Project {
|
||||
@@ -55,11 +54,47 @@ func TestTrajectoryGateBlocksThenClears(t *testing.T) {
|
||||
}
|
||||
|
||||
// plan to implement is gated.
|
||||
proposal := sealed(t, workphase.Plan{
|
||||
Changes: []workphase.Change{{Target: "internal/attr/attr.go", Intent: "add the cache"}},
|
||||
Verification: []string{"go test ./internal/attr/"},
|
||||
Risks: []string{"cache invalidation on rename"},
|
||||
})
|
||||
proposal := []byte("# Attribution cache plan\n" + `
|
||||
## Overview
|
||||
add the cache
|
||||
|
||||
## Current state
|
||||
runs per figure, per research:r1.
|
||||
|
||||
## Desired end state
|
||||
Aggregation is cached per person.
|
||||
|
||||
## Non-goals
|
||||
No identity change.
|
||||
|
||||
## Approach
|
||||
Memoise in the aggregation loop.
|
||||
|
||||
## Phase 1: Add the cache
|
||||
|
||||
### Files
|
||||
- internal/attr/attr.go
|
||||
|
||||
### Changes
|
||||
add the cache to internal/attr/attr.go
|
||||
|
||||
### Verification
|
||||
|
||||
#### Automated
|
||||
- run: ["go", "test", "./internal/attr/"]
|
||||
|
||||
## Testing strategy
|
||||
go test ./internal/attr/
|
||||
|
||||
## Risks and edge cases
|
||||
cache invalidation on rename
|
||||
|
||||
## Migration
|
||||
None.
|
||||
|
||||
## References
|
||||
- research:r1
|
||||
`)
|
||||
_, err := AdvanceWorkPhase(s, project, id, proposal)
|
||||
if !errors.Is(err, ErrTrajectoryGate) {
|
||||
t.Fatalf("want ErrTrajectoryGate, got %v", err)
|
||||
@@ -122,7 +157,7 @@ func TestUngatedProjectAdvances(t *testing.T) {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, planDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement {
|
||||
@@ -141,7 +176,7 @@ func TestOlderDecisionDoesNotOpenTheGate(t *testing.T) {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); !errors.Is(err, ErrTrajectoryGate) {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, planDoc); !errors.Is(err, ErrTrajectoryGate) {
|
||||
t.Fatalf("want ErrTrajectoryGate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
@@ -71,7 +72,16 @@ func advanceWorkPhase(s *store.Store, project registry.Project, taskID string, a
|
||||
return domain.Event{}, err
|
||||
}
|
||||
case domain.WorkPhasePlan:
|
||||
if _, err := workphase.DecodePlan(artifact); err != nil {
|
||||
doc, err := workphase.ParsePlan(artifact)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
// Citations resolve here and nowhere else: the coordinator holds
|
||||
// ResearchRef, so this is the only party that can tell whether a
|
||||
// cited finding exists. A plan resting on a finding nobody
|
||||
// recorded fails on the planner, while its session is still alive
|
||||
// to be told, rather than on the implementer later.
|
||||
if err := resolvePlanReferences(s, t, doc); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
}
|
||||
@@ -169,3 +179,27 @@ func phaseOperation(s *store.Store, taskID, operationID string) (domain.Event, b
|
||||
}
|
||||
return domain.Event{}, false
|
||||
}
|
||||
|
||||
// resolvePlanReferences refuses a plan that cites research the task never
|
||||
// sealed. Its cost is one CAS read against a ref the coordinator already
|
||||
// holds.
|
||||
func resolvePlanReferences(s *store.Store, t domain.Task, doc workphase.PlanDoc) error {
|
||||
if len(doc.References) == 0 {
|
||||
return nil
|
||||
}
|
||||
if t.ResearchRef == "" {
|
||||
return fmt.Errorf("%w: the plan cites %s but this task sealed no research", domain.ErrInvalid, strings.Join(doc.References, ", "))
|
||||
}
|
||||
raw, err := s.Artifact(t.ResearchRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve plan references: %w", err)
|
||||
}
|
||||
r, err := workphase.DecodeStoredResearch(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve plan references: %w", err)
|
||||
}
|
||||
if missing := doc.ResolveReferences(r); len(missing) > 0 {
|
||||
return fmt.Errorf("%w: the plan cites research:%s, which the accepted research does not contain", domain.ErrInvalid, strings.Join(missing, ", research:"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -47,7 +47,50 @@ func sealed(t *testing.T, v interface{ Validate() error }) []byte {
|
||||
}
|
||||
|
||||
var research = workphase.Research{Findings: []workphase.Finding{{ID: "r1", Confidence: workphase.Fact, Claim: "runs per figure", Evidence: "attr.go:88"}}}
|
||||
var plan = workphase.Plan{Changes: []workphase.Change{{Target: "attr.go", Intent: "aggregate per person"}}}
|
||||
// planDoc is a real sealed specification. It cites research:r1, which the
|
||||
// research fixture above contains, so the reference check has something to
|
||||
// resolve.
|
||||
var planDoc = []byte("# Attribution plan\n" + `
|
||||
## Overview
|
||||
Aggregate per person.
|
||||
|
||||
## Current state
|
||||
attr.go aggregates per figure, per research:r1.
|
||||
|
||||
## Desired end state
|
||||
attr.go aggregates per person.
|
||||
|
||||
## Non-goals
|
||||
No identity change.
|
||||
|
||||
## Approach
|
||||
Change the aggregation key.
|
||||
|
||||
## Phase 1: Aggregate per person
|
||||
|
||||
### Files
|
||||
- attr.go
|
||||
|
||||
### Changes
|
||||
Change the aggregation key to the person.
|
||||
|
||||
### Verification
|
||||
|
||||
#### Automated
|
||||
- run: ["go", "test", "./internal/attr/"]
|
||||
|
||||
## Testing strategy
|
||||
Package test.
|
||||
|
||||
## Risks and edge cases
|
||||
None known.
|
||||
|
||||
## Migration
|
||||
None.
|
||||
|
||||
## References
|
||||
- research:r1
|
||||
`)
|
||||
|
||||
func TestFullPhasePathSealsEachArtifact(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
@@ -79,7 +122,7 @@ func TestFullPhasePathSealsEachArtifact(t *testing.T) {
|
||||
// plan -> implement must seal the plan, and must not overwrite the
|
||||
// research ref.
|
||||
researchRef := got.ResearchRef
|
||||
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
|
||||
if _, err := AdvanceWorkPhase(s, project, id, planDoc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = s.Task(id)
|
||||
|
||||
@@ -1303,12 +1303,12 @@ func (c *Coordinator) research(ref string) (*workphase.Research, error) {
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (c *Coordinator) plan(ref string) (*workphase.Plan, error) {
|
||||
func (c *Coordinator) plan(ref string) (*workphase.PlanDoc, error) {
|
||||
b, err := c.Store.Artifact(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := workphase.DecodePlan(b)
|
||||
p, err := workphase.DecodeStoredPlan(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
<script type="module" crossorigin src="/assets/index-8NTNRyfU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DpzNF360.css">
|
||||
<script type="module" crossorigin src="/assets/index-C0H-zYgt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B0BkAbwp.css">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package workphase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlanDoc is a sealed markdown specification. The document is retained
|
||||
// verbatim, because that is what an implement session receives: a plan
|
||||
// flattened to bullet lines is a summary, and a summary cannot be executed.
|
||||
//
|
||||
// The parsed fields exist so Orchestra can act on the plan without rereading
|
||||
// prose. Nothing outside this file interprets the markdown.
|
||||
type PlanDoc struct {
|
||||
// Markdown is the exact bytes that were sealed.
|
||||
Markdown string `json:"markdown"`
|
||||
// Title is the level-one heading.
|
||||
Title string `json:"title"`
|
||||
// Phases are ordered and contiguous from 1.
|
||||
Phases []PlanPhase `json:"phases"`
|
||||
// References are the research ids the plan cites, deduplicated.
|
||||
References []string `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
// PlanPhase is one executable unit of the plan. Files and Changes are prose
|
||||
// for the implementer; the verification entries are what Orchestra runs and
|
||||
// what the human signs off.
|
||||
type PlanPhase struct {
|
||||
// ID is the stable name used in a progress request, "phase-1".
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
// Body is the phase's markdown, retained so a mismatch report can quote it.
|
||||
Body string `json:"body"`
|
||||
// Automated are argv arrays. Not shell strings: they execute through
|
||||
// exec.Command with no shell, so a pipe or a redirection is a literal
|
||||
// argument rather than an operator.
|
||||
Automated [][]string `json:"automated,omitempty"`
|
||||
// Manual are human-testable steps. A phase whose automated checks all pass
|
||||
// while manual steps remain is awaiting a human, not verified.
|
||||
Manual []string `json:"manual,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// MaxPlanBytes bounds the whole document. A specification needs room that
|
||||
// the 500-character single-line rule never allowed, but an unbounded
|
||||
// artifact is a transcript that every later session pays to read.
|
||||
MaxPlanBytes = 128 << 10
|
||||
maxPhases = 32
|
||||
maxCommands = 16
|
||||
maxArgv = 32
|
||||
)
|
||||
|
||||
// requiredSections are the level-two headings every plan carries. They are the
|
||||
// questions a plan has to answer before anyone implements against it: what is
|
||||
// true now, what should be true after, and what is deliberately not being done.
|
||||
var requiredSections = []string{
|
||||
"Overview", "Current state", "Desired end state", "Non-goals", "Approach",
|
||||
"Testing strategy", "Risks and edge cases", "Migration", "References",
|
||||
}
|
||||
|
||||
var (
|
||||
phaseHeading = regexp.MustCompile(`^##\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$`)
|
||||
runLine = regexp.MustCompile(`^-\s+run:\s*(.+?)\s*$`)
|
||||
bulletLine = regexp.MustCompile(`^[-*]\s+(.+?)\s*$`)
|
||||
researchCite = regexp.MustCompile(`research:([a-z0-9][a-z0-9_-]{0,63})`)
|
||||
)
|
||||
|
||||
// ParsePlan reads a markdown plan and reports the first structural problem it
|
||||
// finds. Errors name the section or phase, because "invalid plan" sends a
|
||||
// planner rereading its own document with nothing to go on.
|
||||
func ParsePlan(b []byte) (PlanDoc, error) {
|
||||
if len(b) == 0 {
|
||||
return PlanDoc{}, fmt.Errorf("plan: the document is empty")
|
||||
}
|
||||
if len(b) > MaxPlanBytes {
|
||||
return PlanDoc{}, fmt.Errorf("plan: %d bytes exceeds the %d byte bound", len(b), MaxPlanBytes)
|
||||
}
|
||||
doc := PlanDoc{Markdown: string(b)}
|
||||
lines := strings.Split(strings.ReplaceAll(doc.Markdown, "\r\n", "\n"), "\n")
|
||||
|
||||
sections := map[string]bool{}
|
||||
// current tracks which phase's body the parser is inside, and which
|
||||
// verification subsection, so a "- run:" line is attributed to the phase
|
||||
// that actually contains it.
|
||||
var current *PlanPhase
|
||||
subsection := ""
|
||||
inFence := false
|
||||
|
||||
flush := func() {
|
||||
if current != nil {
|
||||
current.Body = strings.TrimSpace(current.Body)
|
||||
doc.Phases = append(doc.Phases, *current)
|
||||
current = nil
|
||||
}
|
||||
}
|
||||
|
||||
for n, raw := range lines {
|
||||
line := strings.TrimRight(raw, " \t")
|
||||
// A heading inside a fenced block is content, not structure. Without
|
||||
// this a plan that shows example markdown parses its own example.
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "```") {
|
||||
inFence = !inFence
|
||||
}
|
||||
if !inFence {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "# "):
|
||||
if doc.Title == "" {
|
||||
doc.Title = strings.TrimSpace(strings.TrimPrefix(line, "# "))
|
||||
}
|
||||
continue
|
||||
case phaseHeading.MatchString(line):
|
||||
flush()
|
||||
m := phaseHeading.FindStringSubmatch(line)
|
||||
number, _ := strconv.Atoi(m[1])
|
||||
current = &PlanPhase{ID: "phase-" + m[1], Number: number, Name: m[2]}
|
||||
subsection = ""
|
||||
continue
|
||||
case strings.HasPrefix(line, "## "):
|
||||
flush()
|
||||
sections[strings.TrimSpace(strings.TrimPrefix(line, "## "))] = true
|
||||
subsection = strings.TrimSpace(strings.TrimPrefix(line, "## "))
|
||||
continue
|
||||
case strings.HasPrefix(line, "#### "):
|
||||
subsection = strings.TrimSpace(strings.TrimPrefix(line, "#### "))
|
||||
case strings.HasPrefix(line, "### "):
|
||||
subsection = strings.TrimSpace(strings.TrimPrefix(line, "### "))
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
current.Body += line + "\n"
|
||||
if inFence {
|
||||
continue
|
||||
}
|
||||
switch subsection {
|
||||
case "Automated":
|
||||
m := runLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
var argv []string
|
||||
if err := json.Unmarshal([]byte(m[1]), &argv); err != nil {
|
||||
return PlanDoc{}, fmt.Errorf("plan: %s line %d: run: must be a JSON argv array like [\"go\", \"test\", \"./...\"], got %s", current.ID, n+1, m[1])
|
||||
}
|
||||
if err := validArgv(current.ID, argv); err != nil {
|
||||
return PlanDoc{}, err
|
||||
}
|
||||
current.Automated = append(current.Automated, argv)
|
||||
case "Manual":
|
||||
if m := bulletLine.FindStringSubmatch(line); m != nil {
|
||||
current.Manual = append(current.Manual, m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
if doc.Title == "" {
|
||||
return PlanDoc{}, fmt.Errorf("plan: a level-one heading naming the plan is required")
|
||||
}
|
||||
for _, name := range requiredSections {
|
||||
if !sections[name] {
|
||||
return PlanDoc{}, fmt.Errorf("plan: the %q section is required", name)
|
||||
}
|
||||
}
|
||||
if err := doc.validatePhases(); err != nil {
|
||||
return PlanDoc{}, err
|
||||
}
|
||||
doc.References = citations(doc.Markdown)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (d PlanDoc) validatePhases() error {
|
||||
if len(d.Phases) == 0 {
|
||||
return fmt.Errorf("plan: at least one \"## Phase 1: <name>\" section is required")
|
||||
}
|
||||
if len(d.Phases) > maxPhases {
|
||||
return fmt.Errorf("plan: %d phases exceeds the %d phase bound", len(d.Phases), maxPhases)
|
||||
}
|
||||
for i, p := range d.Phases {
|
||||
// Contiguous numbering from 1. A plan that jumps from phase 2 to
|
||||
// phase 4 has either lost a phase or renumbered one that progress
|
||||
// already refers to, and both are worse discovered later.
|
||||
if p.Number != i+1 {
|
||||
return fmt.Errorf("plan: phases must be numbered from 1 with no gaps; found Phase %d where Phase %d was expected", p.Number, i+1)
|
||||
}
|
||||
if strings.TrimSpace(p.Name) == "" {
|
||||
return fmt.Errorf("plan: %s has no name", p.ID)
|
||||
}
|
||||
for _, required := range []string{"### Files", "### Changes", "### Verification"} {
|
||||
if !strings.Contains(p.Body, required) {
|
||||
return fmt.Errorf("plan: %s is missing its %q subsection", p.ID, strings.TrimPrefix(required, "### "))
|
||||
}
|
||||
}
|
||||
// A phase nobody can check is a phase that can never leave
|
||||
// in_progress, so it is refused at seal time rather than becoming a
|
||||
// stuck task later.
|
||||
if len(p.Automated) == 0 && len(p.Manual) == 0 {
|
||||
return fmt.Errorf("plan: %s has no automated and no manual verification, so nothing could ever establish it is done", p.ID)
|
||||
}
|
||||
if len(p.Automated) > maxCommands {
|
||||
return fmt.Errorf("plan: %s declares %d automated commands, at most %d", p.ID, len(p.Automated), maxCommands)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validArgv(phase string, argv []string) error {
|
||||
if len(argv) == 0 {
|
||||
return fmt.Errorf("plan: %s declares an empty run: command", phase)
|
||||
}
|
||||
if len(argv) > maxArgv {
|
||||
return fmt.Errorf("plan: %s declares a run: command of %d arguments, at most %d", phase, len(argv), maxArgv)
|
||||
}
|
||||
for i, a := range argv {
|
||||
if strings.TrimSpace(a) == "" {
|
||||
return fmt.Errorf("plan: %s run: argument %d is empty", phase, i)
|
||||
}
|
||||
if strings.ContainsAny(a, "\n\r\x00") {
|
||||
return fmt.Errorf("plan: %s run: argument %d contains a newline or a null byte", phase, i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Phase finds a phase by its id.
|
||||
func (d PlanDoc) Phase(id string) (PlanPhase, bool) {
|
||||
for _, p := range d.Phases {
|
||||
if p.ID == id {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return PlanPhase{}, false
|
||||
}
|
||||
|
||||
// citations returns every research id the document cites, deduplicated, in
|
||||
// order of first appearance.
|
||||
func citations(markdown string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range researchCite.FindAllStringSubmatch(markdown, -1) {
|
||||
if !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveReferences reports the cited research ids that the accepted research
|
||||
// does not contain. A plan resting on a finding nobody recorded is the failure
|
||||
// this prevents, and it lands on the planner rather than the implementer.
|
||||
func (d PlanDoc) ResolveReferences(r Research) []string {
|
||||
known := map[string]bool{}
|
||||
for _, f := range r.Findings {
|
||||
known[f.ID] = true
|
||||
}
|
||||
var missing []string
|
||||
for _, id := range d.References {
|
||||
if !known[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// DecodeStoredPlan reads a plan already in the CAS. New seals are markdown;
|
||||
// refs sealed before plan.md existed are JSON, and refusing them would block
|
||||
// every task whose plan predates this change, at rotation most of all.
|
||||
//
|
||||
// A legacy plan is rendered into the same type so nothing downstream branches
|
||||
// on which era a plan came from. It carries no phases, which is honest: the
|
||||
// old artifact never named an executable unit, so plan progress genuinely
|
||||
// cannot apply to it.
|
||||
func DecodeStoredPlan(b []byte) (PlanDoc, error) {
|
||||
if doc, err := ParsePlan(b); err == nil {
|
||||
return doc, nil
|
||||
}
|
||||
p, err := DecodePlan(b)
|
||||
if err != nil {
|
||||
// Report the markdown failure, not the JSON one: every new seal is
|
||||
// markdown, so that is the error a reader needs.
|
||||
_, mdErr := ParsePlan(b)
|
||||
return PlanDoc{}, mdErr
|
||||
}
|
||||
return p.legacyDoc(), nil
|
||||
}
|
||||
|
||||
// legacyDoc renders a pre-markdown plan as the document type. The text is
|
||||
// labelled so nobody mistakes a flattened plan for a specification.
|
||||
func (p Plan) legacyDoc() PlanDoc {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Accepted plan\n\nSealed before plan.md existed, so it names intent rather than phases.\n\n## Changes\n\n")
|
||||
for _, c := range p.Changes {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", c.Target, c.Intent)
|
||||
}
|
||||
for _, section := range []struct {
|
||||
title string
|
||||
items []string
|
||||
}{
|
||||
{"Verification", p.Verification},
|
||||
{"Risks", p.Risks},
|
||||
{"Human decisions needed", p.DecisionsNeeded},
|
||||
} {
|
||||
if len(section.items) == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n## %s\n\n", section.title)
|
||||
for _, v := range section.items {
|
||||
fmt.Fprintf(&b, "- %s\n", v)
|
||||
}
|
||||
}
|
||||
return PlanDoc{Markdown: b.String(), Title: "Accepted plan"}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package workphase
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const goodPlan = "# Quiet flag implementation plan\n" + `
|
||||
## Overview
|
||||
Add a --quiet flag.
|
||||
|
||||
## Current state
|
||||
The script prints unconditionally. See research:r1.
|
||||
|
||||
## Desired end state
|
||||
--quiet suppresses the success line.
|
||||
|
||||
## Non-goals
|
||||
No change to exit codes.
|
||||
|
||||
## Approach
|
||||
Parse the flag in the existing loop.
|
||||
|
||||
## Phase 1: Parse the flag
|
||||
|
||||
### Files
|
||||
- scripts/orchestra_e2e_healthcheck.sh
|
||||
|
||||
### Changes
|
||||
Add a quiet local and set it in the argument loop.
|
||||
|
||||
### Verification
|
||||
|
||||
#### Automated
|
||||
- run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
|
||||
|
||||
#### Manual
|
||||
- Run the script with --quiet and confirm it prints nothing.
|
||||
|
||||
## Phase 2: Document the flag
|
||||
|
||||
### Files
|
||||
- scripts/orchestra_e2e_healthcheck.sh
|
||||
|
||||
### Changes
|
||||
State --quiet in the --help output, per research:r2.
|
||||
|
||||
### Verification
|
||||
|
||||
#### Automated
|
||||
- run: ["bash", "scripts/orchestra_e2e_healthcheck.sh", "--help"]
|
||||
|
||||
## Testing strategy
|
||||
Shell syntax check plus a manual run.
|
||||
|
||||
## Risks and edge cases
|
||||
An unknown flag is still ignored.
|
||||
|
||||
## Migration
|
||||
None.
|
||||
|
||||
## References
|
||||
- research:r1
|
||||
- research:r2
|
||||
`
|
||||
|
||||
func TestParsePlanAcceptsACompleteSpecification(t *testing.T) {
|
||||
doc, err := ParsePlan([]byte(goodPlan))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if doc.Title != "Quiet flag implementation plan" {
|
||||
t.Fatalf("title %q", doc.Title)
|
||||
}
|
||||
if len(doc.Phases) != 2 {
|
||||
t.Fatalf("got %d phases, want 2", len(doc.Phases))
|
||||
}
|
||||
one, ok := doc.Phase("phase-1")
|
||||
if !ok {
|
||||
t.Fatal("phase-1 is not addressable")
|
||||
}
|
||||
if len(one.Automated) != 1 || one.Automated[0][0] != "bash" || len(one.Automated[0]) != 3 {
|
||||
t.Fatalf("phase-1 automated: %v", one.Automated)
|
||||
}
|
||||
if len(one.Manual) != 1 {
|
||||
t.Fatalf("phase-1 manual: %v", one.Manual)
|
||||
}
|
||||
// A command is attributed to the phase that contains it, never to the
|
||||
// previous one.
|
||||
two, _ := doc.Phase("phase-2")
|
||||
if len(two.Automated) != 1 || two.Automated[0][2] != "--help" {
|
||||
t.Fatalf("phase-2 automated: %v", two.Automated)
|
||||
}
|
||||
if len(two.Manual) != 0 {
|
||||
t.Fatalf("phase-2 borrowed manual steps: %v", two.Manual)
|
||||
}
|
||||
if strings.Join(doc.References, ",") != "r1,r2" {
|
||||
t.Fatalf("references %v", doc.References)
|
||||
}
|
||||
// The document survives byte for byte. An implement session receives this,
|
||||
// not a reconstruction of it.
|
||||
if doc.Markdown != goodPlan {
|
||||
t.Fatal("the sealed document was not retained verbatim")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanRefusesStructuralGaps(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
mutate func(string) string
|
||||
names string
|
||||
}{
|
||||
"no title": {func(s string) string { return strings.Replace(s, "# Quiet flag implementation plan", "", 1) }, "level-one"},
|
||||
"missing section": {func(s string) string { return strings.Replace(s, "## Non-goals", "## Notgoals", 1) }, "Non-goals"},
|
||||
"no phases": {func(s string) string { return strings.ReplaceAll(s, "## Phase ", "## Step ") }, "Phase 1"},
|
||||
"phase without files": {func(s string) string { return strings.Replace(s, "### Files\n- scripts", "### Filez\n- scripts", 1) }, "Files"},
|
||||
"unparsable run": {func(s string) string {
|
||||
return strings.Replace(s, `- run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]`, `- run: bash -n scripts/x.sh`, 1)
|
||||
}, "argv array"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
_, err := ParsePlan([]byte(c.mutate(goodPlan)))
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted, want a refusal", name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.names) {
|
||||
t.Errorf("%s: error %q does not name %q", name, err, c.names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A phase nobody can check can never leave in_progress. Refusing it at seal
|
||||
// time puts the failure on the planner, while the implementer would otherwise
|
||||
// discover it as a task that never finishes.
|
||||
func TestParsePlanRefusesAPhaseWithNoVerification(t *testing.T) {
|
||||
stripped := strings.Replace(goodPlan,
|
||||
"#### Automated\n- run: [\"bash\", \"scripts/orchestra_e2e_healthcheck.sh\", \"--help\"]\n", "", 1)
|
||||
_, err := ParsePlan([]byte(stripped))
|
||||
if err == nil || !strings.Contains(err.Error(), "phase-2") {
|
||||
t.Fatalf("expected phase-2 to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanRefusesNoncontiguousPhaseNumbers(t *testing.T) {
|
||||
renumbered := strings.Replace(goodPlan, "## Phase 2:", "## Phase 4:", 1)
|
||||
_, err := ParsePlan([]byte(renumbered))
|
||||
if err == nil || !strings.Contains(err.Error(), "Phase 4") {
|
||||
t.Fatalf("expected a gap to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan that shows example markdown must not parse its own example.
|
||||
func TestParsePlanIgnoresHeadingsInsideFences(t *testing.T) {
|
||||
withFence := strings.Replace(goodPlan, "## Testing strategy\n",
|
||||
"## Testing strategy\n\n```\n## Phase 9: not a real phase\n#### Automated\n- run: [\"rm\", \"-rf\", \"/\"]\n```\n\n", 1)
|
||||
doc, err := ParsePlan([]byte(withFence))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(doc.Phases) != 2 {
|
||||
t.Fatalf("a fenced example became %d phases", len(doc.Phases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePlanBoundsTheDocument(t *testing.T) {
|
||||
if _, err := ParsePlan(nil); err == nil {
|
||||
t.Fatal("an empty document was accepted")
|
||||
}
|
||||
huge := goodPlan + strings.Repeat("x", MaxPlanBytes)
|
||||
if _, err := ParsePlan([]byte(huge)); err == nil {
|
||||
t.Fatal("an oversized document was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReferencesNamesUnknownFindings(t *testing.T) {
|
||||
doc, err := ParsePlan([]byte(goodPlan))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full := Research{Findings: []Finding{
|
||||
{ID: "r1", Confidence: Fact, Claim: "c", Evidence: "e"},
|
||||
{ID: "r2", Confidence: Inference, Claim: "d", Evidence: "f"},
|
||||
}}
|
||||
if missing := doc.ResolveReferences(full); len(missing) != 0 {
|
||||
t.Fatalf("resolved research reported missing: %v", missing)
|
||||
}
|
||||
partial := Research{Findings: []Finding{{ID: "r1", Confidence: Fact, Claim: "c", Evidence: "e"}}}
|
||||
missing := doc.ResolveReferences(partial)
|
||||
if len(missing) != 1 || missing[0] != "r2" {
|
||||
t.Fatalf("want r2 reported missing, got %v", missing)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user