Skip to content

Commit 4db2109

Browse files
authored
feat(boatstack): delivery.terminal — the standing goal becomes a typed setpoint (#239)
* feat(boatstack): flow watch — bounded observe-compare loop over the frontier New `flow watch` verb: re-observe the read-only frontier on an interval and exit on the first row-signature change (position, actor, lifecycle, failing checks), immediately when nothing can move, or at the deadline with a distinct exit code (1) so callers can tell change from timeout. Time flows through injectable seams so the loop is provable without real waiting; a failing gh observation degrades rows to Unknown and the loop stays bounded. The watch observes and exits — it never executes a transition and never writes, pinned across ticks including a terminal MERGED observation. SKILL.md and workflow.md document composing watch with the loop (watch exits → next-status → continue). control-law: watch-observes-and-exits-never-acts Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit * feat(boatstack): delivery.terminal — the standing goal becomes a typed setpoint New optional config block delivery.terminal: "published" (default, byte- identical behavior pinned by conformance) or "merged" (the flow's advisors keep reporting the standing goal past publish). Resolution is state-then- config-then-default: activation snapshots the non-default goal onto DeliveryState.Goal (additive omitempty, no schema bump, preserved across plan amendments), so a mid-flight config change never changes an in-progress delivery's goal and a fresh session hydrates the goal from the repository instead of the operator restating it. FlowNext gains a Terminal field; only the widened goal earns a rendered line. The oracle's Goal stays StatePublished — the delivery machine models no transition past publish; Terminal is the operator-facing setpoint layer. Every invalid or unreadable input resolves to the narrower published goal. control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit * fix(boatstack): normalize JSON-escaped Windows paths in the terminal golden test The byte-identical default-vs-published comparison blanks the per-fixture temp repo path out of both captures; on Windows the JSON capture carries the path with escaped backslashes, so the raw-path replacement missed it and the two fixtures diverged on their temp paths alone. Blank both the raw and the JSON-escaped forms. Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit
1 parent 5850912 commit 4db2109

10 files changed

Lines changed: 367 additions & 3 deletions

File tree

labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ boatstack-user-config-field:workflow.visual_evidence_publish.mode
1515
boatstack-user-config-field:workflow.visual_evidence_publish.host
1616
boatstack-user-config-field:workflow.visual_evidence_publish.expiry
1717
boatstack-user-config-field:workflow.ignored_deliveries
18+
boatstack-user-config-field:delivery.terminal
1819
boatstack-user-config-field:workspace.enabled
1920
boatstack-user-config-field:workspace.mode
2021
boatstack-user-config-field:workspace.cleanup
@@ -41,6 +42,7 @@ Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit on
4142
| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. |
4243
| 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. |
4344
| 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. |
45+
| 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. |
4446
| Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. |
4547
| Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. |
4648

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

144146
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.
145147

148+
## Delivery goal
149+
150+
```json
151+
{
152+
"delivery": {
153+
"terminal": "merged"
154+
}
155+
}
156+
```
157+
158+
`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.
159+
146160
## Installer-owned fields
147161

148162
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
### You can now tell Boatstack the goal is a merged PR, not just an open one
2+
3+
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.
4+
5+
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.

labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func TestSerializedConfigurationSurfaceIsDocumentedInternally(t *testing.T) {
113113
func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) {
114114
want := []string{
115115
"adapters",
116+
"delivery.terminal",
116117
"project.commands",
117118
"project.context",
118119
"project.default_branch",

labs/12-product-engineering-loop/product-engineering-loop/delivery.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ type DeliveryState struct {
6666
RepairAttempt int `json:"repair_attempt,omitempty"`
6767
SupersededReceipts []string `json:"superseded_receipts,omitempty"`
6868
ParentDelivery string `json:"parent_delivery,omitempty"`
69+
// Goal snapshots the non-default delivery terminal ("merged") this
70+
// delivery was activated under, so a mid-flight config change never
71+
// silently changes an in-progress delivery's goal. Empty means: resolve
72+
// from config at read time (and keeps a default-config state file
73+
// byte-identical to the pre-field format).
74+
// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
75+
Goal string `json:"goal,omitempty"`
6976
}
7077

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

@@ -485,6 +493,7 @@ func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliveryS
485493
Slices: merged,
486494
Mode: "NORMAL",
487495
ParentDelivery: existing.ParentDelivery,
496+
Goal: existing.Goal,
488497
}
489498
}
490499

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package boatstack
2+
3+
import "strings"
4+
5+
// The delivery terminal is the standing goal of the flow — the state past
6+
// which nothing more is owed. It resolves in a fixed order: the goal the
7+
// delivery was ACTIVATED under (state.Goal — hysteresis, so a mid-flight
8+
// config change never silently changes an in-progress delivery's goal), then
9+
// the repository config (delivery.terminal), then the published default.
10+
// Every unreadable or invalid input resolves to the narrower published goal:
11+
// a goal is widened only by an explicit, verifiable operator choice.
12+
// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
13+
type DeliveryTerminal string
14+
15+
const (
16+
// TerminalPublished — the flow is done when the slice's PR is open.
17+
TerminalPublished DeliveryTerminal = "published"
18+
// TerminalMerged — the flow keeps naming read-only post-publish steps
19+
// until the PR is observed merged.
20+
TerminalMerged DeliveryTerminal = "merged"
21+
)
22+
23+
func normalizeDeliveryTerminal(value string) (DeliveryTerminal, bool) {
24+
switch strings.ToLower(strings.TrimSpace(value)) {
25+
case string(TerminalPublished):
26+
return TerminalPublished, true
27+
case string(TerminalMerged):
28+
return TerminalMerged, true
29+
default:
30+
return "", false
31+
}
32+
}
33+
34+
// configuredDeliveryTerminal reads the repository's standing terminal from
35+
// the project config. Absent, invalid, or unreadable configuration resolves
36+
// to published — never an error, because the terminal is consulted from
37+
// read-only paths that must not gain a new failure mode.
38+
func configuredDeliveryTerminal(repo string) DeliveryTerminal {
39+
config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath())
40+
if err != nil || config.Delivery == nil {
41+
return TerminalPublished
42+
}
43+
if terminal, ok := normalizeDeliveryTerminal(config.Delivery.Terminal); ok {
44+
return terminal
45+
}
46+
return TerminalPublished
47+
}
48+
49+
// resolveDeliveryTerminal resolves the terminal for one feature: the
50+
// activation snapshot first, then config, then the default.
51+
func resolveDeliveryTerminal(repo, feature string) DeliveryTerminal {
52+
if strings.TrimSpace(feature) != "" {
53+
if state, err := LoadDeliveryState(repo, feature); err == nil {
54+
if terminal, ok := normalizeDeliveryTerminal(state.Goal); ok {
55+
return terminal
56+
}
57+
}
58+
}
59+
return configuredDeliveryTerminal(repo)
60+
}
61+
62+
// deliveryGoalSnapshot is what activation records on the new delivery state.
63+
// Only the non-default goal is snapshotted: a default-config delivery keeps
64+
// an empty Goal, so its persisted state is byte-identical to before this
65+
// field existed.
66+
func deliveryGoalSnapshot(repo string) string {
67+
if configuredDeliveryTerminal(repo) == TerminalMerged {
68+
return string(TerminalMerged)
69+
}
70+
return ""
71+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package boatstack
2+
3+
// control-law: terminal-goal-defaults-to-published-and-hydrates-from-state-then-config
4+
//
5+
// The delivery terminal — the standing goal of the flow — resolves in a fixed
6+
// order: the goal the delivery was ACTIVATED under (state.Goal), then the
7+
// repository config (delivery.terminal), then the published default. The
8+
// default is a hard no-op: with no delivery block (or an explicit
9+
// "published"), every advisory output is identical to the pre-field
10+
// behavior, because a goal this standing is widened only by an explicit
11+
// operator choice, never by an upgrade. Invalid and unreadable inputs
12+
// resolve to the NARROWER published goal (fail-closed direction: the wider
13+
// goal implies more agent-owned steps).
14+
//
15+
// Test classes: positive (config merged → Terminal merged; activation
16+
// snapshots the non-default goal), relation (state.Goal overrides config both
17+
// ways — hysteresis), negative (invalid config value fails validation;
18+
// invalid state.Goal is ignored), bypass (default vs explicit published →
19+
// byte-identical rendering and JSON across the slice lifecycle), failure-state
20+
// (a pre-field state file without goal loads clean and resolves from config).
21+
22+
import (
23+
"encoding/json"
24+
"os"
25+
"path/filepath"
26+
"strings"
27+
"testing"
28+
)
29+
30+
func writeTerminalConfig(t *testing.T, repo, terminal string) {
31+
t.Helper()
32+
config := testConfig()
33+
if terminal != "" {
34+
config.Delivery = &DeliveryPolicy{Terminal: terminal}
35+
}
36+
value, err := MarshalJSON(config)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil {
41+
t.Fatal(err)
42+
}
43+
}
44+
45+
// Positive: the configured terminal surfaces on the advisory, and only the
46+
// widened goal earns a rendered line.
47+
func TestConfiguredTerminalSurfacesOnAdvisory(t *testing.T) {
48+
repo := nextTestRepo(t)
49+
writeNextDelivery(t, repo, "feature", "BUILD", 0)
50+
writeTerminalConfig(t, repo, "merged")
51+
52+
next, err := NextControl(repo, "feature")
53+
if err != nil {
54+
t.Fatal(err)
55+
}
56+
if next.Terminal != TerminalMerged {
57+
t.Fatalf("terminal = %q, want merged", next.Terminal)
58+
}
59+
if !strings.Contains(FormatFlowNext(next), "Terminal goal: merged") {
60+
t.Fatal("widened goal must be visible in the rendering")
61+
}
62+
}
63+
64+
// Relation: the activation snapshot outranks config in BOTH directions — a
65+
// delivery keeps the goal it was started under when config flips mid-flight.
66+
func TestActivationSnapshotOverridesConfig(t *testing.T) {
67+
repo := nextTestRepo(t)
68+
writeNextDelivery(t, repo, "feature", "BUILD", 0)
69+
70+
// Delivery activated under merged; config later narrowed to published.
71+
state, err := LoadDeliveryState(repo, "feature")
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
state.Goal = string(TerminalMerged)
76+
if err := saveDeliveryState(repo, state); err != nil {
77+
t.Fatal(err)
78+
}
79+
writeTerminalConfig(t, repo, "published")
80+
if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
81+
t.Fatalf("mid-flight narrowing changed the goal: %q", got)
82+
}
83+
84+
// Delivery activated under the default; config later widened to merged.
85+
// The empty snapshot means "resolve from config", so the widening applies.
86+
state.Goal = ""
87+
if err := saveDeliveryState(repo, state); err != nil {
88+
t.Fatal(err)
89+
}
90+
writeTerminalConfig(t, repo, "merged")
91+
if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
92+
t.Fatalf("config terminal not hydrated: %q", got)
93+
}
94+
95+
// An invalid snapshot value is ignored, never trusted.
96+
state.Goal = "deployed"
97+
if err := saveDeliveryState(repo, state); err != nil {
98+
t.Fatal(err)
99+
}
100+
writeTerminalConfig(t, repo, "")
101+
if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalPublished {
102+
t.Fatalf("invalid snapshot must resolve to published: %q", got)
103+
}
104+
}
105+
106+
// Positive: first activation snapshots the non-default goal onto the new
107+
// delivery state; the default snapshots nothing (byte-stable state files).
108+
func TestActivationSnapshotsNonDefaultGoalOnly(t *testing.T) {
109+
for _, test := range []struct {
110+
terminal string
111+
wantGoal string
112+
}{
113+
{"merged", "merged"},
114+
{"published", ""},
115+
{"", ""},
116+
} {
117+
repo := nextTestRepo(t)
118+
writeTerminalConfig(t, repo, test.terminal)
119+
if got := deliveryGoalSnapshot(repo); got != test.wantGoal {
120+
t.Fatalf("terminal %q: snapshot = %q, want %q", test.terminal, got, test.wantGoal)
121+
}
122+
}
123+
}
124+
125+
// Negative: an explicit invalid enum fails config validation fail-closed.
126+
func TestInvalidTerminalRejectedByValidation(t *testing.T) {
127+
config := testConfig()
128+
config.Delivery = &DeliveryPolicy{Terminal: "deployed"}
129+
if err := ValidateConfig(config); err == nil || !strings.Contains(err.Error(), "delivery.terminal") {
130+
t.Fatalf("invalid terminal accepted: %v", err)
131+
}
132+
config.Delivery = &DeliveryPolicy{}
133+
if err := ValidateConfig(config); err != nil {
134+
t.Fatalf("empty terminal must stay legal: %v", err)
135+
}
136+
}
137+
138+
// Bypass: the default is a hard no-op — for every slice-lifecycle stage, the
139+
// advisory under an absent delivery block is byte-identical (JSON and
140+
// rendering) to an explicit published terminal, and carries no merged
141+
// wording anywhere.
142+
func TestDefaultTerminalIsByteIdenticalToExplicitPublished(t *testing.T) {
143+
for _, stage := range []string{"BUILD", "TEST_PASSED", "REVIEW_PASSED", "PUBLISHED"} {
144+
capture := func(terminal string) (string, string) {
145+
repo := nextTestRepo(t)
146+
activeIndex := 0
147+
if stage == "PUBLISHED" {
148+
activeIndex = 1
149+
}
150+
writeNextDelivery(t, repo, "feature", stage, activeIndex)
151+
writeTerminalConfig(t, repo, terminal)
152+
if stage == "PUBLISHED" {
153+
updateRecoveryDelivery(t, repo, "feature", "feat/phase", "https://example.invalid/pr/9", "")
154+
withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPass))
155+
}
156+
next, err := NextControl(repo, "feature")
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
// The repo path differs per fixture; blank it out of the compared
161+
// values so only behavior is compared. JSON escapes Windows path
162+
// separators, so the escaped form must be blanked too.
163+
value, err := MarshalJSON(next)
164+
if err != nil {
165+
t.Fatal(err)
166+
}
167+
escaped, err := json.Marshal(repo)
168+
if err != nil {
169+
t.Fatal(err)
170+
}
171+
blank := func(s string) string {
172+
s = strings.ReplaceAll(s, strings.Trim(string(escaped), `"`), "<repo>")
173+
return strings.ReplaceAll(s, repo, "<repo>")
174+
}
175+
return blank(string(value)), blank(FormatFlowNext(next))
176+
}
177+
defaultJSON, defaultText := capture("")
178+
publishedJSON, publishedText := capture("published")
179+
if defaultJSON != publishedJSON {
180+
t.Fatalf("stage %s: default and explicit published diverge:\n%s\n---\n%s", stage, defaultJSON, publishedJSON)
181+
}
182+
if defaultText != publishedText {
183+
t.Fatalf("stage %s: rendering diverges:\n%s\n---\n%s", stage, defaultText, publishedText)
184+
}
185+
if strings.Contains(defaultText, "merged (delivery.terminal)") {
186+
t.Fatalf("stage %s: default rendering mentions the widened goal:\n%s", stage, defaultText)
187+
}
188+
}
189+
}
190+
191+
// Failure-state: a pre-field state file (no goal key) loads clean and
192+
// resolves from config — the migration law is untouched by the additive
193+
// field.
194+
func TestPreFieldStateResolvesFromConfig(t *testing.T) {
195+
repo := nextTestRepo(t)
196+
writeNextDelivery(t, repo, "feature", "BUILD", 0)
197+
statePath, err := deliveryStatePath(repo, "feature")
198+
if err != nil {
199+
t.Fatal(err)
200+
}
201+
raw, err := os.ReadFile(statePath)
202+
if err != nil {
203+
t.Fatal(err)
204+
}
205+
if strings.Contains(string(raw), "\"goal\"") {
206+
t.Fatal("fixture unexpectedly contains a goal key")
207+
}
208+
writeTerminalConfig(t, repo, "merged")
209+
if got := resolveDeliveryTerminal(repo, "feature"); got != TerminalMerged {
210+
t.Fatalf("pre-field state did not hydrate from config: %q", got)
211+
}
212+
if _, err := LoadDeliveryState(repo, "feature"); err != nil {
213+
t.Fatalf("pre-field state failed to load: %v", err)
214+
}
215+
}

