From 117dc434b309229a5380433fd4ce3cac09084f83 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 28 Jul 2026 17:46:44 +0100 Subject: [PATCH] =?UTF-8?q?feat(boatstack):=20post-publish=20prescriptions?= =?UTF-8?q?=20=E2=80=94=20the=20frontier=20extends=20to=20merged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under delivery.terminal "merged", the flow keeps prescribing past publish from the live PR observation: checks pending -> flow watch (agent), checks failing -> the existing delivery.record_change transition with the derivable --source-stage ci filled and the failure facts owed from check logs (agent), merge eligible -> the exact `gh pr merge --squash` (agent, PRESCRIBE-ONLY). Review-required, changes-requested, closed, and unknown positions prescribe nothing and end at the operator frontier; a merged observation owes nobody anything. The published default is untouched. New published.* markers carry provenance; PrescribedCommand gains Program for the one honestly-foreign command, and canAutoDrive refuses any foreign-program command categorically — before the allowlist — so no future allowlist entry can make Boatstack run gh. The response contract renders the new agent steps through the existing delegation line. SKILL.md and workflow.md state the post-publish protocol. control-law: merged-terminal-prescribes-merge-never-executes-it control-law: turn-ends-only-at-the-operator-frontier Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../2026-07-28-post-publish-prescriptions.md | 5 + .../product-engineering-loop/SKILL.md | 4 +- .../product-engineering-loop/flow_control.go | 127 ++++++++++- .../product-engineering-loop/flow_drive.go | 9 +- ...post_publish_prescribe_conformance_test.go | 211 ++++++++++++++++++ .../references/workflow.md | 11 + 6 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-post-publish-prescriptions.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-post-publish-prescriptions.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-post-publish-prescriptions.md new file mode 100644 index 000000000..53ea036b5 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-post-publish-prescriptions.md @@ -0,0 +1,5 @@ +### With a merged goal, the flow now walks your PR from open to merged + +When `delivery.terminal` is `merged`, the flow advisors keep naming the next step after you publish, from the live pull-request observation: checks still running prescribes `flow watch`; failing checks prescribe the exact correction command with the failing check names attached; a merge-eligible PR (green checks, satisfied reviews, clean merge state) prescribes the exact `gh pr merge` command. Each of these is the agent's step, so a status reply hands it over with one key instead of assigning you the waiting and the fixing. + +Boatstack itself never merges: the merge command is prescribe-only, refused categorically by the execute driver, and runs only under your host's own permissions. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable position always comes back to you. With the default `published` goal, nothing changes at all. 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 2a66ff302..9c4ef693a 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -38,7 +38,9 @@ To see every feature at once, run the read-only `.product-loop/bin/boatstack-hel For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Before the first delivery-stage operation (`build`, `repair`, `test-gate`, `review-gate`, or `ship-gate`), run `run-preflight --repo . --json`. Planning and approval do not require a remote fetch. The preflight fetches `origin` and verifies the current named branch contains the fetched delivery base and is not behind or diverged from its upstream. A failed fetch, missing remote/base, stale base, upstream drift, or constrained branch mismatch blocks before delivery mutation. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. -After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice; the durable delivery attempt count does not reset across turns or hosts. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Persist execution facts and retry identity, never autonomous workflow intent; conversation is not workflow evidence. Completion means every slice PR is published for review, never merged or deployed. +After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice; the durable delivery attempt count does not reset across turns or hosts. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Persist execution facts and retry identity, never autonomous workflow intent; conversation is not workflow evidence. Completion means every slice PR is published for review, never merged or deployed — unless `delivery.terminal` is `merged`, in which case the flow continues past publication with prescribed read-only steps until the PR is observed merged (see the post-publish rules below). + +When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message and classification from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. ## Enforce the irreversible-operation boundary diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go index 887e63b5b..0f1093ad7 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_control.go @@ -97,6 +97,14 @@ const ( MarkerRecoveryDoctor = deliverycontrol.TransitionID("recovery.doctor") MarkerRecoveryDiscard = deliverycontrol.TransitionID("recovery.discard_delivery") MarkerRecoveryRepair = deliverycontrol.TransitionID("recovery.repair_state") + // Post-publish markers (merged terminal only). The delivery machine + // deliberately models nothing past PUBLISHED — merging is not a Boatstack + // verb and FEATURE_COMPLETE is entered by observation — so the post-publish + // steps are markers like the planning ones: self-describing provenance, + // never legal registry transitions, never on the auto-drive allowlist. + // control-law: merged-terminal-prescribes-merge-never-executes-it + MarkerPublishedWatch = deliverycontrol.TransitionID("published.watch_checks") + MarkerPublishedMerge = deliverycontrol.TransitionID("published.merge") ) // NextActor names who performs the prescribed next step. The operator owns a @@ -148,7 +156,22 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor { status.ObservedStage == "PUBLISHED" && status.Lifecycle == "PUBLISHED_MERGED": return NextActorNone case status.ObservedStage == "PUBLISHED": - // Reviewing the open pull request is the operator's act. + // Under the default published terminal, reviewing the open pull + // request is the operator's act — unchanged. Under the merged + // terminal, the frontier extends: the phases whose next step is + // work-derivable (watch running checks, fix failing checks from the + // check logs, run the prescribed merge of an eligible PR) are the + // agent's; every phase owing operator authority or knowledge — a + // review approval, a changes-requested verdict, a closed PR, an + // unknown position — stays the operator's. Fail-closed: the zero + // Terminal behaves as published. + // control-law: turn-ends-only-at-the-operator-frontier + if next.Terminal == TerminalMerged { + switch PRPhase(status.PRPhase) { + case PRPhaseChecksPending, PRPhaseChecksFailing, PRPhaseMergeEligible: + return NextActorAgent + } + } return NextActorOperator case next.Prescribed == nil: // Ambiguity and unprescribed blocks resolve only by operator choice. @@ -237,6 +260,14 @@ type PrescribedCommand struct { RequiresHumanInput []string `json:"requires_human_input,omitempty"` AutoDerivable bool `json:"auto_derivable"` Transition deliverycontrol.TransitionID `json:"transition"` + // Program names the executable when the prescribed step is honestly NOT a + // boatstack-helper verb (today: `gh`, for the operator-frontier merge). + // Empty means boatstack-helper, exactly as before. A foreign-program + // command is rendering-only by construction: canAutoDrive refuses it + // categorically and executePrescribed has no executor for it, so the + // execute driver can never run a program that is not the helper. + // control-law: merged-terminal-prescribes-merge-never-executes-it + Program string `json:"program,omitempty"` } // CommandLine renders the auto-derivable part of the prescribed command as a @@ -244,7 +275,11 @@ type PrescribedCommand struct { // placeholders so the rendering is never a fabricated, runnable-as-is command // when input is still owed. func (p PrescribedCommand) CommandLine() string { - parts := append([]string{"boatstack-helper", p.Verb}, p.Args...) + program := p.Program + if program == "" { + program = "boatstack-helper" + } + parts := append([]string{program, p.Verb}, p.Args...) for _, flag := range p.RequiresHumanInput { parts = append(parts, flag, "") } @@ -399,6 +434,84 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri } } +// prescribePostPublish closes the prescriptive loop past publish, but ONLY +// under the merged terminal: with the published default this function returns +// nothing and post-publish behavior is exactly what it always was. The +// delivery oracle is at its sink at PUBLISHED, so these prescriptions derive +// from the live PR observation instead of the registry graph: +// +// checks running -> flow watch (agent; read-only wait, exits on change) +// checks failing -> record-change --source-stage ci (agent; the failure +// facts are work-derivable from the failing check logs, +// so this branch's owed flags do not cross the frontier) +// merge eligible -> gh pr merge --squash (agent, PRESCRIBE-ONLY: +// Program!="" is categorically undrivable and the agent +// runs gh under its own authority, never Boatstack's) +// everything else -> nothing; approvals, changes-requested verdicts, closed +// PRs, and unknown positions are the operator's. +// +// FEATURE_COMPLETE and a merged lifecycle prescribe nothing: the goal is met. +// control-law: merged-terminal-prescribes-merge-never-executes-it +// control-law: prescriptive-closure-every-stage-names-a-runnable-command +func prescribePostPublish(repo string, status NextStatus, terminal DeliveryTerminal) (*PrescribedCommand, string) { + if terminal != TerminalMerged || status.ObservedStage != "PUBLISHED" || status.Lifecycle == "PUBLISHED_MERGED" { + return nil, "" + } + var repoArgs []string + if repo != "" && repo != "." { + repoArgs = []string{"--repo", repo} + } + switch PRPhase(status.PRPhase) { + case PRPhaseChecksPending: + cmd := &PrescribedCommand{ + Verb: "flow", + Args: append([]string{"watch"}, repoArgs...), + Transition: MarkerPublishedWatch, + } + cmd.AutoDerivable = true + return cmd, "When the watch exits, resolve the flow again and continue from the fresh state." + case PRPhaseChecksFailing: + if status.Feature == "" { + return nil, "" + } + desc, ok := deliverycontrol.Transition(deliverycontrol.TransitionID("delivery.record_change")) + if !ok || desc.CLIVerb == "" { + return nil, "" + } + // The registry transition IS the fix path — no new machinery. The + // source stage is derivable (this observation is the CI failure); the + // message and classification are owed, to be derived from the failing + // check logs, never fabricated. + cmd := &PrescribedCommand{Verb: desc.CLIVerb, Transition: deliverycontrol.TransitionID("delivery.record_change")} + cmd.Args = append(append([]string{}, repoArgs...), "--feature", status.Feature) + if status.ActiveSlice != "" { + cmd.Args = append(cmd.Args, "--slice", status.ActiveSlice) + } + cmd.Args = append(cmd.Args, "--source-stage", "ci") + cmd.RequiresHumanInput = []string{"--message", "--classification"} + followUp := "Read the failing check logs" + if len(status.PRFailingChecks) > 0 { + followUp += " (" + strings.Join(status.PRFailingChecks, ", ") + ")" + } + followUp += " to derive the exact message and classification; after the correction re-passes its gates, republish with publish-pr --action update." + return cmd, followUp + case PRPhaseMergeEligible: + if strings.TrimSpace(status.PRURL) == "" { + return nil, "" + } + cmd := &PrescribedCommand{ + Program: "gh", + Verb: "pr", + Args: []string{"merge", status.PRURL, "--squash"}, + Transition: MarkerPublishedMerge, + } + cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0 + return cmd, "Run it exactly as rendered — this merge is prescribed only from the live merge-eligible observation, never with --admin, and never for a different pull request." + default: + return nil, "" + } +} + // buildWorkspaceCut and buildActivatePlan are the single assembly points for // their commands, shared by prescribePlanning (the primary) and the solution-set // enumerator (the alternatives) so the two can never drift apart. @@ -484,6 +597,16 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { } } } + // Past publish the oracle sits at its sink and prescribes nothing; under + // the merged terminal the observation-derived post-publish layer takes + // over. It fills only an empty prescription — it can never override an + // oracle move. + if out.Prescribed == nil { + if cmd, followUp := prescribePostPublish(repo, status, out.Terminal); cmd != nil { + out.Prescribed = cmd + out.FollowUp = followUp + } + } out.Alternatives = alternativesFor(repo, status, out) out.Actor = classifyNextActor(status, out) return out, nil diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_drive.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_drive.go index 1c561177d..a4439b10b 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/flow_drive.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_drive.go @@ -53,11 +53,18 @@ type DriveDecision struct { // human input. Both conditions must hold: the command owes no human input // (AutoDerivable) AND its transition is on the allowlist. A derivable command off // the allowlist is not driven, and an allowlisted transition that still owes input -// is not driven. +// is not driven. A foreign-program command (Program != "", e.g. the prescribed +// `gh pr merge`) is refused CATEGORICALLY, before the allowlist is even +// consulted: the driver executes boatstack-helper verbs only, so no future +// allowlist entry can ever make Boatstack run someone else's program. +// control-law: merged-terminal-prescribes-merge-never-executes-it func canAutoDrive(cmd *PrescribedCommand, allowlist map[deliverycontrol.TransitionID]bool) bool { if cmd == nil || !cmd.AutoDerivable { return false } + if cmd.Program != "" { + return false + } return allowlist[cmd.Transition] } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go new file mode 100644 index 000000000..166f9323a --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/post_publish_prescribe_conformance_test.go @@ -0,0 +1,211 @@ +package boatstack + +// control-law: merged-terminal-prescribes-merge-never-executes-it +// control-law: turn-ends-only-at-the-operator-frontier (post-publish extension) +// +// Under delivery.terminal "merged", the flow keeps prescribing past publish — +// from the live PR observation only: watch running checks (agent), fix +// failing checks through the existing record-change transition (agent; the +// failure facts are work-derivable from check logs), and merge an eligible PR +// via a prescribed `gh pr merge` (agent, PRESCRIBE-ONLY). Boatstack itself +// can never execute the merge: the command carries a foreign Program, which +// canAutoDrive refuses categorically before the allowlist is consulted, its +// transition is a marker (never allowlisted), and executePrescribed has no +// executor. Every phase owing operator authority — review approval, changes +// requested, closed, unknown — prescribes nothing and ends at the operator +// frontier. Under the published default, nothing changes at all. +// +// Test classes: positive (per-phase prescription + actor under merged, +// through the real ResolveNext path), negative (published default: no +// post-publish prescription, operator actor — behavior preserved), bypass +// (the merge prescription cannot be driven even with a hostile allowlist; +// the driver decision is prescribe-and-stop), failure-state (unknown phase → +// operator, nothing prescribed; merged observation → none, nothing owed). + +import ( + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +func mergedTerminalRepo(t *testing.T) string { + t.Helper() + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + writeTerminalConfig(t, repo, "merged") + return repo +} + +// Positive: each observed phase maps to exactly one prescription and one +// actor under the merged terminal. +func TestMergedTerminalPrescribesPostPublishSteps(t *testing.T) { + for _, test := range []struct { + name string + payload func(string, ...string) (string, error) + wantActor NextActor + wantVerb string + wantProgram string + wantTransition string + wantInCommand string + wantOwed []string + }{ + { + name: "checks_pending_prescribes_watch", + payload: phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending), + wantActor: NextActorAgent, + wantVerb: "flow", + wantTransition: string(MarkerPublishedWatch), + wantInCommand: "boatstack-helper flow watch", + }, + { + name: "checks_failing_prescribes_ci_correction", + payload: phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail), + wantActor: NextActorAgent, + wantVerb: "record-change", + wantTransition: "delivery.record_change", + wantInCommand: "--source-stage ci", + wantOwed: []string{"--message", "--classification"}, + }, + { + name: "merge_eligible_prescribes_gh_merge", + payload: phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass), + wantActor: NextActorAgent, + wantVerb: "pr", + wantProgram: "gh", + wantTransition: string(MarkerPublishedMerge), + wantInCommand: "gh pr merge https://example.invalid/pr/9 --squash", + }, + } { + t.Run(test.name, func(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, test.payload) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != test.wantActor { + t.Fatalf("actor = %q, want %q (%#v)", next.Actor, test.wantActor, next) + } + if next.Prescribed == nil { + t.Fatalf("nothing prescribed: %#v", next) + } + if next.Prescribed.Verb != test.wantVerb || next.Prescribed.Program != test.wantProgram { + t.Fatalf("prescribed %q/%q, want %q/%q", next.Prescribed.Program, next.Prescribed.Verb, test.wantProgram, test.wantVerb) + } + if string(next.Prescribed.Transition) != test.wantTransition { + t.Fatalf("transition = %q, want %q", next.Prescribed.Transition, test.wantTransition) + } + if !strings.Contains(next.Prescribed.CommandLine(), test.wantInCommand) { + t.Fatalf("command %q does not contain %q", next.Prescribed.CommandLine(), test.wantInCommand) + } + if strings.Join(next.Prescribed.RequiresHumanInput, " ") != strings.Join(test.wantOwed, " ") { + t.Fatalf("owed inputs %v, want %v", next.Prescribed.RequiresHumanInput, test.wantOwed) + } + }) + } +} + +// Positive: the rendered response marks post-publish agent steps with the +// delegation line — the frontier extension reuses the one rendering path. +func TestResponseDelegatesPostPublishAgentSteps(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass)) + status, err := ResolveNext(repo, "shipped") + if err != nil { + t.Fatal(err) + } + rendered, err := RenderNextStatusResponse(repo, status) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(rendered, "This step is mine to do.") { + t.Fatalf("agent-owned merge step not delegated:\n%s", rendered) + } + if !strings.Contains(rendered, "gh pr merge https://example.invalid/pr/9 --squash") { + t.Fatalf("rendered response lacks the exact merge command:\n%s", rendered) + } +} + +// Negative: the operator-owed phases prescribe nothing and end the turn at +// the frontier, merged terminal or not. +func TestOperatorPhasesPrescribeNothing(t *testing.T) { + for _, test := range []struct { + name string + payload func(string, ...string) (string, error) + }{ + {"review_required", phaseObservationPayload("OPEN", "REVIEW_REQUIRED", "BLOCKED", rollupCheckRunPass)}, + {"changes_requested", phaseObservationPayload("OPEN", "CHANGES_REQUESTED", "CLEAN", rollupCheckRunFail)}, + {"closed_pr", phaseObservationPayload("CLOSED", "", "", "")}, + {"unknown_phase", phaseObservationPayload("OPEN", "APPROVED", "DIRTY", rollupCheckRunPass)}, + } { + t.Run(test.name, func(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, test.payload) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator { + t.Fatalf("actor = %q, want operator", next.Actor) + } + if next.Prescribed != nil { + t.Fatalf("operator phase must not carry a prescription: %#v", next.Prescribed) + } + }) + } +} + +// Negative: under the published default nothing is prescribed post-publish +// and the actor stays operator — the frontier does not move without the +// explicit setpoint. +func TestPublishedDefaultKeepsPostPublishBehavior(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorOperator || next.Prescribed != nil { + t.Fatalf("published default drifted: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } +} + +// Bypass: the prescribed merge can NEVER be executed by Boatstack — not by +// the pure drive decision, not with a hostile allowlist naming its marker, +// and not by the CLI executor. +func TestMergePrescriptionIsCategoricallyUndrivable(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, phaseObservationPayload("OPEN", "APPROVED", "CLEAN", rollupCheckRunPass)) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Prescribed == nil || next.Prescribed.Program != "gh" { + t.Fatalf("fixture did not produce the merge prescription: %#v", next.Prescribed) + } + decision := DecideDrive(next, true, false) + if decision.Action != DrivePrescribe { + t.Fatalf("drive decision = %q, want prescribe-and-stop", decision.Action) + } + hostileAllowlist := map[deliverycontrol.TransitionID]bool{MarkerPublishedMerge: true} + if canAutoDrive(next.Prescribed, hostileAllowlist) { + t.Fatal("a foreign-program command passed canAutoDrive despite the categorical refusal") + } +} + +// Failure-state: a merged observation owes nobody anything. +func TestMergedObservationEndsTheFlow(t *testing.T) { + repo := mergedTerminalRepo(t) + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + next, err := NextControl(repo, "shipped") + if err != nil { + t.Fatal(err) + } + if next.Actor != NextActorNone || next.Prescribed != nil { + t.Fatalf("merged flow still owes something: actor=%q prescribed=%#v", next.Actor, next.Prescribed) + } +} 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 042d202a1..6ea0bfc73 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 @@ -487,6 +487,17 @@ When `workspace.enabled` is set and an approved feature is still on the default When `workspace.enabled` is set, `boatstack-next` surfaces `workspace-cleanup` for a published feature whose managed worktree still exists locally. The `workspace-cleanup` operation checks the pull request's merge state (GitHub CLI, falling back to local ancestry) and reports it. When `workspace.cleanup_after` is `merge`, cleanup is offered only once the PR is confirmed merged; while it is still open, the workspace is kept and the human may keep waiting or override explicitly. Cleanup never removes a workspace with uncommitted or unmerged work without an explicit forced override, and it reclaims only the local worktree and branch — it never deletes a remote branch or merges anything. In `confirm` mode the human reclaims the workspace with the exact reply `c` (or keeps it with `k`); `auto` mode reclaims a merged workspace without a prompt; `off` disables cleanup. A fresh feature workspace is likewise cut from the up-to-date default branch when a new feature begins, so work never starts on a stale branch. +### `PR_OPEN -> MERGED` (only when `delivery.terminal` is `merged`) + +With the default `published` terminal, the flow ends at an open PR, exactly as before. With `delivery.terminal: merged`, the read-only advisors keep prescribing until the PR is observed merged, from the live PR observation (never from anyone's claim): + +- Checks running: the advisor prescribes `flow watch` (agent). The watch exits on change; resolve again. +- Checks failing: the advisor prescribes `record-change --source-stage ci` (agent). The failing check names ride along; the exact message and classification are derived from the check logs, then the correction re-passes its gates and republishes with `publish-pr --action update`. +- Merge eligible (checks green, reviews satisfied, clean merge state): the advisor prescribes the exact `gh pr merge --squash` command (agent). This is prescribe-only: the command carries a foreign program, which the execute driver refuses categorically, so Boatstack can never merge — the agent runs `gh` under the host's own authority, and only as rendered. +- Review required, changes requested, PR closed, or an unverifiable position: nothing is prescribed; the step is the operator's. + +The merged observation ends the flow (`FEATURE_COMPLETE`), which is also the workspace-cleanup/reap checkpoint. + ### `PR_OPEN -> WATCH` A published pull request changes asynchronously: checks finish, reviews land, merges happen. `flow watch` is the bounded waiting primitive for that interval. It re-observes the read-only frontier on an interval and exits when a row's position or owner changes, when nothing on the frontier can move, or when its timeout passes (distinct exit code). It performs no writes and executes no operation — observation and actuation stay separate, so waiting can never become acting. When the watch exits, resolve `next-status` again and continue from the fresh state.