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
Expand Up @@ -15,6 +15,7 @@ boatstack-user-config-field:workflow.visual_evidence_publish.mode
boatstack-user-config-field:workflow.visual_evidence_publish.host
boatstack-user-config-field:workflow.visual_evidence_publish.expiry
boatstack-user-config-field:workflow.ignored_deliveries
boatstack-user-config-field:delivery.terminal
boatstack-user-config-field:workspace.enabled
boatstack-user-config-field:workspace.mode
boatstack-user-config-field:workspace.cleanup
Expand All @@ -41,6 +42,7 @@ Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit on
| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. |
| Render screenshots inline on a private PR | `workflow.visual_evidence_publish.*` | `mode: external-host` uploads the captured PNGs to an anonymous expiring host so the comment renders inline even on a private repo; opt-in, never automatic. |
| Ignore old ambiguous deliveries | `workflow.ignored_deliveries` | Listed feature slugs are excluded from delivery-ambiguity resolution so past work stops blocking new work; new, unlisted ambiguous deliveries still pause. |
| Pursue the PR to merge, not just to open | `delivery.terminal` | `merged` keeps the read-only flow advisors naming post-publish steps (watch checks, route corrections) until the PR is observed merged; the default `published` ends the flow when the PR is open, exactly as before. |
| Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. |
| Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. |

Expand Down Expand Up @@ -143,6 +145,18 @@ List feature slugs here to drop past deliveries from the ambiguity check so hist

Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. `reap` is `confirm`, `auto`, or `off`: when a delivery's PR is confirmed merged, Boatstack sweeps every terminal (merged or abandoned) Boatstack workspace at once — `confirm` asks the operator once before reclaiming them, `auto` reclaims without asking, and `off` disables the sweep. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces.

## Delivery goal

```json
{
"delivery": {
"terminal": "merged"
}
}
```

`delivery.terminal` names the state a delivery pursues before the flow reports nothing left to do. The default `published` ends the flow when the slice's pull request is open, exactly as before. `merged` keeps the read-only flow advisors (`next-status`, `flow next`, `flow frontier`, `flow watch`) naming post-publish steps — watch the checks, route a correction, surface merge eligibility — until the pull request is observed merged. The goal a delivery starts under is snapshotted with the delivery, so changing this value never changes an in-progress delivery's goal. Boatstack itself never merges a pull request under any setting.

## Installer-owned fields

The installer maintains `schema_version`, `project.name`, and integration records. Select gstack or Spec Kit through installation and update flows. Their `requested`, `status`, `version`, and `detail` values are receipts and provenance, not hand-edited workflow switches.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### You can now tell Boatstack the goal is a merged PR, not just an open one

A new `delivery.terminal` setting names the state a delivery pursues before the flow reports nothing left to do. The default, `published`, keeps today's behavior exactly: the flow ends when your pull request is open. Setting `merged` tells the read-only flow advisors to keep reporting the standing goal until the pull request is observed merged; the prescribed post-publish steps arrive in the next update.

The goal a delivery starts under is saved with that delivery, so changing the setting mid-flight never silently changes an in-progress delivery's goal, and a fresh session hydrates the goal from your repository instead of you restating it. Invalid or unreadable values always resolve to the narrower `published` goal, and Boatstack itself never merges a pull request under any setting.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### You can now wait for a pull request to move without polling it yourself

`flow watch` observes your delivery frontier on an interval and exits the moment something changes: checks finish or fail, a review lands, a merge happens. It also exits immediately when nothing can move, and with a distinct exit code when its timeout passes with no change, so a script or an agent loop can tell "something happened" from "still waiting". Defaults are a 30-second interval and a 30-minute timeout, both adjustable.

The watch only observes: it performs no writes and never runs an operation on your behalf. When it exits, run `next-status` and continue from the fresh state. Before this, waiting on CI meant either re-running status by hand or asking your agent to poll GitHub in prose.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ For the full state machine, read [workflow.md](references/workflow.md). For arti

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` points to `auto-plan` (run it with the plan path via `--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. When an `AMBIGUOUS` block names only past deliveries the user no longer cares about, name the ignorable delivery slug(s) and offer to exclude them from ambiguity resolution; only after explicit user confirmation, add each slug with `.product-loop/bin/boatstack-helper ignore-delivery --repo . --feature <slug>` (a bounded, provenance-safe write to `workflow.ignored_deliveries` — never hand-edit config or delivery state). Any new, unlisted ambiguous delivery still pauses the workflow.

To see every feature at once, run the read-only `.product-loop/bin/boatstack-helper flow frontier --repo .`. It lists each delivery, its observed position, and who owes the next step. To wait for a published PR to move (checks finish, a review lands, a merge happens), run the read-only `.product-loop/bin/boatstack-helper flow watch --repo .`. The watch observes on an interval and exits when the frontier changes, when nothing can move, or at its timeout. It never acts on what it sees. When it exits, run `next-status` again and continue from the fresh state.

## Run through ship

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"time"

