diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-multi-pr-dx-improvements.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-multi-pr-dx-improvements.md
new file mode 100644
index 000000000..fe1da2601
--- /dev/null
+++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-20-multi-pr-dx-improvements.md
@@ -0,0 +1,3 @@
+### Improve developer experience for multi-PR features
+
+Boatstack now surfaces slice progression in multi-PR features by indicating the current slice relative to the total slices (e.g., `(PR 1 of 4)`). This context is shown in the terminal when interacting with the feature and is embedded directly within generated PR descriptions. Additionally, Boatstack update notices are suppressed during intermediate slice builds to ensure developers aren't distracted until the final PR of the feature is published.
diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go
index b21ace45d..2ad42ef09 100644
--- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go
+++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go
@@ -654,7 +654,12 @@ func publishPRCommand(arguments []string) int {
verb = "updated"
}
fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url)
- if update, ok := boatstack.PostShipUpdateNotice(*repo); ok {
+
+ feature := ""
+ if preview, err := boatstack.ParsePRPreview(*previewPath); err == nil {
+ feature = preview.Feature
+ }
+ if update, ok := boatstack.PostShipUpdateNotice(*repo, feature); ok {
fmt.Printf("UPDATE_AVAILABLE=%s\nUPDATE_RELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL)
}
return 0
diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go
index baff20a34..889576d3f 100644
--- a/labs/12-product-engineering-loop/product-engineering-loop/next.go
+++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go
@@ -18,6 +18,8 @@ type NextStatus struct {
VerificationStatus string `json:"verification_status"`
Feature string `json:"feature,omitempty"`
ActiveSlice string `json:"active_slice,omitempty"`
+ SliceIndex int `json:"slice_index,omitempty"`
+ TotalSlices int `json:"total_slices,omitempty"`
ObservedStage string `json:"observed_stage"`
NextOperation string `json:"next_operation"`
Reason string `json:"reason"`
@@ -136,6 +138,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) {
status := NextStatus{
SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "VERIFIED",
Feature: feature, ActiveSlice: slice.ID, ObservedStage: slice.Status,
+ SliceIndex: state.ActiveIndex + 1, TotalSlices: len(state.Slices),
}
switch slice.Status {
case "BUILD":
@@ -336,7 +339,11 @@ func FormatNextStatus(status NextStatus) string {
parts = append(parts, "Feature: "+status.Feature)
}
if status.ActiveSlice != "" {
- parts = append(parts, "Active slice: "+status.ActiveSlice)
+ if status.TotalSlices > 1 {
+ parts = append(parts, fmt.Sprintf("Active slice: %s (PR %d of %d)", status.ActiveSlice, status.SliceIndex, status.TotalSlices))
+ } else {
+ parts = append(parts, "Active slice: "+status.ActiveSlice)
+ }
}
parts = append(parts, "Reason: "+status.Reason, "Next: "+status.NextOperation)
if len(status.BlockingAmbiguity) > 0 {
diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr.go b/labs/12-product-engineering-loop/product-engineering-loop/pr.go
index a2b039e0f..296e63950 100644
--- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go
+++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go
@@ -35,6 +35,8 @@ type PRContext struct {
Mode string `json:"mode"`
Feature string `json:"feature,omitempty"`
SliceID string `json:"slice_id,omitempty"`
+ SliceIndex int `json:"slice_index,omitempty"`
+ TotalSlices int `json:"total_slices,omitempty"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
BaseCommit string `json:"base_commit"`
@@ -429,6 +431,8 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
sources := []PRSource{configSource}
gateStatus := map[string]string{}
sliceID := ""
+ sliceIndex := 0
+ totalSlices := 0
safety, err := CheckRepositorySafety(repo)
if err != nil {
return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err)
@@ -443,7 +447,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
}
sources = append(sources, managedSources...)
gateStatus = statuses
- _, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed)
+ state, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed)
if deliveryErr != nil {
return PRContext{}, deliveryErr
}
@@ -451,6 +455,8 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
return PRContext{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID)
}
sliceID = slice.ID
+ sliceIndex = state.ActiveIndex + 1
+ totalSlices = len(state.Slices)
sources = append(sources, gateSources...)
}
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
@@ -474,6 +480,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) {
}
return PRContext{
SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, SliceID: sliceID,
+ SliceIndex: sliceIndex, TotalSlices: totalSlices,
BaseBranch: base, HeadBranch: head, BaseCommit: baseCommit, MergeBaseCommit: mergeBaseCommit, HeadCommit: headCommit,
ProductDiffSHA256: SHA256Bytes(diff), ContextFingerprint: SHA256Bytes(fingerprintPayload),
ChangedFiles: changed, Commits: commits, DiffStat: diffStat,
@@ -855,7 +862,7 @@ func PRPreviewTemplate(context PRContext) string {
return string(encoded)
}
safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack."
- return strings.Join([]string{
+ lines := []string{
"---",
"boatstack_pr_version: 2",
"title: " + quote("Describe the product or user value of this change (e.g., 'Enable historical data migration')"),
@@ -873,8 +880,12 @@ func PRPreviewTemplate(context PRContext) string {
"## Operational safety", "", safetySummary, "",
"## Known gaps and risks", "", "List explicit gaps or say that no material gaps are known.", "",
"## Rollout and rollback", "", "Describe deployment impact and the smallest safe rollback.", "",
- "", "Boatstack provenance
", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", " ", "",
- }, "\n")
+ }
+ if context.TotalSlices > 1 {
+ lines = append(lines, fmt.Sprintf("> *(This is PR %d of %d in the `%s` feature)*", context.SliceIndex, context.TotalSlices, context.Feature), "")
+ }
+ lines = append(lines, "", "Boatstack provenance
", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", " ", "")
+ return strings.Join(lines, "\n")
}
func PRContextJSON(context PRContext) ([]byte, error) {
diff --git a/labs/12-product-engineering-loop/product-engineering-loop/update.go b/labs/12-product-engineering-loop/product-engineering-loop/update.go
index e23d85f66..2b3fa52bc 100644
--- a/labs/12-product-engineering-loop/product-engineering-loop/update.go
+++ b/labs/12-product-engineering-loop/product-engineering-loop/update.go
@@ -268,7 +268,12 @@ func CachedUpdate(repoPath string) (UpdateCheckResult, bool) {
// PostShipUpdateNotice is deliberately best-effort: release discovery can add
// information after a successful publication, but it cannot change that result.
-func PostShipUpdateNotice(repo string) (UpdateCheckResult, bool) {
+func PostShipUpdateNotice(repo string, feature string) (UpdateCheckResult, bool) {
+ if feature != "" {
+ if state, err := LoadDeliveryState(repo, feature); err == nil && state.ActiveIndex + 1 < len(state.Slices) {
+ return UpdateCheckResult{}, false
+ }
+ }
result, err := CheckForUpdate(UpdateCheckOptions{Repo: repo, Notify: true})
if err != nil || result.Status != "available" || !result.ShouldNotify {
return UpdateCheckResult{}, false
diff --git a/labs/12-product-engineering-loop/product-engineering-loop/update_test.go b/labs/12-product-engineering-loop/product-engineering-loop/update_test.go
index 91914efca..29cc991c9 100644
--- a/labs/12-product-engineering-loop/product-engineering-loop/update_test.go
+++ b/labs/12-product-engineering-loop/product-engineering-loop/update_test.go
@@ -173,7 +173,7 @@ func TestUpdateCheckCurrentAndFailures(t *testing.T) {
if err := os.Remove(updateStatePath(repo)); err != nil {
t.Fatal(err)
}
- if notice, ok := PostShipUpdateNotice(repo); ok {
+ if notice, ok := PostShipUpdateNotice(repo, ""); ok {
t.Fatalf("release lookup failure changed post-ship behavior: %#v", notice)
}
if cached, ok := CachedUpdate(repo); ok {