labs/12-product-engineering-loop/product-engineering-loop/export.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ func ValidateConfig(config ProjectConfig) error {
128128
if err := validateWorkspaceConfig(config.Workspace); err != nil {
129129
return err
130130
}
131+
if err := validateDeliveryConfig(config.Delivery); err != nil {
132+
return err
133+
}
131134
if policy := strings.TrimSpace(config.Workflow.PRVisualEvidence); policy != "" && policy != "off" && policy != "suggest" && policy != "require" {
132135
return fmt.Errorf("workflow.pr_visual_evidence must be \"off\", \"suggest\", or \"require\"")
133136
}
@@ -137,6 +140,18 @@ func ValidateConfig(config ProjectConfig) error {
137140
return nil
138141
}
139142

143+
// validateDeliveryConfig rejects only explicit invalid enum values. A nil
144+
// block or empty terminal resolves to the published default at use.
145+
func validateDeliveryConfig(delivery *DeliveryPolicy) error {
146+
if delivery == nil {
147+
return nil
148+
}
149+
if terminal := delivery.Terminal; terminal != "" && terminal != "published" && terminal != "merged" {
150+
return fmt.Errorf("delivery.terminal must be \"published\" or \"merged\"")
151+
}
152+
return nil
153+
}
154+
140155
// validateVisualEvidencePublish rejects only explicit invalid enum values. A nil
141156
// block or empty fields are legal and resolve to defaults at use, so configs written
142157
// before this block existed remain valid.

0 commit comments

Comments
 (0)