boatstack "github.com/operatorstack/boatstack/boatstack"
)
Expand All @@ -14,7 +15,7 @@ import (
// gate, authority, or exit code.
func flowCommand(arguments []string) int {
if len(arguments) == 0 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow <check|next|tasks|frontier|report>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow <check|next|tasks|frontier|watch|report>")
return 2
}
switch arguments[0] {
Expand All @@ -26,6 +27,8 @@ func flowCommand(arguments []string) int {
return flowTasksCommand(arguments[1:])
case "frontier":
return flowFrontierCommand(arguments[1:])
case "watch":
return flowWatchCommand(arguments[1:])
case "report":
return flowReportCommand(arguments[1:])
default:
Expand Down Expand Up @@ -177,6 +180,41 @@ func flowFrontierCommand(arguments []string) int {
return 0
}

// flowWatchCommand runs the bounded observe-compare loop: re-observe the
// frontier on an interval, exit 0 the moment it changes (or when nothing can
// move), exit 1 when the timeout passes with no change. It observes and
// exits; it never acts on what it sees.
// control-law: watch-observes-and-exits-never-acts
func flowWatchCommand(arguments []string) int {
flags := flag.NewFlagSet("flow watch", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose delivery frontier should be watched")
interval := flags.Duration("interval", 30*time.Second, "time between frontier observations")
timeout := flags.Duration("timeout", 30*time.Minute, "maximum time to wait for a frontier change")
jsonOutput := flags.Bool("json", false, "print the structured watch result")
if err := flags.Parse(arguments); err != nil {
return 2
}
result, err := boatstack.WatchFrontier(boatstack.FlowWatchOptions{
Repo: *repo, Interval: *interval, Timeout: *timeout,
})
if err != nil {
return fail(err)
}
if *jsonOutput {
value, marshalErr := boatstack.MarshalJSON(result)
if marshalErr != nil {
return fail(marshalErr)
}
fmt.Print(string(value))
} else {
fmt.Print(boatstack.FormatFlowWatch(result))
}
if result.Outcome == boatstack.WatchOutcomeTimeout {
return 1
}
return 0
}

// flowTasksCommand renders the active delivery slice's sub-actions from the
// compiled plan task DAG, in dependency order, with the one to start pointed at.
// It is read-only and never fails on flow position — an unresolved slice or an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ func TestSerializedConfigurationSurfaceIsDocumentedInternally(t *testing.T) {
func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) {
want := []string{
"adapters",
"delivery.terminal",
"project.commands",
"project.context",
"project.default_branch",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ type DeliveryState struct {
RepairAttempt int `json:"repair_attempt,omitempty"`
SupersededReceipts []string `json:"superseded_receipts,omitempty"`
ParentDelivery string `json:"parent_delivery,omitempty"`
// Goal snapshots the non-default delivery terminal ("merged") this
// delivery was activated under, so a mid-flight config change never
// silently changes an in-progress delivery's goal. Empty means: resolve
// from config at read time (and keeps a default-config state file
// byte-identical to the pre-field format).
// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
Goal string `json:"goal,omitempty"`
}

type DeliveryGateReceipt struct {
Expand Down Expand Up @@ -377,6 +384,7 @@ func initializeDeliveryState(repo, feature, planPath, lockPath string) error {
SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash,
ActiveIndex: 0, Slices: slices, Mode: "NORMAL",
ParentDelivery: strings.TrimSpace(stringValue(plan["parent_delivery"])),
Goal: deliveryGoalSnapshot(repo),
})
}

Expand Down Expand Up @@ -485,6 +493,7 @@ func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliveryS
Slices: merged,
Mode: "NORMAL",
ParentDelivery: existing.ParentDelivery,
Goal: existing.Goal,
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package boatstack

import "strings"

// The delivery terminal is the standing goal of the flow — the state past
// which nothing more is owed. It resolves in a fixed order: the goal the
// delivery was ACTIVATED under (state.Goal — hysteresis, so a mid-flight
// config change never silently changes an in-progress delivery's goal), then
// the repository config (delivery.terminal), then the published default.
// Every unreadable or invalid input resolves to the narrower published goal:
// a goal is widened only by an explicit, verifiable operator choice.
// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
type DeliveryTerminal string

const (
// TerminalPublished — the flow is done when the slice's PR is open.
TerminalPublished DeliveryTerminal = "published"
// TerminalMerged — the flow keeps naming read-only post-publish steps
// until the PR is observed merged.
TerminalMerged DeliveryTerminal = "merged"
)

func normalizeDeliveryTerminal(value string) (DeliveryTerminal, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case string(TerminalPublished):
return TerminalPublished, true
case string(TerminalMerged):
return TerminalMerged, true
default:
return "", false
}
}

// configuredDeliveryTerminal reads the repository's standing terminal from
// the project config. Absent, invalid, or unreadable configuration resolves
// to published — never an error, because the terminal is consulted from
// read-only paths that must not gain a new failure mode.
func configuredDeliveryTerminal(repo string) DeliveryTerminal {
config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
if err != nil || config.Delivery == nil {
return TerminalPublished
}
if terminal, ok := normalizeDeliveryTerminal(config.Delivery.Terminal); ok {
return terminal
}
return TerminalPublished
}

// resolveDeliveryTerminal resolves the terminal for one feature: the
// activation snapshot first, then config, then the default.
func resolveDeliveryTerminal(repo, feature string) DeliveryTerminal {
if strings.TrimSpace(feature) != "" {
if state, err := LoadDeliveryState(repo, feature); err == nil {
if terminal, ok := normalizeDeliveryTerminal(state.Goal); ok {
return terminal
}
}
}
return configuredDeliveryTerminal(repo)
}

// deliveryGoalSnapshot is what activation records on the new delivery state.
// Only the non-default goal is snapshotted: a default-config delivery keeps
// an empty Goal, so its persisted state is byte-identical to before this
// field existed.
func deliveryGoalSnapshot(repo string) string {
if configuredDeliveryTerminal(repo) == TerminalMerged {
return string(TerminalMerged)
}
return ""
}
Loading
Loading