diff --git a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md index dd4f4d182..1b12b043d 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md @@ -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 @@ -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. | @@ -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. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-configurable-delivery-terminal.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-configurable-delivery-terminal.md new file mode 100644 index 000000000..6970713bb --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-configurable-delivery-terminal.md @@ -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. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-watch-loop.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-watch-loop.md new file mode 100644 index 000000000..90a73bb7c --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-28-flow-watch-loop.md @@ -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. 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 87bbe5b1b..2a66ff302 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -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 ` (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 `; 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. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go index 7b5874cbf..f0eb6733c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/flow.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "time" boatstack "github.com/operatorstack/boatstack/boatstack" ) @@ -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 ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") return 2 } switch arguments[0] { @@ -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: @@ -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 diff --git a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go index 3ea7db12a..322b18510 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go @@ -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", 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 c8027788d..dde618dfa 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -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 { @@ -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), }) } @@ -485,6 +493,7 @@ func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliveryS Slices: merged, Mode: "NORMAL", ParentDelivery: existing.ParentDelivery, + Goal: existing.Goal, } } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal.go new file mode 100644 index 000000000..7fb2ac3fb --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal.go @@ -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 "" +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal_conformance_test.go new file mode 100644 index 000000000..49a7f0ca7 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery_terminal_conformance_test.go @@ -0,0 +1,215 @@ +package boatstack + +// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config +// +// The delivery terminal — the standing goal of the flow — resolves in a fixed +// order: the goal the delivery was ACTIVATED under (state.Goal), then the +// repository config (delivery.terminal), then the published default. The +// default is a hard no-op: with no delivery block (or an explicit +// "published"), every advisory output is identical to the pre-field +// behavior, because a goal this standing is widened only by an explicit +// operator choice, never by an upgrade. Invalid and unreadable inputs +// resolve to the NARROWER published goal (fail-closed direction: the wider +// goal implies more agent-owned steps). +// +// Test classes: positive (config merged → Terminal merged; activation +// snapshots the non-default goal), relation (state.Goal overrides config both +// ways — hysteresis), negative (invalid config value fails validation; +// invalid state.Goal is ignored), bypass (default vs explicit published → +// byte-identical rendering and JSON across the slice lifecycle), failure-state +// (a pre-field state file without goal loads clean and resolves from config). + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTerminalConfig(t *testing.T, repo, terminal string) { + t.Helper() + config := testConfig() + if terminal != "" { + config.Delivery = &DeliveryPolicy{Terminal: terminal} + } + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } +} + +// Positive: the configured terminal surfaces on the advisory, and only the +// widened goal earns a rendered line. +func TestConfiguredTerminalSurfacesOnAdvisory(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + writeTerminalConfig(t, repo, "merged") + + next, err := NextControl(repo, "feature") + if err != nil { + t.Fatal(err) + } + if next.Terminal != TerminalMerged { + t.Fatalf("terminal = %q, want merged", next.Terminal) + } + if !strings.Contains(FormatFlowNext(next), "Terminal goal: merged") { + t.Fatal("widened goal must be visible in the rendering") + } +} + +// Relation: the activation snapshot outranks config in BOTH directions — a +// delivery keeps the goal it was started under when config flips mid-flight. +func TestActivationSnapshotOverridesConfig(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + + // Delivery activated under merged; config later narrowed to published. + state, err := LoadDeliveryState(repo, "feature") + if err != nil { + t.Fatal(err) + } + state.Goal = string(TerminalMerged) + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "published") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("mid-flight narrowing changed the goal: %q", got) + } + + // Delivery activated under the default; config later widened to merged. + // The empty snapshot means "resolve from config", so the widening applies. + state.Goal = "" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "merged") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("config terminal not hydrated: %q", got) + } + + // An invalid snapshot value is ignored, never trusted. + state.Goal = "deployed" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + writeTerminalConfig(t, repo, "") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalPublished { + t.Fatalf("invalid snapshot must resolve to published: %q", got) + } +} + +// Positive: first activation snapshots the non-default goal onto the new +// delivery state; the default snapshots nothing (byte-stable state files). +func TestActivationSnapshotsNonDefaultGoalOnly(t *testing.T) { + for _, test := range []struct { + terminal string + wantGoal string + }{ + {"merged", "merged"}, + {"published", ""}, + {"", ""}, + } { + repo := nextTestRepo(t) + writeTerminalConfig(t, repo, test.terminal) + if got := deliveryGoalSnapshot(repo); got != test.wantGoal { + t.Fatalf("terminal %q: snapshot = %q, want %q", test.terminal, got, test.wantGoal) + } + } +} + +// Negative: an explicit invalid enum fails config validation fail-closed. +func TestInvalidTerminalRejectedByValidation(t *testing.T) { + config := testConfig() + config.Delivery = &DeliveryPolicy{Terminal: "deployed"} + if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "delivery.terminal") { + t.Fatalf("invalid terminal accepted: %v", err) + } + config.Delivery = &DeliveryPolicy{} + if err := ValidateConfig(config); err != nil { + t.Fatalf("empty terminal must stay legal: %v", err) + } +} + +// Bypass: the default is a hard no-op — for every slice-lifecycle stage, the +// advisory under an absent delivery block is byte-identical (JSON and +// rendering) to an explicit published terminal, and carries no merged +// wording anywhere. +func TestDefaultTerminalIsByteIdenticalToExplicitPublished(t *testing.T) { + for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED", "PUBLISHED"} { + capture := func(terminal string) (string, string) { + repo := nextTestRepo(t) + activeIndex := 0 + if stage == "PUBLISHED" { + activeIndex = 1 + } + writeNextDelivery(t, repo, "feature", stage, activeIndex) + writeTerminalConfig(t, repo, terminal) + if stage == "PUBLISHED" { + updateRecoveryDelivery(t, repo, "feature", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPass)) + } + next, err := NextControl(repo, "feature") + if err != nil { + t.Fatal(err) + } + // The repo path differs per fixture; blank it out of the compared + // values so only behavior is compared. JSON escapes Windows path + // separators, so the escaped form must be blanked too. + value, err := MarshalJSON(next) + if err != nil { + t.Fatal(err) + } + escaped, err := json.Marshal(repo) + if err != nil { + t.Fatal(err) + } + blank := func(s string) string { + s = strings.ReplaceAll(s, strings.Trim(string(escaped), `"`), "") + return strings.ReplaceAll(s, repo, "") + } + return blank(string(value)), blank(FormatFlowNext(next)) + } + defaultJSON, defaultText := capture("") + publishedJSON, publishedText := capture("published") + if defaultJSON != publishedJSON { + t.Fatalf("stage %s: default and explicit published diverge:\n%s\n---\n%s", stage, defaultJSON, publishedJSON) + } + if defaultText != publishedText { + t.Fatalf("stage %s: rendering diverges:\n%s\n---\n%s", stage, defaultText, publishedText) + } + if strings.Contains(defaultText, "merged (delivery.terminal)") { + t.Fatalf("stage %s: default rendering mentions the widened goal:\n%s", stage, defaultText) + } + } +} + +// Failure-state: a pre-field state file (no goal key) loads clean and +// resolves from config — the migration law is untouched by the additive +// field. +func TestPreFieldStateResolvesFromConfig(t *testing.T) { + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "feature", "BUILD", 0) + statePath, err := deliveryStatePath(repo, "feature") + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "\"goal\"") { + t.Fatal("fixture unexpectedly contains a goal key") + } + writeTerminalConfig(t, repo, "merged") + if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged { + t.Fatalf("pre-field state did not hydrate from config: %q", got) + } + if _, err := LoadDeliveryState(repo, "feature"); err != nil { + t.Fatalf("pre-field state failed to load: %v", err) + } +} 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 e9ebdfa29..d3a7baa74 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -128,6 +128,9 @@ func ValidateConfig(config ProjectConfig) error { if err := validateWorkspaceConfig(config.Workspace); err != nil { return err } + if err := validateDeliveryConfig(config.Delivery); err != nil { + return err + } if policy := strings.TrimSpace(config.Workflow.PRVisualEvidence); policy != "" && policy != "off" && policy != "suggest" && policy != "require" { return fmt.Errorf("workflow.pr_visual_evidence must be \"off\", \"suggest\", or \"require\"") } @@ -137,6 +140,18 @@ func ValidateConfig(config ProjectConfig) error { return nil } +// validateDeliveryConfig rejects only explicit invalid enum values. A nil +// block or empty terminal resolves to the published default at use. +func validateDeliveryConfig(delivery *DeliveryPolicy) error { + if delivery == nil { + return nil + } + if terminal := delivery.Terminal; terminal != "" && terminal != "published" && terminal != "merged" { + return fmt.Errorf("delivery.terminal must be \"published\" or \"merged\"") + } + return nil +} + // validateVisualEvidencePublish rejects only explicit invalid enum values. A nil // block or empty fields are legal and resolve to defaults at use, so configs written // before this block existed remain valid. 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 6d39eaf61..887e63b5b 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 @@ -173,9 +173,17 @@ func classifyNextActor(status NextStatus, next FlowNext) NextActor { // authority. Resolved is false when the oracle cannot place the flow, in which // case only the real recommendation is meaningful. type FlowNext struct { - Resolved bool `json:"resolved"` - State deliverycontrol.StateID `json:"state,omitempty"` - Goal deliverycontrol.StateID `json:"goal"` + Resolved bool `json:"resolved"` + State deliverycontrol.StateID `json:"state,omitempty"` + Goal deliverycontrol.StateID `json:"goal"` + // Terminal is the standing goal of this delivery ("published" or + // "merged"), resolved state-then-config-then-default. Goal above remains + // the ORACLE's sink (always StatePublished — the delivery machine has no + // modeled transition past it); Terminal is the operator-facing setpoint + // that decides whether anything is still owed after publish. In this + // slice it is surfaced only; post-publish prescriptions follow. + // control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config + Terminal DeliveryTerminal `json:"terminal"` RecommendedOp string `json:"recommended_operation"` OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"` RemainingCost int `json:"remaining_flow_cost"` @@ -432,6 +440,7 @@ func NextControl(repo, feature string) (FlowNext, error) { func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { out := FlowNext{ Goal: flowGoal, + Terminal: resolveDeliveryTerminal(repo, status.Feature), RecommendedOp: status.NextOperation, Reason: status.Reason, } @@ -493,6 +502,11 @@ func FormatFlowNext(next FlowNext) string { if next.Actor != "" { fmt.Fprintf(&b, "Next actor: %s\n", next.Actor) } + // The published default renders exactly as before; only the widened goal + // earns a line, so opting in is visible and not opting in changes nothing. + if next.Terminal == TerminalMerged { + fmt.Fprintf(&b, "Terminal goal: merged (delivery.terminal)\n") + } if next.Resolved { fmt.Fprintf(&b, "Flow state: %s -> goal %s\n", next.State, next.Goal) fmt.Fprintf(&b, "Advisory (flow oracle): next %s, remaining cost %d\n", next.OracleNext, next.RemainingCost) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go new file mode 100644 index 000000000..df86bcafd --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch.go @@ -0,0 +1,169 @@ +package boatstack + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// `flow watch` is the bounded waiting primitive for the asynchronous world a +// published PR lives in (CI runs, reviews land, merges happen). Each tick it +// re-runs the same read-only frontier observation and compares a stable +// signature of every row; it EXITS on the first change, on an all-terminal +// frontier, or at the deadline — it never acts on what it sees. Boatstack +// stays a synchronous oracle: the loop here only decides when to ask the +// oracle again, and hands control back the moment the answer differs. No +// daemon, no writes, no transition execution path is reachable from it. +// control-law: watch-observes-and-exits-never-acts +const flowWatchSchemaVersion = 1 + +const ( + WatchOutcomeChanged = "changed" + WatchOutcomeTerminal = "terminal" + WatchOutcomeTimeout = "timeout" +) + +// Seams for tests: the watcher must be provable without real waiting. +var ( + flowWatchNow = time.Now + flowWatchSleep = time.Sleep +) + +type FlowWatchOptions struct { + Repo string + Interval time.Duration + Timeout time.Duration +} + +// FlowWatchResult reports why the watch loop returned and what it saw. Final +// always carries the last observed frontier so the caller re-orients without +// another resolution. +type FlowWatchResult struct { + SchemaVersion int `json:"schema_version"` + Outcome string `json:"outcome"` + Ticks int `json:"ticks"` + ChangedRows []string `json:"changed_rows,omitempty"` + Final FlowFrontier `json:"final"` +} + +const ( + defaultWatchInterval = 30 * time.Second + defaultWatchTimeout = 30 * time.Minute + // minimumWatchInterval keeps a mistyped interval from hammering GitHub. + minimumWatchInterval = 5 * time.Second +) + +// WatchFrontier runs the bounded observe-compare loop. It returns an error +// only for the faults ResolveFrontier itself refuses (unreadable store, +// invalid config); a failing gh observation degrades each row to an Unknown +// phase — a signature like any other — and the loop stays bounded. +func WatchFrontier(options FlowWatchOptions) (FlowWatchResult, error) { + interval := options.Interval + if interval <= 0 { + interval = defaultWatchInterval + } + if interval < minimumWatchInterval { + interval = minimumWatchInterval + } + timeout := options.Timeout + if timeout <= 0 { + timeout = defaultWatchTimeout + } + + result := FlowWatchResult{SchemaVersion: flowWatchSchemaVersion} + initial, err := ResolveFrontier(options.Repo) + if err != nil { + return result, err + } + result.Final = initial + if frontierAllTerminal(initial) { + result.Outcome = WatchOutcomeTerminal + return result, nil + } + baseline := frontierSignatures(initial) + deadline := flowWatchNow().Add(timeout) + for { + if !flowWatchNow().Before(deadline) { + result.Outcome = WatchOutcomeTimeout + return result, nil + } + flowWatchSleep(interval) + result.Ticks++ + current, err := ResolveFrontier(options.Repo) + if err != nil { + return result, err + } + result.Final = current + signatures := frontierSignatures(current) + if changed := signatureDiff(baseline, signatures); len(changed) > 0 { + result.Outcome = WatchOutcomeChanged + result.ChangedRows = changed + return result, nil + } + } +} + +// frontierAllTerminal reports whether nothing on the frontier can move: no +// rows at all, or every row terminal. Blocked and operator rows are NOT +// terminal — external state (a review, a merge, a fix landing elsewhere) can +// change them, which is exactly what a watcher waits for. +func frontierAllTerminal(frontier FlowFrontier) bool { + if !frontier.Initialized || len(frontier.Rows) == 0 { + return true + } + for _, row := range frontier.Rows { + if row.Actor != string(NextActorNone) { + return false + } + } + return true +} + +// frontierSignatures reduces each row to the stable facts a caller would act +// on: position, actor, lifecycle, and the failing-check set. Reasons and +// prescribed command text are deliberately excluded — wording changes are not +// frontier changes. +func frontierSignatures(frontier FlowFrontier) map[string]string { + signatures := map[string]string{} + for _, row := range frontier.Rows { + key := row.Feature + "/" + row.Slice + signatures[key] = strings.Join([]string{ + row.Stage, row.Lifecycle, row.PRPhase, row.Actor, row.NextOperation, + fmt.Sprintf("blocked=%t", row.Blocked), + strings.Join(row.PRFailingChecks, "|"), + }, "·") + } + return signatures +} + +func signatureDiff(before, after map[string]string) []string { + changed := []string{} + for key, value := range after { + if previous, ok := before[key]; !ok || previous != value { + changed = append(changed, key) + } + } + for key := range before { + if _, ok := after[key]; !ok { + changed = append(changed, key+" (gone)") + } + } + sort.Strings(changed) + return changed +} + +// FormatFlowWatch renders the watch outcome and the final frontier. +func FormatFlowWatch(result FlowWatchResult) string { + var b strings.Builder + switch result.Outcome { + case WatchOutcomeChanged: + fmt.Fprintf(&b, "Watch: frontier changed after %d tick(s): %s\n", result.Ticks, strings.Join(result.ChangedRows, ", ")) + case WatchOutcomeTerminal: + b.WriteString("Watch: nothing on the frontier can move; not waiting.\n") + case WatchOutcomeTimeout: + fmt.Fprintf(&b, "Watch: no frontier change within the timeout (%d tick(s)).\n", result.Ticks) + } + b.WriteString(FormatFlowFrontier(result.Final)) + return b.String() +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/flow_watch_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch_conformance_test.go new file mode 100644 index 000000000..8d9b4d5ab --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/flow_watch_conformance_test.go @@ -0,0 +1,164 @@ +package boatstack + +// control-law: watch-observes-and-exits-never-acts +// +// `flow watch` is a bounded observe-compare loop over the read-only frontier: +// each tick re-observes, and the loop exits on the first signature change, on +// an all-terminal frontier, or at the deadline. It never executes a +// transition and never writes — across any number of ticks the delivery +// ledger stays byte-identical. Time is injected through seams so the law is +// provable without real waiting. +// +// Test classes: positive (an external phase change ends the wait and names +// the changed row), negative (no change → timeout outcome, frontier intact), +// bypass (zero writes across many ticks), failure-state (gh failing every +// tick degrades to Unknown rows and the loop still terminates at the +// deadline; an all-terminal frontier refuses to wait at all). + +import ( + "errors" + "os" + "sync/atomic" + "testing" + "time" +) + +// fakeWatchClock replaces the time seams: sleeping advances a virtual clock, +// so deadlines fire deterministically and instantly. +func fakeWatchClock(t *testing.T) *atomic.Int64 { + t.Helper() + var virtual atomic.Int64 + previousNow, previousSleep := flowWatchNow, flowWatchSleep + flowWatchNow = func() time.Time { return time.Unix(0, virtual.Load()) } + flowWatchSleep = func(d time.Duration) { virtual.Add(int64(d)) } + t.Cleanup(func() { flowWatchNow, flowWatchSleep = previousNow, previousSleep }) + return &virtual +} + +func watchRepoWithOpenPR(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", "") + return repo +} + +// Positive: the PR's checks finish between ticks; the watch exits with +// outcome "changed" and names the row that moved. +func TestWatchExitsWhenTheFrontierChanges(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + var calls atomic.Int64 + withRecoveryGh(t, func(_ string, args ...string) (string, error) { + if calls.Add(1) <= 1 { + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...) + } + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)(repo, args...) + }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeChanged || result.Ticks != 1 { + t.Fatalf("unexpected watch result: %#v", result) + } + if len(result.ChangedRows) != 1 || result.ChangedRows[0] != "shipped/delivery" { + t.Fatalf("changed row not named: %#v", result.ChangedRows) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseChecksFailing) { + t.Fatalf("final frontier does not carry the new observation: %#v", result.Final.Rows) + } +} + +// Negative: nothing changes; the watch times out with the frontier intact and +// the CLI-visible timeout outcome. +func TestWatchTimesOutWhenNothingChanges(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 10 * time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTimeout { + t.Fatalf("unexpected outcome: %#v", result) + } + if result.Ticks < 5 || result.Ticks > 7 { + t.Fatalf("unexpected tick count for 1h/10m: %d", result.Ticks) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseChecksPending) { + t.Fatalf("frontier drifted without a change: %#v", result.Final.Rows) + } +} + +// Bypass: across many ticks — including a tick that observes a terminal +// MERGED lifecycle — the watch writes nothing. The change is reported, never +// recorded. +func TestWatchWritesNothingAcrossTicks(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + statePath, err := deliveryStatePath(repo, "shipped") + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int64 + withRecoveryGh(t, func(_ string, args ...string) (string, error) { + if calls.Add(1) <= 3 { + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...) + } + return phaseObservationPayload("MERGED", "", "", "")(repo, args...) + }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeChanged { + t.Fatalf("unexpected outcome: %#v", result) + } + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("watch mutated the delivery ledger") + } +} + +// Failure-state 1: gh fails on every tick — rows degrade to Unknown, no +// crash, and the loop still ends at the deadline. +func TestWatchStaysBoundedWhenObservationFails(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("gh unavailable") }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 15 * time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTimeout { + t.Fatalf("unexpected outcome: %#v", result) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseUnknown) { + t.Fatalf("degraded observation not fail-closed: %#v", result.Final.Rows) + } +} + +// Failure-state 2: when nothing on the frontier can move, the watch refuses +// to wait at all. +func TestWatchRefusesToWaitOnTerminalFrontier(t *testing.T) { + fakeWatchClock(t) + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "done", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "done", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTerminal || result.Ticks != 0 { + t.Fatalf("unexpected result on a terminal frontier: %#v", result) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md index 0b4d214c2..2d29ce72e 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md @@ -23,6 +23,8 @@ boatstack-config-field:workflow.visual_evidence_publish.mode boatstack-config-field:workflow.visual_evidence_publish.host boatstack-config-field:workflow.visual_evidence_publish.expiry boatstack-config-field:workflow.ignored_deliveries +boatstack-config-field:delivery +boatstack-config-field:delivery.terminal boatstack-config-field:workspace boatstack-config-field:workspace.enabled boatstack-config-field:workspace.mode @@ -53,6 +55,7 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `project` (object, required): General project definition. - `workflow` (object, required): Flags controlling state machine transitions and safety gates. - `workspace` (object, optional): Opt-in per-feature branch or worktree management. +- `delivery` (object, optional): The standing goal of the delivery flow. - `adapters` (array of strings, optional): Enabled host environment adapters. If empty, defaults to enabling all. - `integrations` (object, optional): Installer-owned state for third-party integrations. @@ -91,6 +94,10 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `cleanup_after` (string, optional): `merge` or `ship`. Defaults to `merge`. - `reap` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs the post-merge sweep that reclaims all terminal (merged or abandoned) Boatstack workspaces at once. `confirm` prompts the operator once when reclaimable workspaces exist; `auto` reclaims them without prompting; `off` disables the sweep and its prompt. +### delivery Fields + +- `terminal` (string, optional): `published` or `merged`. Defaults to `published`. Deterministic goal control: the state a delivery pursues before the flow reports nothing left to do. `published` ends the flow when the slice's pull request is open (the prior behavior, unchanged). `merged` keeps the read-only flow advisors naming post-publish steps until the pull request is observed merged. The goal a delivery is activated under is snapshotted on its state, so changing this value mid-flight never changes an in-progress delivery's goal; every invalid or unreadable value resolves to `published`. + ### adapters Values Supported values are `cursor`, `claude`, `codex`, `gemini`, and `github`. An empty or omitted array enables all supported adapters. 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 bb636cd43..042d202a1 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,10 @@ 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 -> 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. + ### `PR_OPEN -> RETRO` Record unexpected friction and outcomes. A retro may propose a loop move, but it may not mutate durable instructions automatically. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go index da16dded4..f3a839d62 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/runtime.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/runtime.go @@ -34,10 +34,23 @@ type ProjectConfig struct { Project Project `json:"project"` Workflow Workflow `json:"workflow"` Workspace Workspace `json:"workspace,omitempty"` + Delivery *DeliveryPolicy `json:"delivery,omitempty"` Adapters []string `json:"adapters"` Integrations map[string]IntegrationState `json:"integrations,omitempty"` } +// DeliveryPolicy declares the standing goal of the delivery flow. Terminal +// names the state a delivery pursues before the flow reports "nothing left to +// do": "published" (default — the flow ends when the slice's PR is open) or +// "merged" (the flow keeps naming read-only post-publish steps until the PR +// is observed merged). The nil zero value preserves the prior behavior +// exactly: a goal this standing is widened only by an explicit operator +// choice, never by an upgrade. +// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config +type DeliveryPolicy struct { + Terminal string `json:"terminal,omitempty"` // "" | "published" | "merged" +} + type Project struct { Name string `json:"name"` DefaultBranch string `json:"default_branch,omitempty"`