diff --git a/labs/12-product-engineering-loop/boatstack-distribution/README.md b/labs/12-product-engineering-loop/boatstack-distribution/README.md index be9b8b9f4..c8f238745 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/README.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/README.md @@ -97,16 +97,14 @@ In Claude Code, Cursor, and Gemini CLI, that guidance moves through `/plan-gate` ## Change course without losing the delivery -After Build, describe changes normally. Boatstack records them, preserves valid work, and resumes at the earliest boundary. - -Invoke `/repair` in Claude Code, Cursor, or Gemini CLI, or `$boatstack repair` in Codex. It needs an active delivery and the exact change; earlier work routes to planning or Build. +After Build, describe changes normally. Boatstack records them, preserves valid work, and resumes at the earliest boundary. You do not need to remember a repair command: ordinary CI failures, review findings, and denied publication attempts route automatically for active deliveries and published PRs. ```text “This is wrong” → record → repair → test → review ↘ changed intent → approve delta ``` -Receipts remain as history; published corrections become linked deliveries. +Receipts remain as history; published corrections become independently approved linked deliveries. An open PR is updated after fresh gates, while merged or closed work receives a new PR. ## What you get diff --git a/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md b/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md index f34e6660a..d269f126e 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md @@ -39,9 +39,13 @@ Boatstack received a hook event without a decodable command or tool call. It fai Replace `cursor` with `claude` or `codex` for those hosts. A passing probe proves the installed wrapper, shared runtime, decoder, and canonical allow response; it cannot reveal the live payload emitted by the coding host. For Cursor, start a new task after a passing probe. Do not reinstall or hydrate Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime. -## `/repair` says there is no active delivery +## A published PR fails CI or receives review feedback -Repair compares an exact requested change with an activated, approved baseline. If no Boatstack feature has started, save the host Plan-mode file and run `/auto-plan`. If a draft or approved feature already exists, run the one planning or build operation reported by `/boatstack-next`; do not create or clear delivery state manually. +Describe the failure normally. Boatstack resolves the current branch and recorded PR, preserves the published parent, and prepares a corrective delivery for approval. Do not manually repeat a push or PR mutation denied by the safety hook. If several features match, choose from the named candidates; if GitHub is unavailable, the correction may be planned but its PR destination remains unverified until publication. + +## Repair reports no matching delivery + +Recovery compares an exact requested change with an activated or published baseline. If no feature matches the current branch or recorded PR, save a new host Plan-mode file. If a draft or approved feature already exists, run the one planning or build operation reported by the status check; do not create or clear delivery state manually. ## Boatstack reports invalid or orphaned delivery state diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-automatic-post-publication-recovery.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-automatic-post-publication-recovery.md new file mode 100644 index 000000000..a24882324 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-automatic-post-publication-recovery.md @@ -0,0 +1,8 @@ +### Route post-publication failures into corrective delivery automatically + +Boatstack now distinguishes a published PR from a verified merged feature. CI +failures, review findings, ordinary correction requests, and denied publication +attempts resolve against the current branch and recorded PR, then prepare an +independently approved corrective child without requiring users to know a repair +command. Safety denials identify the blocking delivery and never recommend that +the user repeat the denied push manually. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md index b6e202bca..1630ba462 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -29,7 +29,7 @@ For the full state machine, read [workflow.md](references/workflow.md). For arti ## Report what is next -Run the project-local helper's read-only `next-status --repo . --json` inspection. Repository artifacts, managed delivery state, and gate receipts are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. Distinguish a Boatstack feature that is complete from one that has not started: `NOT_STARTED` and `SOURCE_PLAN_READY` point to `auto-plan`, while `FEATURE_COMPLETE` requires no action. If state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. +Run the project-local helper's read-only `next-status --repo . --json` inspection. Repository artifacts, managed delivery state, gate receipts, and the recorded PR identity are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. `NOT_STARTED` and `SOURCE_PLAN_READY` point to `auto-plan`; `PUBLISHED` means a PR exists but is not a verified merge; only `FEATURE_COMPLETE` requires no action. If state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. ## Run through ship @@ -161,7 +161,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. ## Repair from ordinary conversation -Before any product edit or explicit `repair`, run `next-status`. Repair requires an active managed delivery and the user's exact requested change. If no Boatstack feature has started, explain that there is nothing to repair yet and point to `auto-plan`; if a draft or approved feature has not activated, route to its verified `plan-gate` or `build` operation. If a delivery is active and the user reports a problem or requests a modification, use `repair` even when they do not name Boatstack or a slash command. Compare the exact request with the current lock, acceptance criteria, diff, evidence, and receipts. Classify it as `implementation_repair`, `verification_repair`, `review_repair`, `requirement_amendment`, or `needs_clarification`, then invoke `record-change` before editing. A published feature is immutable; an exact correction request starts a linked Boatstack feature rather than rewriting completed evidence. +Before any product edit or explicit `repair`, run `recovery-status` with the exact requested change and observed source stage. It resolves active work and published work associated with the current branch or recorded PR. Automatically use repair for ordinary CI failures, review findings, denied publication, problems, and modifications even when the user does not name Boatstack or a slash command. Active work resumes through `record-change`; a published parent returns `CORRECTIVE_CHILD_REQUIRED` and a deterministic child id. Never ask the user to manually repeat a denied push or PR mutation. If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime. @@ -169,7 +169,7 @@ If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it cou Same-intent repair resumes at the helper-reported stage and reuses the existing gates. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority. -A published delivery is immutable. Record the observation against it, then plan the correction under a new feature id whose structured plan sets `parent_delivery` to the published feature. Activation refuses to reset published slices; the corrective child receives its own lock and full gates. +A published delivery is immutable. Record the append-only observation without changing its state, then automatically prepare a one-slice correction under the suggested feature id with `parent_delivery` set to the published feature. Present the inherited intent, observed failure, existing local diff, verification, and PR destination, then pause for the normal fingerprinted human approval. The corrective child receives its own lock and full gates. A verified open PR reuses its head branch and is updated; merged or closed work uses a fresh branch and PR. Unknown PR state may be planned but blocks destination-specific publication. ## Enforce the gates diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index ffb1adf62..de846c777 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -403,6 +403,38 @@ func nextStatusCommand(arguments []string) int { return 0 } +func recoveryStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("recovery-status", flag.ContinueOnError) + options := boatstack.RecoveryStatusOptions{} + flags.StringVar(&options.Repo, "repo", ".", "repository whose managed delivery should be resolved") + flags.StringVar(&options.Feature, "feature", "", "optional specific active or published feature") + flags.StringVar(&options.Message, "message", "", "exact reported correction") + flags.StringVar(&options.SourceStage, "source-stage", "", "ci, review, publication, or user") + flags.StringVar(&options.Evidence, "evidence", "", "bounded failure or review reference") + flags.StringVar(&options.ObservedHeadSHA, "observed-head-sha", "", "optional PR head tied to the reported evidence") + jsonOutput := flags.Bool("json", false, "print the versioned structured recovery decision") + if err := flags.Parse(arguments); err != nil { + return 2 + } + status, err := boatstack.ResolveRecovery(options) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(status) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Printf("Recovery: %s\nFeature: %s\nLifecycle: %s\nNext operation: %s\nReason: %s\n", status.VerificationStatus, status.Feature, status.Lifecycle, status.NextOperation, status.Reason) + } + if status.VerificationStatus == "BLOCKED" { + return 1 + } + return 0 +} + func runPreflightCommand(arguments []string) int { flags := flag.NewFlagSet("run-preflight", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Git state should be verified before boatstack run") @@ -448,7 +480,10 @@ func recordChangeCommand(arguments []string) int { if err != nil { return fail(err) } - fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, state.Mode, state.ResumeStage) + fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nOUTCOME=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, observation.Outcome, state.Mode, state.ResumeStage) + if observation.Outcome == "CORRECTIVE_CHILD_REQUIRED" { + fmt.Printf("PARENT_DELIVERY=%s\nSUGGESTED_FEATURE_ID=%s\n", observation.ParentDelivery, observation.SuggestedFeatureID) + } return 0 } @@ -783,7 +818,7 @@ func workspaceStatusCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -813,6 +848,8 @@ func run() int { return deliveryStatusCommand(os.Args[2:]) case "next-status": return nextStatusCommand(os.Args[2:]) + case "recovery-status": + return recoveryStatusCommand(os.Args[2:]) case "run-preflight": return runPreflightCommand(os.Args[2:]) case "record-change": diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index ba353e7da..dbd5ff8f7 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -6,6 +6,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" ) @@ -108,17 +109,20 @@ type ChangeObservationOptions struct { } type ChangeObservation struct { - ID string `json:"id"` - Feature string `json:"feature"` - SliceID string `json:"slice_id,omitempty"` - SourceStage string `json:"source_stage"` - Expected string `json:"expected,omitempty"` - Actual string `json:"actual,omitempty"` - Evidence string `json:"evidence,omitempty"` - Message string `json:"message"` - Classification string `json:"classification"` - ResumeStage string `json:"resume_stage,omitempty"` - RecordedAt string `json:"recorded_at"` + ID string `json:"id"` + Feature string `json:"feature"` + SliceID string `json:"slice_id,omitempty"` + SourceStage string `json:"source_stage"` + Expected string `json:"expected,omitempty"` + Actual string `json:"actual,omitempty"` + Evidence string `json:"evidence,omitempty"` + Message string `json:"message"` + Classification string `json:"classification"` + ResumeStage string `json:"resume_stage,omitempty"` + RecordedAt string `json:"recorded_at"` + Outcome string `json:"outcome,omitempty"` + ParentDelivery string `json:"parent_delivery,omitempty"` + SuggestedFeatureID string `json:"suggested_feature_id,omitempty"` } func deliveryEvidenceGateStatus(value, gate, sliceID string, explicit bool) string { @@ -376,12 +380,30 @@ func appendChangeObservation(repo string, observation ChangeObservation) error { if len(existing) == 0 { existing = []byte("# Change observations\n\nAppend-only observations recorded after build activation.\n") } - block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Resume stage: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Resolution: pending\n", + block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Outcome: `%s`\n- Resume stage: `%s`\n- Parent delivery: `%s`\n- Suggested corrective feature: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Resolution: pending\n", observation.ID, observation.RecordedAt, observation.SourceStage, observation.Classification, - observation.ResumeStage, observation.Message, observation.Expected, observation.Actual, observation.Evidence) + observation.Outcome, observation.ResumeStage, observation.ParentDelivery, observation.SuggestedFeatureID, + observation.Message, observation.Expected, observation.Actual, observation.Evidence) return atomicWriteMode(path, append(existing, []byte(block)...), 0o644) } +func nextChangeObservationID(repo, feature string, fallback int) string { + path := filepath.Join(repo, ".product-loop", "features", feature, "changes.md") + value, err := os.ReadFile(path) + if err != nil { + return fmt.Sprintf("CHG-%03d", fallback) + } + maximum := fallback - 1 + pattern := regexp.MustCompile(`(?m)^## CHG-([0-9]+)\s*$`) + for _, match := range pattern.FindAllStringSubmatch(string(value), -1) { + parsed, parseErr := strconv.Atoi(match[1]) + if parseErr == nil && parsed > maximum { + maximum = parsed + } + } + return fmt.Sprintf("CHG-%03d", maximum+1) +} + func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservation, DeliveryState, error) { repo, err := ResolveRepository(options.Repo) if err != nil { @@ -407,22 +429,41 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" { return ChangeObservation{}, DeliveryState{}, fmt.Errorf("change observation requires the user message and source stage") } - state.RepairAttempt++ + published := state.ActiveIndex >= len(state.Slices) + if !published { + state.RepairAttempt++ + } id := fmt.Sprintf("CHG-%03d", state.RepairAttempt) + if published { + id = nextChangeObservationID(repo, options.Feature, state.RepairAttempt+1) + } observation := ChangeObservation{ ID: id, Feature: options.Feature, SourceStage: strings.ToUpper(strings.TrimSpace(options.SourceStage)), Expected: strings.TrimSpace(options.Expected), Actual: strings.TrimSpace(options.Actual), Evidence: strings.TrimSpace(options.Evidence), Message: strings.TrimSpace(options.Message), Classification: classification, ResumeStage: resume, RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), } - if state.ActiveIndex < len(state.Slices) { + if !published { observation.SliceID = state.Slices[state.ActiveIndex].ID - } else if classification != "requirement_amendment" { - return ChangeObservation{}, DeliveryState{}, fmt.Errorf("published delivery changes require requirement_amendment and a corrective child delivery") + observation.Outcome = "RESUME_ACTIVE" + } else { + if len(state.Slices) > 0 { + observation.SliceID = state.Slices[len(state.Slices)-1].ID + } + states, statesErr := allManagedDeliveryStates(repo) + if statesErr != nil { + return ChangeObservation{}, DeliveryState{}, statesErr + } + observation.Outcome = "CORRECTIVE_CHILD_REQUIRED" + observation.ParentDelivery = state.Feature + observation.SuggestedFeatureID = suggestedCorrectionFeature(states, state.Feature) } if err := appendChangeObservation(repo, observation); err != nil { return ChangeObservation{}, DeliveryState{}, err } + if published { + return observation, state, nil + } state.ActiveObservationID = id state.ResumeStage = resume if classification == "needs_clarification" || classification == "requirement_amendment" { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go index 0386ab7f9..5c3790954 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go @@ -308,15 +308,41 @@ func TestPublishedChangeRemainsDiscoverableAndCannotResetOriginalDelivery(t *tes if err := saveDeliveryState(repo, completed); err != nil { t.Fatal(err) } - if _, _, err := RecordChangeObservation(ChangeObservationOptions{ + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + observation, unchanged, err := RecordChangeObservation(ChangeObservationOptions{ Repo: repo, Feature: feature, Message: "production needs a different success state", - SourceStage: "published", Classification: "requirement_amendment", - }); err != nil { + SourceStage: "ci", Classification: "verification_repair", + }) + if err != nil { + t.Fatal(err) + } + if observation.Outcome != "CORRECTIVE_CHILD_REQUIRED" || observation.ParentDelivery != feature || observation.SuggestedFeatureID != feature+"-correction-01" { + t.Fatalf("unexpected corrective-child result: %#v", observation) + } + changes, err := os.ReadFile(filepath.Join(repo, ".product-loop", "features", feature, "changes.md")) + if err != nil || !strings.Contains(string(changes), "CORRECTIVE_CHILD_REQUIRED") || !strings.Contains(string(changes), feature+"-correction-01") { + t.Fatalf("corrective-child outcome was not recorded in the parent ledger: %v %s", err, changes) + } + if unchanged.Mode != completed.Mode || unchanged.ActiveIndex != completed.ActiveIndex { + t.Fatalf("published parent was mutated in memory: %#v", unchanged) + } + after, err := os.ReadFile(statePath) + if err != nil { t.Fatal(err) } + if string(before) != string(after) { + t.Fatalf("published parent state changed while recording correction\nbefore=%s\nafter=%s", before, after) + } active, err := ActiveManagedDeliveries(repo) - if err != nil || len(active) != 1 || active[0] != feature { - t.Fatalf("published correction was not discoverable: %#v %v", active, err) + if err != nil || len(active) != 0 { + t.Fatalf("published parent became active: %#v %v", active, err) } lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") if err := os.WriteFile(lockPath, []byte("replacement-lock"), 0o644); err != nil { @@ -399,11 +425,46 @@ func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" { t.Fatalf("direct publication was not denied for %q: %#v", command, findings) } + if findings[0].BlockingFeature != "phased-feature" || findings[0].BlockingSlice != "phase-one" || findings[0].NextOperation != "recovery-status" { + t.Fatalf("publication denial omitted recovery context for %q: %#v", command, findings) + } + } + state, err := LoadDeliveryState(repo, "phased-feature") + if err != nil { + t.Fatal(err) + } + state.Slices[0].HeadBranch = "main" + state.ParentDelivery = "published-parent" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + related := ClassifyCommand(repo, "git push origin main") + if len(related) != 1 || related[0].BranchRelation != "current_branch" || related[0].ParentDelivery != "published-parent" { + t.Fatalf("related publication denial was not identified: %#v", related) } findings := ClassifyTool(repo, "github_create_pull_request", map[string]any{"title": "phase one"}) if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" { t.Fatalf("GitHub tool publication was not denied: %#v", findings) } + if findings[0].BlockingFeature != related[0].BlockingFeature || findings[0].BlockingSlice != related[0].BlockingSlice || findings[0].BranchRelation != related[0].BranchRelation || findings[0].NextOperation != related[0].NextOperation || findings[0].ParentDelivery != related[0].ParentDelivery { + t.Fatalf("shell and tool publication findings diverged:\nshell=%#v\ntool=%#v", related[0], findings[0]) + } + hostEvents := map[string]string{ + "cursor": `{"hook_event_name":"beforeShellExecution","command":"git push origin main"}`, + "claude": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}`, + "codex": `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git push origin main"}}`, + } + for host, event := range hostEvents { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(event)}) + if !denied { + t.Fatalf("%s publication event was allowed", host) + } + for _, expected := range []string{"phased-feature", "slice=phase-one", "relation=current_branch", "parent=published-parent", "next=recovery-status", "do not repeat this push"} { + if !strings.Contains(string(output), expected) { + t.Fatalf("%s hook omitted recovery context %q: %s", host, expected, output) + } + } + } if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 { t.Fatalf("read-only Git was unexpectedly denied: %#v", findings) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index 41400e586..f65d47d8e 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -265,12 +265,12 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations := map[string]string{ - "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from FEATURE_COMPLETE, which responds Feature complete and requires no action. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", - "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", + "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from PUBLISHED, which responds PR published and makes reviewing its checks the one action, and FEATURE_COMPLETE, which is reserved for a verified merged PR and responds Feature complete with No action required. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", + "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", "plan-gate": "Run check-plan read-only and present its fingerprint and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", - "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", + "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant or unresolved, show the exact fingerprinted local PNGs and public-repository warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Use a signed-in host browser to upload or update that comment when available and record the observed PR and comment URLs with record-pr-visual-publication; otherwise expose the local paths for manual attachment. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and fix forward from visual_pending after attachment failure. Add security/privacy, migration, or operations sections only when relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", @@ -293,7 +293,7 @@ The source of truth is @.product-loop/workflow.md and @.product-loop/project.jso Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation. -Before modifying product code, check for an active managed delivery. When one exists and the user reports a problem or requests a modification in ordinary language, route through the Boatstack repair operation before editing. The repair operation records the exact request, compares it with approved intent, and either resumes the earliest affected stage or blocks for a plan amendment. If no managed delivery exists, continue ordinary conversation. +Before modifying product code, check for an active managed delivery or a published delivery associated with the current branch or recorded PR. When one exists and the user reports a CI failure, review finding, denied publication, problem, or modification in ordinary language, route through the Boatstack repair operation before editing. The repair operation resolves active and published work, records the exact request, and either resumes the active delivery or prepares an independently approved corrective child. Never ask the user to manually repeat a push or PR mutation denied by the safety hook. If no managed delivery matches, continue ordinary conversation. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. @@ -312,12 +312,12 @@ Boatstack's repository hooks deny high-confidence irreversible operations across adapterSkill := fmt.Sprintf(`--- name: %s -description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when ordinary free-form change language targets an active managed delivery. +description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when ordinary free-form change language, CI failure, review feedback, or a denied publication targets an active or current-branch published managed delivery. --- # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery. If one exists and ordinary user language reports a problem or asks for a modification, automatically use repair even when the user did not name the operation. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery or a published delivery associated with the current branch or recorded PR. If one exists and ordinary user language reports a CI failure, review finding, denied publication, problem, or modification, automatically use repair even when the user did not name the operation. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go index e821c4067..c74a49229 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go @@ -330,7 +330,7 @@ func TestExportAndDriftCheck(t *testing.T) { } } repair := string(bundle.Files[".cursor/commands/repair.md"]) - for _, expected := range []string{"next-status", "No active delivery to repair", "NOT_STARTED", "DRAFT_PLAN", "APPROVED", "record-change", "implementation_repair", "verification_repair", "requirement_amendment", "needs_clarification", "/test-gate", "/review-gate", "MainThreadShellExec not initialized", "Developer: Reload Window"} { + for _, expected := range []string{"recovery-status", "repair_active", "draft_corrective_child", "record-change", "parent_delivery", "The PR needs a corrective delivery", "never reuse the parent's approval", "Never ask the user to repeat a denied push", "MainThreadShellExec not initialized", "Developer: Reload Window"} { if !strings.Contains(repair, expected) { t.Fatalf("repair adapter is missing %q", expected) } @@ -343,7 +343,7 @@ func TestExportAndDriftCheck(t *testing.T) { } for _, path := range []string{".claude/skills/boatstack/SKILL.md", ".gemini/skills/boatstack/SKILL.md", ".agents/skills/boatstack/SKILL.md"} { router := string(bundle.Files[path]) - if !strings.Contains(router, "automatically use repair") || !strings.Contains(router, "active managed delivery") { + if !strings.Contains(router, "automatically use repair") || !strings.Contains(router, "current-branch published managed delivery") || !strings.Contains(router, "Never instruct the user to manually repeat") { t.Fatalf("%s does not auto-route free-form delivery changes", path) } } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index 9526c1929..9d73fff49 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -8,7 +8,7 @@ import ( "strings" ) -const nextStatusSchemaVersion = 1 +const nextStatusSchemaVersion = 2 // NextStatus is the read-only, host-neutral projection of Boatstack's current // workflow position. Conversation and terminal context are deliberately absent: @@ -25,6 +25,10 @@ type NextStatus struct { Operator DecisionOperator `json:"operator,omitempty"` Reason string `json:"reason"` BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"` + Lifecycle string `json:"lifecycle,omitempty"` + PRURL string `json:"pr_url,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + ParentDelivery string `json:"parent_delivery,omitempty"` } func blockedNextStatus(stage, operation, reason string, ambiguity ...string) NextStatus { @@ -163,6 +167,30 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { return status, nil } +func nextForPublished(repo string, state DeliveryState) NextStatus { + pr := observePublishedPR(repo, state) + _, sliceID, _ := deliveryBranchAndSlice(state) + status := NextStatus{ + SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED", + Feature: state.Feature, ActiveSlice: sliceID, SliceIndex: len(state.Slices), + TotalSlices: len(state.Slices), ObservedStage: "PUBLISHED", NextOperation: "none", + Lifecycle: pr.Lifecycle, PRURL: pr.URL, HeadBranch: pr.Branch, + ParentDelivery: state.ParentDelivery, + } + switch pr.Lifecycle { + case "PUBLISHED_MERGED": + status.ObservedStage = "FEATURE_COMPLETE" + status.Reason = fmt.Sprintf("The published PR for feature %q is merged.", state.Feature) + case "PUBLISHED_OPEN": + status.Reason = fmt.Sprintf("Feature %q is published in an open PR; review and required checks may still produce a corrective delivery.", state.Feature) + case "PUBLISHED_CLOSED": + status.Reason = fmt.Sprintf("The PR for feature %q is closed without a verified merge; a future correction requires a fresh PR.", state.Feature) + default: + status.Reason = fmt.Sprintf("Feature %q is published, but its PR state could not be verified.", state.Feature) + } + return status +} + func completedManagedStates(repo string) ([]DeliveryState, error) { directory, err := deliveryStateDirectory(repo) if err != nil { @@ -192,8 +220,9 @@ func completedManagedStates(repo string) ([]DeliveryState, error) { return completed, nil } -// ResolveNext performs bounded, local, read-only state inspection. It never -// contacts GitHub and never treats process or conversation history as evidence. +// ResolveNext performs bounded, read-only state inspection. Published states +// use the recorded PR identity when GitHub is available; conversation and +// process history are never treated as evidence. func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { repo, err := ResolveRepository(repoPath) if err != nil { @@ -227,8 +256,10 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { } if found { active = []string{explicitFeature} + } else if completedState, completedErr := CurrentDeliveryState(repo, explicitFeature); completedErr == nil && completedState.ActiveIndex >= len(completedState.Slices) { + return nextForPublished(repo, completedState), nil } else { - return blockedNextStatus("INVALID_STATE", "repair-state", fmt.Sprintf("Feature %s is not currently an active managed delivery.", explicitFeature)), nil + return blockedNextStatus("INVALID_STATE", "repair-state", fmt.Sprintf("Feature %s is not a verifiable active or published managed delivery.", explicitFeature)), nil } } @@ -325,30 +356,35 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack found invalid completed delivery state. Preserve the artifacts and restore its evidence before continuing: "+err.Error()), nil } if len(completed) > 0 { - base.VerificationStatus = "VERIFIED" - base.ObservedStage = "FEATURE_COMPLETE" - base.NextOperation = "none" if len(completed) == 1 { - base.Feature = completed[0].Feature - head := "" - if slices := completed[0].Slices; len(slices) > 0 { - last := slices[len(slices)-1] - base.ActiveSlice = last.ID - head = last.HeadBranch - } - base.Reason = fmt.Sprintf("All managed slices for feature %q are already published.", completed[0].Feature) + base = nextForPublished(repo, completed[0]) // When workspace management is on and the shipped feature still has a - // linked worktree locally, surface cleanup as the next step. This is a - // local-only check; merge confirmation and gating happen in the - // workspace-cleanup operation, never here. - if head != "" && workspaceEnabled(repo) { - if path := worktreePathForBranch(repo, head); path != "" { + // linked worktree locally, surface cleanup only after a verified merge. + if base.Lifecycle == "PUBLISHED_MERGED" && base.HeadBranch != "" && workspaceEnabled(repo) { + if path := worktreePathForBranch(repo, base.HeadBranch); path != "" { base.NextOperation = "workspace-cleanup" - base.Reason = fmt.Sprintf("Feature %q is published; its workspace on %q can be cleaned up.", completed[0].Feature, head) + base.Reason = fmt.Sprintf("Feature %q is merged; its workspace on %q can be cleaned up.", completed[0].Feature, base.HeadBranch) } } } else { - base.Reason = "All managed delivery states are already published." + branch, _ := gitCommand(repo, "branch", "--show-current") + matches := []DeliveryState{} + for _, state := range completed { + if stateMatchesBranch(state, strings.TrimSpace(branch)) { + matches = append(matches, state) + } + } + if len(matches) == 1 { + base = nextForPublished(repo, matches[0]) + } else { + base.VerificationStatus = "BLOCKED" + base.ObservedStage = "AMBIGUOUS" + base.NextOperation = "resolve-ambiguity" + base.Reason = "Multiple published deliveries exist and none is uniquely associated with the current branch." + for _, state := range completed { + base.BlockingAmbiguity = append(base.BlockingAmbiguity, state.Feature) + } + } } return base, nil } @@ -375,6 +411,12 @@ func FormatNextStatus(status NextStatus) string { parts = append(parts, "Active slice: "+status.ActiveSlice) } } + if status.Lifecycle != "" { + parts = append(parts, "Lifecycle: "+status.Lifecycle) + } + if status.PRURL != "" { + parts = append(parts, "PR: "+status.PRURL) + } parts = append(parts, "Reason: "+status.Reason, "Next: "+status.NextOperation) if len(status.BlockingAmbiguity) > 0 { parts = append(parts, "Candidates: "+strings.Join(status.BlockingAmbiguity, ", ")) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next_test.go b/labs/12-product-engineering-loop/product-engineering-loop/next_test.go index b2c61f4f1..6c2a04f6c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "reflect" + "strings" "testing" ) @@ -250,7 +251,7 @@ func TestResolveNextDeliveryTransitions(t *testing.T) { } } -func TestResolveNextReportsFeatureCompleteAfterPublication(t *testing.T) { +func TestResolveNextReportsPublishedUnknownWithoutPRVerification(t *testing.T) { repo := nextTestRepo(t) writeNextDelivery(t, repo, "recovery", "PUBLISHED", 1) intake := filepath.Join(repo, ".product-loop", "intake") @@ -268,7 +269,7 @@ func TestResolveNextReportsFeatureCompleteAfterPublication(t *testing.T) { if err != nil { t.Fatal(err) } - if status.ObservedStage != "FEATURE_COMPLETE" || status.NextOperation != "none" { + if status.SchemaVersion != 2 || status.ObservedStage != "PUBLISHED" || status.Lifecycle != "PUBLISHED_UNKNOWN" || status.NextOperation != "none" { t.Fatalf("unexpected status: %+v", status) } if status.Feature != "recovery" { @@ -279,6 +280,37 @@ func TestResolveNextReportsFeatureCompleteAfterPublication(t *testing.T) { } } +func TestResolveNextReportsFeatureCompleteOnlyAfterVerifiedMerge(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "recovery", "PUBLISHED", 1) + state, err := LoadDeliveryState(repo, "recovery") + if err != nil { + t.Fatal(err) + } + state.Slices[0].HeadBranch = "feat/recovery" + state.Slices[0].PRURL = "https://example.invalid/pr/1" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + withRecoveryGh(t, recoveryPR("MERGED", "feat/recovery", "abc123")) + status, err := ResolveNext(repo, "recovery") + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "FEATURE_COMPLETE" || status.Lifecycle != "PUBLISHED_MERGED" || status.NextOperation != "none" { + t.Fatalf("unexpected merged status: %+v", status) + } +} + +func TestFormatNextStatusStillRendersSchemaV1Values(t *testing.T) { + value := FormatNextStatus(NextStatus{SchemaVersion: 1, VerificationStatus: "VERIFIED", Feature: "legacy", ObservedStage: "FEATURE_COMPLETE", NextOperation: "none", Reason: "Legacy published state."}) + for _, expected := range []string{"Feature: legacy", "Boatstack stage: FEATURE_COMPLETE", "Next: none"} { + if !strings.Contains(value, expected) { + t.Fatalf("legacy status rendering omitted %q: %s", expected, value) + } + } +} + func TestResolveNextPrefersNewDraftOverCompletedHistory(t *testing.T) { repo := nextTestRepo(t) writeNextDelivery(t, repo, "published", "PUBLISHED", 1) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go new file mode 100644 index 000000000..a8dc2e875 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go @@ -0,0 +1,363 @@ +package boatstack + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const recoveryStatusSchemaVersion = 1 + +// RecoveryStatus is the read-only, host-neutral decision for a reported +// delivery problem. It deliberately carries no authority to edit, approve, or +// publish anything. +type RecoveryStatus struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + Feature string `json:"feature,omitempty"` + Slice string `json:"slice,omitempty"` + ParentDelivery string `json:"parent_delivery,omitempty"` + Lifecycle string `json:"lifecycle,omitempty"` + PRURL string `json:"pr_url,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + ObservedPRHeadSHA string `json:"observed_pr_head_sha,omitempty"` + NextOperation string `json:"next_operation"` + SuggestedFeatureID string `json:"suggested_feature_id,omitempty"` + ExistingDiffSHA256 string `json:"existing_diff_sha256,omitempty"` + ExistingChangedPaths []string `json:"existing_changed_paths,omitempty"` + Reason string `json:"reason"` + Blockers []string `json:"blockers,omitempty"` +} + +type RecoveryStatusOptions struct { + Repo string + Feature string + Message string + SourceStage string + Evidence string + ObservedHeadSHA string +} + +type publishedPRObservation struct { + Lifecycle string + URL string + Branch string + HeadSHA string +} + +var recoveryGh = func(repo string, arguments ...string) (string, error) { + return commandOutput(repo, "gh", arguments...) +} + +func blockedRecovery(reason string, blockers ...string) RecoveryStatus { + return RecoveryStatus{ + SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "BLOCKED", + NextOperation: "resolve_ambiguity", Reason: reason, Blockers: blockers, + } +} + +func allManagedDeliveryStates(repo string) ([]DeliveryState, error) { + directory, err := deliveryStateDirectory(repo) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + states := []DeliveryState{} + for _, entry := range entries { + if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) { + continue + } + state, loadErr := CurrentDeliveryState(repo, entry.Name()) + if loadErr != nil { + return nil, fmt.Errorf("invalid managed delivery state for %s: %w", entry.Name(), loadErr) + } + states = append(states, state) + } + sort.Slice(states, func(i, j int) bool { return states[i].Feature < states[j].Feature }) + return states, nil +} + +func deliveryBranchAndSlice(state DeliveryState) (string, string, string) { + if len(state.Slices) == 0 { + return "", "", "" + } + index := state.ActiveIndex + if index >= len(state.Slices) { + index = len(state.Slices) - 1 + } + slice := state.Slices[index] + return slice.HeadBranch, slice.ID, slice.PRURL +} + +func stateMatchesBranch(state DeliveryState, branch string) bool { + if strings.TrimSpace(branch) == "" { + return false + } + head, _, _ := deliveryBranchAndSlice(state) + if head != "" { + return head == branch + } + return branchForFeature(state.Feature) == branch +} + +func selectRecoveryDelivery(states []DeliveryState, explicitFeature, currentBranch string) (DeliveryState, []string, error) { + if explicitFeature != "" { + for _, state := range states { + if state.Feature == explicitFeature { + return state, nil, nil + } + } + return DeliveryState{}, nil, fmt.Errorf("managed delivery %s does not exist", explicitFeature) + } + selectMatching := func(active bool) []DeliveryState { + matches := []DeliveryState{} + for _, state := range states { + isActive := state.ActiveIndex < len(state.Slices) + if isActive == active && stateMatchesBranch(state, currentBranch) { + matches = append(matches, state) + } + } + return matches + } + for _, active := range []bool{true, false} { + matches := selectMatching(active) + if len(matches) == 1 { + return matches[0], nil, nil + } + if len(matches) > 1 { + features := make([]string, 0, len(matches)) + for _, state := range matches { + features = append(features, state.Feature) + } + return DeliveryState{}, features, nil + } + } + if len(states) == 1 { + return states[0], nil, nil + } + features := make([]string, 0, len(states)) + for _, state := range states { + features = append(features, state.Feature) + } + return DeliveryState{}, features, nil +} + +func observePublishedPR(repo string, state DeliveryState) publishedPRObservation { + branch, _, prURL := deliveryBranchAndSlice(state) + observation := publishedPRObservation{Lifecycle: "PUBLISHED_UNKNOWN", URL: prURL, Branch: branch} + target := prURL + if target == "" { + target = branch + } + if target == "" { + return observation + } + value, err := recoveryGh(repo, "pr", "view", target, "--json", "state,headRefName,headRefOid,url") + if err != nil { + return observation + } + var payload struct { + State string `json:"state"` + HeadRefName string `json:"headRefName"` + HeadRefOID string `json:"headRefOid"` + URL string `json:"url"` + } + if DecodeJSON("inspect published PR", target, []byte(value), &payload) != nil { + return observation + } + if payload.URL != "" { + observation.URL = payload.URL + } + if payload.HeadRefName != "" { + observation.Branch = payload.HeadRefName + } + observation.HeadSHA = payload.HeadRefOID + switch strings.ToUpper(strings.TrimSpace(payload.State)) { + case "OPEN": + observation.Lifecycle = "PUBLISHED_OPEN" + case "MERGED": + observation.Lifecycle = "PUBLISHED_MERGED" + case "CLOSED": + observation.Lifecycle = "PUBLISHED_CLOSED" + } + return observation +} + +func suggestedCorrectionFeature(states []DeliveryState, parent string) string { + used := map[int]bool{} + prefix := parent + "-correction-" + for _, state := range states { + if state.ParentDelivery != parent || !strings.HasPrefix(state.Feature, prefix) { + continue + } + value, err := strconv.Atoi(strings.TrimPrefix(state.Feature, prefix)) + if err == nil && value > 0 { + used[value] = true + } + } + for value := 1; ; value++ { + if !used[value] { + return fmt.Sprintf("%s%02d", prefix, value) + } + } +} + +func existingRecoveryDiff(repo string, state DeliveryState) (string, []string) { + baseCommit := "" + if len(state.Slices) > 0 { + last := state.Slices[len(state.Slices)-1] + if receipt, err := readDeliveryReceipt(repo, state.Feature, last.ID, "review"); err == nil { + baseCommit = strings.TrimSpace(receipt.HeadCommit) + } + } + if baseCommit == "" { + base := defaultPRBase(repo) + if len(state.Slices) > 0 && strings.TrimSpace(state.Slices[len(state.Slices)-1].BaseBranch) != "" { + base = state.Slices[len(state.Slices)-1].BaseBranch + } + resolved, err := resolveBaseCommit(repo, base) + if err != nil { + return "", nil + } + baseCommit = resolved + } + diff, err := exec.Command("git", "-C", repo, "diff", "--binary", "--no-ext-diff", baseCommit, "--", ".").Output() + if err != nil { + return "", nil + } + // Read stdout only: Git may emit platform-specific line-ending warnings on + // stderr, and those messages are not changed paths. NUL separation also + // preserves unusual but valid filenames. + names, err := exec.Command("git", "-C", repo, "diff", "--name-only", "-z", baseCommit, "--", ".").Output() + if err != nil { + return "", nil + } + paths := []string{} + for _, raw := range bytes.Split(names, []byte{0}) { + if len(raw) != 0 { + paths = append(paths, filepath.ToSlash(string(raw))) + } + } + untracked, err := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard", "-z").Output() + if err != nil { + return "", nil + } + canonical := bytes.NewBuffer(diff) + for _, raw := range bytes.Split(untracked, []byte{0}) { + if len(raw) == 0 { + continue + } + path := filepath.ToSlash(string(raw)) + value, readErr := os.ReadFile(filepath.Join(repo, filepath.FromSlash(path))) + if readErr != nil { + return "", nil + } + paths = append(paths, path) + canonical.WriteString("\nuntracked ") + canonical.WriteString(path) + canonical.WriteString(" ") + canonical.WriteString(SHA256Bytes(value)) + } + if canonical.Len() == 0 { + return "", nil + } + sort.Strings(paths) + return SHA256Bytes(canonical.Bytes()), paths +} + +// ResolveRecovery identifies the delivery that owns an exact correction and +// returns the safe transition. It does not record the request or draft files. +func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return RecoveryStatus{}, err + } + if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" { + return RecoveryStatus{}, fmt.Errorf("recovery status requires the exact message and source stage") + } + switch strings.ToLower(strings.TrimSpace(options.SourceStage)) { + case "ci", "review", "publication", "user": + default: + return RecoveryStatus{}, fmt.Errorf("recovery source stage must be ci, review, publication, or user") + } + if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + return RecoveryStatus{ + SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "UNVERIFIED", + NextOperation: "none", Reason: "This repository has no managed delivery installation to inspect.", + }, nil + } + states, err := allManagedDeliveryStates(repo) + if err != nil { + return blockedRecovery("Managed delivery state cannot be verified: " + err.Error()), nil + } + branch, _ := gitCommand(repo, "branch", "--show-current") + selected, ambiguity, selectErr := selectRecoveryDelivery(states, strings.TrimSpace(options.Feature), strings.TrimSpace(branch)) + if selectErr != nil { + return blockedRecovery(selectErr.Error()), nil + } + if len(ambiguity) > 0 { + return blockedRecovery("More than one managed delivery could own this correction; choose the feature explicitly.", ambiguity...), nil + } + if selected.Feature == "" { + return RecoveryStatus{ + SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "UNVERIFIED", + NextOperation: "none", Reason: "No managed delivery matches the current branch or correction.", + }, nil + } + head, sliceID, prURL := deliveryBranchAndSlice(selected) + status := RecoveryStatus{ + SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "VERIFIED", + Feature: selected.Feature, Slice: sliceID, ParentDelivery: selected.ParentDelivery, + HeadBranch: head, PRURL: prURL, + } + if selected.ActiveIndex < len(selected.Slices) { + status.Lifecycle = "ACTIVE" + status.NextOperation = "repair_active" + status.Reason = fmt.Sprintf("Managed delivery %q is active; route the exact correction through its current repair boundary.", selected.Feature) + return status, nil + } + pr := observePublishedPR(repo, selected) + status.Lifecycle = pr.Lifecycle + status.PRURL = pr.URL + status.ObservedPRHeadSHA = pr.HeadSHA + if pr.Branch != "" { + status.HeadBranch = pr.Branch + } + if head != "" && pr.Branch != "" && head != pr.Branch { + status.VerificationStatus = "BLOCKED" + status.NextOperation = "resolve_ambiguity" + status.Reason = "The recorded delivery branch does not match the observed PR head branch." + status.Blockers = []string{head, pr.Branch} + return status, nil + } + if expected := strings.TrimSpace(options.ObservedHeadSHA); expected != "" && pr.HeadSHA != "" && expected != pr.HeadSHA { + status.VerificationStatus = "BLOCKED" + status.NextOperation = "none" + status.Reason = "The reported failure belongs to a stale PR head; refresh the failure evidence before drafting a correction." + status.Blockers = []string{"reported_head=" + expected, "current_head=" + pr.HeadSHA} + return status, nil + } + status.NextOperation = "draft_corrective_child" + status.SuggestedFeatureID = suggestedCorrectionFeature(states, selected.Feature) + status.ExistingDiffSHA256, status.ExistingChangedPaths = existingRecoveryDiff(repo, selected) + switch pr.Lifecycle { + case "PUBLISHED_OPEN": + status.Reason = "The published PR is open; draft an independently approved corrective child that will update the same PR." + case "PUBLISHED_MERGED", "PUBLISHED_CLOSED": + status.Reason = "The prior PR is no longer open; draft an independently approved corrective child on a fresh branch and PR." + default: + status.Reason = "The PR state cannot be verified; draft the corrective child now and defer its publication destination until verification succeeds." + } + return status, nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery_test.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery_test.go new file mode 100644 index 000000000..ac033d511 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery_test.go @@ -0,0 +1,186 @@ +package boatstack + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" +) + +func withRecoveryGh(t *testing.T, fn func(string, ...string) (string, error)) { + t.Helper() + previous := recoveryGh + recoveryGh = fn + t.Cleanup(func() { recoveryGh = previous }) +} + +func recoveryPR(state, branch, sha string) func(string, ...string) (string, error) { + return func(_ string, _ ...string) (string, error) { + return fmt.Sprintf(`{"state":%q,"headRefName":%q,"headRefOid":%q,"url":"https://example.invalid/pr/1"}`, state, branch, sha), nil + } +} + +func updateRecoveryDelivery(t *testing.T, repo, feature, branch, prURL, parent string) { + t.Helper() + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + index := state.ActiveIndex + if index >= len(state.Slices) { + index = len(state.Slices) - 1 + } + state.Slices[index].HeadBranch = branch + state.Slices[index].PRURL = prURL + state.ParentDelivery = parent + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } +} + +func TestResolveRecoveryRoutesActiveCurrentBranch(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + writeNextDelivery(t, repo, "active", "BUILD", 0) + updateRecoveryDelivery(t, repo, "active", branch, "", "") + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the test failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "VERIFIED" || status.Feature != "active" || status.Lifecycle != "ACTIVE" || status.NextOperation != "repair_active" { + t.Fatalf("unexpected active recovery: %#v", status) + } +} + +func TestResolveRecoveryDraftsPublishedCorrectionByPRState(t *testing.T) { + for _, test := range []struct { + state string + lifecycle string + }{ + {state: "OPEN", lifecycle: "PUBLISHED_OPEN"}, + {state: "MERGED", lifecycle: "PUBLISHED_MERGED"}, + {state: "CLOSED", lifecycle: "PUBLISHED_CLOSED"}, + } { + t.Run(test.state, func(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + writeNextDelivery(t, repo, "published", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "") + withRecoveryGh(t, recoveryPR(test.state, branch, "head123")) + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "review found a regression", SourceStage: "review", ObservedHeadSHA: "head123"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "VERIFIED" || status.Lifecycle != test.lifecycle || status.NextOperation != "draft_corrective_child" || status.SuggestedFeatureID != "published-correction-01" { + t.Fatalf("unexpected published recovery: %#v", status) + } + }) + } +} + +func TestResolveRecoveryAllowsDraftWhenGitHubUnavailable(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + writeNextDelivery(t, repo, "published", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "") + withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("not authenticated") }) + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "CI failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.Lifecycle != "PUBLISHED_UNKNOWN" || status.NextOperation != "draft_corrective_child" { + t.Fatalf("unknown PR state blocked drafting: %#v", status) + } +} + +func TestResolveRecoveryRejectsStaleHeadEvidence(t *testing.T) { + repo := nextTestRepo(t) + branch, _ := gitCommand(repo, "branch", "--show-current") + writeNextDelivery(t, repo, "published", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "published", branch, "https://example.invalid/pr/1", "") + withRecoveryGh(t, recoveryPR("OPEN", branch, "current")) + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "CI failed", SourceStage: "ci", ObservedHeadSHA: "stale"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "BLOCKED" || status.NextOperation != "none" { + t.Fatalf("stale evidence was accepted: %#v", status) + } +} + +func TestResolveRecoveryBlocksAmbiguousPublishedHistory(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "first", "PUBLISHED", 1) + writeNextDelivery(t, repo, "second", "PUBLISHED", 1) + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "the PR failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "BLOCKED" || status.NextOperation != "resolve_ambiguity" || len(status.Blockers) != 2 { + t.Fatalf("ambiguous history was selected: %#v", status) + } +} + +func TestResolveRecoveryIncrementsOnlyLinkedCorrectionIDs(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "parent", "PUBLISHED", 1) + writeNextDelivery(t, repo, "parent-correction-01", "BUILD", 0) + updateRecoveryDelivery(t, repo, "parent-correction-01", "feat/other", "", "parent") + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "parent", Message: "another failure", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.SuggestedFeatureID != "parent-correction-02" { + t.Fatalf("unexpected correction id: %#v", status) + } +} + +func TestResolveRecoveryBlocksObservedPRBranchMismatch(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "published", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "published", "feat/expected", "https://example.invalid/pr/1", "") + withRecoveryGh(t, recoveryPR("OPEN", "feat/different", "head123")) + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "published", Message: "CI failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.VerificationStatus != "BLOCKED" || len(status.Blockers) != 2 { + t.Fatalf("PR branch mismatch was accepted: %#v", status) + } +} + +func TestResolveRecoveryFingerprintsExistingCommittedAndLocalCorrection(t *testing.T) { + repo := nextTestRepo(t) + runGit(t, repo, "config", "user.name", "Recovery Test") + runGit(t, repo, "config", "user.email", "recovery@example.invalid") + runGit(t, repo, "branch", "-M", "main") + if err := os.WriteFile(filepath.Join(repo, "app.txt"), []byte("published\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + writeNextDelivery(t, repo, "published", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "published", "main", "https://example.invalid/pr/1", "") + runGit(t, repo, "add", ".product-loop") + runGit(t, repo, "commit", "-m", "published evidence") + if err := os.WriteFile(filepath.Join(repo, "app.txt"), []byte("corrected\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "new-test.txt"), []byte("regression\n"), 0o644); err != nil { + t.Fatal(err) + } + withRecoveryGh(t, recoveryPR("OPEN", "main", "head123")) + + status, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Feature: "published", Message: "CI failed", SourceStage: "ci"}) + if err != nil { + t.Fatal(err) + } + if status.ExistingDiffSHA256 == "" || len(status.ExistingChangedPaths) != 2 || status.ExistingChangedPaths[0] != "app.txt" || status.ExistingChangedPaths[1] != "new-test.txt" { + t.Fatalf("existing correction was not fingerprinted: %#v", status) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md index 64d7f62d1..457b09d40 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md @@ -16,6 +16,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Delivery state | Ignored worktree-local Git active-slice state bound to the approved plan lock; never an approval artifact | Build activation and successful slice publication | | `changes.md` | Append-only, reviewable post-build observations with exact user message, expected/actual behavior, classification, evidence, and resolution | Controlled `record-change` transition | | Repair state | Ignored delivery mode, resume stage, active observation, attempt count, and superseded receipt references | Controlled repair and gate transitions | +| Recovery status | Read-only active/published delivery, PR lifecycle, branch/SHA identity, ambiguity, and safe next transition | Before responding to CI, review, publication denial, or ordinary correction language | | Gate receipt | Machine-local test or review transition bound to one delivery slice, base/head branches, commit, product diff, and evidence hash | A slice passes test or review | | Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes | | Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent | @@ -68,6 +69,10 @@ Every material statement should indicate whether it came from: Generated artifacts include the canonical loop version and config hash. Human edits to generated adapters are drift and should be moved into project-owned context or canonical source. +A completed parent's delivery state, plan lock, and receipts remain immutable. +Post-publication observations append to its `changes.md`; the linked corrective +child owns all new approval, lock, gate, and publication evidence. + ## PR projection boundary `pr.md` is a lossy review projection, not a replacement for the feature package. Its visible body contains only why, changed behavior, review order, evidence, gaps/risks, rollout, and rollback. Approval hashes, source paths, and host attribution remain in non-rendered metadata or collapsed provenance. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/failure-moves.md b/labs/12-product-engineering-loop/product-engineering-loop/references/failure-moves.md index aab769960..4156d6e7d 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/failure-moves.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/failure-moves.md @@ -19,6 +19,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules | | Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations | | Worktree bootstrap deadlock | A linked worktree inherits fail-closed hooks but not the ignored runtime required to evaluate them | Versioned Git-common runtime; atomic first-use hydration; provenance check | Cross-version execution or weakened failure behavior | +| Post-publication correction routing | CI, review, or a denied push targets work already marked published | Resolve branch and recorded PR identity; append the observation; draft an independently approved corrective child | Treating PR creation as completion or asking the user to bypass the guard | ## Lessons encoded from the benchmark campaign diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/host-hook-contracts.md b/labs/12-product-engineering-loop/product-engineering-loop/references/host-hook-contracts.md index e55065c46..9b43219d3 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/host-hook-contracts.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/host-hook-contracts.md @@ -26,3 +26,9 @@ ambiguous, or malformed events deny without echoing tool arguments. Deterministic schema, payload, decision, exit-code, and hydration fixtures block release. Live host checks are opt-in through `BOATSTACK_LIVE_HOST_TESTS=1` and report host availability separately from deterministic conformance. + +Publication denials carry only secret-free recovery context: blocking feature +and slice, branch relation, parent delivery, and the read-only next operation. +Every host receives the same instruction to preserve edits and enter managed +recovery. A host must never translate denial into a request that the user repeat +the push or PR mutation manually. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index ed212ea88..783e9ce3e 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -19,6 +19,9 @@ INTENT -> REVIEW_GATE -> SHIP_GATE -> PR_OPEN + -> PUBLISHED (open, closed, or remotely unverified PR) + -> CORRECTIVE_CHILD (when CI, review, or ordinary conversation reports a correction) + -> FEATURE_COMPLETE (only after verified merge) -> WORKSPACE_CLEANUP (when workspace management is on and the feature's PR has merged) -> RETRO ``` @@ -27,7 +30,9 @@ Each transition emits an artifact and evidence. A host adapter may change how a After build activation, persistent host adapters route ordinary change language through `REPAIR` before product edits. Same-intent implementation, verification, and review repairs resume at the earliest affected stage and supersede only downstream receipts. Changed or ambiguous intent enters `AMENDMENT_REQUIRED` and cannot pass a gate until a newly approved plan revision is activated. Existing `/test-gate` and `/review-gate` operations remain rerunnable; there are no repair-specific gates. -A published delivery cannot be reset. Its correction uses a new feature id and declares `parent_delivery` as the published feature, producing a separate plan lock, delivery state, receipts, and PR while preserving the original evidence. +A published delivery cannot be reset. Its correction uses a deterministic new feature id and declares `parent_delivery` as the published feature, producing a separate plan lock, delivery state, and receipts while preserving the original evidence. If the recorded PR is verified open and still owns the recorded head branch, the child updates that PR. Merged or closed work uses a fresh branch and PR; unknown PR state may be planned but cannot select a publication destination. + +`recovery-status` is the read-only resolver for CI failures, review findings, denied publication, and ordinary corrections. It selects by explicit feature, current active branch, current published branch, recorded PR identity, or one unambiguous candidate. It never chooses by recency. A stale reported head SHA, branch mismatch, or multiple match returns a blocker instead of drafting against the wrong delivery. The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. After build, its path and hash remain recorded for provenance, but `TEST_GATE`, `REVIEW_GATE`, and `SHIP_GATE` do not require the original file to be present. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index 2c98ce140..c7108920c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -3,6 +3,7 @@ package boatstack import ( "encoding/json" "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -14,9 +15,14 @@ import ( // SafetyFinding is intentionally small and secret-free. The guard reports the // class and a stable explanation, never the full command or tool arguments. type SafetyFinding struct { - Category string `json:"category"` - Reason string `json:"reason"` - Source string `json:"source,omitempty"` + Category string `json:"category"` + Reason string `json:"reason"` + Source string `json:"source,omitempty"` + BlockingFeature string `json:"blocking_feature,omitempty"` + BlockingSlice string `json:"blocking_slice,omitempty"` + BranchRelation string `json:"branch_relation,omitempty"` + NextOperation string `json:"next_operation,omitempty"` + ParentDelivery string `json:"parent_delivery,omitempty"` } type SafetyReport struct { @@ -65,6 +71,50 @@ var directPublicationPattern = regexp.MustCompile(`(?i)(?:\bgit\b[^\n;&|]*\bpush var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper\s+publish-pr\b[^\n;&|]*$`) var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\]deliveries|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`) +func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) { + active, err := ActiveManagedDeliveries(repo) + if err != nil { + return SafetyFinding{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}, true + } + if len(active) == 0 { + return SafetyFinding{}, false + } + branch, _ := gitCommand(repo, "branch", "--show-current") + selected := "" + relation := "unrelated" + for _, feature := range active { + state, loadErr := LoadDeliveryState(repo, feature) + if loadErr == nil && stateMatchesBranch(state, strings.TrimSpace(branch)) { + if selected != "" { + selected = strings.Join(active, ",") + relation = "ambiguous" + break + } + selected = feature + relation = "current_branch" + } + } + if selected == "" { + if len(active) == 1 { + selected = active[0] + } else { + selected = strings.Join(active, ",") + relation = "ambiguous" + } + } + finding := SafetyFinding{ + Category: "workflow-publication-bypass", Reason: reason, Source: source, + BlockingFeature: selected, BranchRelation: relation, NextOperation: "recovery-status", + } + if !strings.Contains(selected, ",") { + if state, loadErr := LoadDeliveryState(repo, selected); loadErr == nil { + _, finding.BlockingSlice, _ = deliveryBranchAndSlice(state) + finding.ParentDelivery = state.ParentDelivery + } + } + return finding, true +} + func classifySafetyText(value, source string) []SafetyFinding { if isPureReadOnlyCommand(value) { return nil @@ -193,12 +243,8 @@ func ClassifyCommand(repo, command string) []SafetyFinding { return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}} } if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) { - active, activeErr := ActiveManagedDeliveries(repo) - if activeErr != nil { - return []SafetyFinding{{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}} - } - if len(active) > 0 { - return []SafetyFinding{{Category: "workflow-publication-bypass", Reason: "direct push or PR mutation is denied while a managed delivery slice is active", Source: "tool-input"}} + if finding, blocked := publicationBypassFinding(repo, "direct push or PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked { + return []SafetyFinding{finding} } } findings := classifySafetyText(command, "command") @@ -254,11 +300,8 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { } if (strings.Contains(publicationText, "pull_request") || strings.Contains(publicationText, "pull request")) && regexp.MustCompile(`(?:create|update|edit|merge|publish)`).MatchString(publicationText) { - active, activeErr := ActiveManagedDeliveries(repo) - if activeErr != nil { - findings = append(findings, SafetyFinding{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}) - } else if len(active) > 0 { - findings = append(findings, SafetyFinding{Category: "workflow-publication-bypass", Reason: "direct PR mutation is denied while a managed delivery slice is active", Source: "tool-input"}) + if finding, blocked := publicationBypassFinding(repo, "direct PR mutation is denied while a managed delivery slice is active", "tool-input"); blocked { + findings = append(findings, finding) } } if regexp.MustCompile(`(?:delete|destroy|reset|drop|truncate|terminate)`).MatchString(nameLower) && regexp.MustCompile(`(?:database|schema|project|cluster|namespace|volume|bucket|backup|snapshot|instance)`).MatchString(strings.ToLower(combined)) { @@ -455,7 +498,33 @@ func denialMessage(host string, finding SafetyFinding) string { return "Boatstack denied direct delivery-state mutation. Use the active build, test, review, or ship transition instead of editing runtime authority." } if finding.Category == "workflow-publication-bypass" { - return "Boatstack denied a publication bypass. Finish the active slice's test and review gates, then use ship-gate and the confirmed Boatstack publisher." + target := "the active managed delivery" + if finding.BlockingFeature != "" { + target = fmt.Sprintf("managed delivery %q", finding.BlockingFeature) + } + relation := "" + if finding.BranchRelation == "unrelated" { + relation = " It is unrelated to the current branch." + } else if finding.BranchRelation == "ambiguous" { + relation = " More than one delivery may be blocking publication." + } + context := "" + if finding.BlockingSlice != "" { + context += " slice=" + finding.BlockingSlice + } + if finding.BranchRelation != "" { + context += " relation=" + finding.BranchRelation + } + if finding.ParentDelivery != "" { + context += " parent=" + finding.ParentDelivery + } + if finding.NextOperation != "" { + context += " next=" + finding.NextOperation + } + if context != "" { + context = " Recovery context:" + context + "." + } + return "Boatstack denied the publication bypass because " + target + " still owns publication authority." + relation + context + " Resolve the reported change through the managed recovery path; do not repeat this push or PR mutation manually." } return "Boatstack denied an irreversible operation (" + finding.Category + "). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow." } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go index 9fb10c655..9f78dad03 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace_test.go @@ -410,6 +410,7 @@ func TestResolveNextRoutesToWorkspaceCleanupAfterPublication(t *testing.T) { t.Fatal(err) } writeCompletedDelivery(t, repo, "shipped", "feat/shipped") + withRecoveryGh(t, recoveryPR("MERGED", "feat/shipped", "head")) status, err := ResolveNext(repo, "") if err != nil { t.Fatal(err) @@ -422,6 +423,7 @@ func TestResolveNextRoutesToWorkspaceCleanupAfterPublication(t *testing.T) { func TestResolveNextFeatureCompleteStaysNoneWithoutWorktree(t *testing.T) { repo := workspaceRepo(t, defaultWorkspace()) writeCompletedDelivery(t, repo, "shipped", "feat/no-worktree") + withRecoveryGh(t, recoveryPR("MERGED", "feat/no-worktree", "head")) status, err := ResolveNext(repo, "") if err != nil { t.Fatal(err) @@ -436,6 +438,7 @@ func TestResolveNextFeatureCompleteStaysNoneWhenWorkspaceDisabled(t *testing.T) // A worktree exists on disk, but management is off, so cleanup is not surfaced. workspaceGitDo(t, repo, "worktree", "add", "-b", "feat/manual", filepath.Join(repo, "wt-manual")) writeCompletedDelivery(t, repo, "shipped", "feat/manual") + withRecoveryGh(t, recoveryPR("MERGED", "feat/manual", "head")) status, err := ResolveNext(repo, "") if err != nil { t.Fatal(err)