Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`; 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -237,14 +260,26 @@ 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
// runnable string. Human-required flags are appended as explicit <REQUIRED>
// 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, "<REQUIRED>")
}
Expand Down Expand Up @@ -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 <url> --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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}

Expand Down
Loading
Loading