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 @@ -97,16 +97,14 @@ In Claude Code, Cursor, and Gemini CLI, that guidance moves through `/plan-gate`

## Change course without losing the delivery

After Build, describe changes normally. Boatstack records them, preserves valid work, and resumes at the earliest boundary.

Invoke `/repair` in Claude Code, Cursor, or Gemini CLI, or `$boatstack repair` in Codex. It needs an active delivery and the exact change; earlier work routes to planning or Build.
After Build, describe changes normally. Boatstack records them, preserves valid work, and resumes at the earliest boundary. You do not need to remember a repair command: ordinary CI failures, review findings, and denied publication attempts route automatically for active deliveries and published PRs.

```text
“This is wrong” → record → repair → test → review
↘ changed intent → approve delta
```

Receipts remain as history; published corrections become linked deliveries.
Receipts remain as history; published corrections become independently approved linked deliveries. An open PR is updated after fresh gates, while merged or closed work receives a new PR.

## What you get

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ Boatstack received a hook event without a decodable command or tool call. It fai

Replace `cursor` with `claude` or `codex` for those hosts. A passing probe proves the installed wrapper, shared runtime, decoder, and canonical allow response; it cannot reveal the live payload emitted by the coding host. For Cursor, start a new task after a passing probe. Do not reinstall or hydrate Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime.

## `/repair` says there is no active delivery
## A published PR fails CI or receives review feedback

Repair compares an exact requested change with an activated, approved baseline. If no Boatstack feature has started, save the host Plan-mode file and run `/auto-plan`. If a draft or approved feature already exists, run the one planning or build operation reported by `/boatstack-next`; do not create or clear delivery state manually.
Describe the failure normally. Boatstack resolves the current branch and recorded PR, preserves the published parent, and prepares a corrective delivery for approval. Do not manually repeat a push or PR mutation denied by the safety hook. If several features match, choose from the named candidates; if GitHub is unavailable, the correction may be planned but its PR destination remains unverified until publication.

## Repair reports no matching delivery

Recovery compares an exact requested change with an activated or published baseline. If no feature matches the current branch or recorded PR, save a new host Plan-mode file. If a draft or approved feature already exists, run the one planning or build operation reported by the status check; do not create or clear delivery state manually.

## Boatstack reports invalid or orphaned delivery state

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
### Route post-publication failures into corrective delivery automatically

Boatstack now distinguishes a published PR from a verified merged feature. CI
failures, review findings, ordinary correction requests, and denied publication
attempts resolve against the current branch and recorded PR, then prepare an
independently approved corrective child without requiring users to know a repair
command. Safety denials identify the blocking delivery and never recommend that
the user repeat the denied push manually.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ For the full state machine, read [workflow.md](references/workflow.md). For arti

## Report what is next

Run the project-local helper's read-only `next-status --repo . --json` inspection. Repository artifacts, managed delivery state, and gate receipts are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. Distinguish a Boatstack feature that is complete from one that has not started: `NOT_STARTED` and `SOURCE_PLAN_READY` point to `auto-plan`, while `FEATURE_COMPLETE` requires no action. If state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts.
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` and `SOURCE_PLAN_READY` point to `auto-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.

## Run through ship

Expand Down Expand Up @@ -161,15 +161,15 @@ Do not branch the workflow on model brand, price, or a guessed capability tier.

## Repair from ordinary conversation

Before any product edit or explicit `repair`, run `next-status`. Repair requires an active managed delivery and the user's exact requested change. If no Boatstack feature has started, explain that there is nothing to repair yet and point to `auto-plan`; if a draft or approved feature has not activated, route to its verified `plan-gate` or `build` operation. If a delivery is active and the user reports a problem or requests a modification, use `repair` even when they do not name Boatstack or a slash command. Compare the exact request with the current lock, acceptance criteria, diff, evidence, and receipts. Classify it as `implementation_repair`, `verification_repair`, `review_repair`, `requirement_amendment`, or `needs_clarification`, then invoke `record-change` before editing. A published feature is immutable; an exact correction request starts a linked Boatstack feature rather than rewriting completed evidence.
Before any product edit or explicit `repair`, run `recovery-status` with the exact requested change and observed source stage. It resolves active work and published work associated with the current branch or recorded PR. Automatically use repair for ordinary CI failures, review findings, denied publication, problems, and modifications even when the user does not name Boatstack or a slash command. Active work resumes through `record-change`; a published parent returns `CORRECTIVE_CHILD_REQUIRED` and a deterministic child id. Never ask the user to manually repeat a denied push or PR mutation.

If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime.

If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/bin/boatstack-helper diagnose-hook --host <host> --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime.

Same-intent repair resumes at the helper-reported stage and reuses the existing gates. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority.

A published delivery is immutable. Record the observation against it, then plan the correction under a new feature id whose structured plan sets `parent_delivery` to the published feature. Activation refuses to reset published slices; the corrective child receives its own lock and full gates.
A published delivery is immutable. Record the append-only observation without changing its state, then automatically prepare a one-slice correction under the suggested feature id with `parent_delivery` set to the published feature. Present the inherited intent, observed failure, existing local diff, verification, and PR destination, then pause for the normal fingerprinted human approval. The corrective child receives its own lock and full gates. A verified open PR reuses its head branch and is updated; merged or closed work uses a fresh branch and PR. Unknown PR state may be planned but blocks destination-specific publication.

## Enforce the gates

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,38 @@ func nextStatusCommand(arguments []string) int {
return 0
}

func recoveryStatusCommand(arguments []string) int {
flags := flag.NewFlagSet("recovery-status", flag.ContinueOnError)
options := boatstack.RecoveryStatusOptions{}
flags.StringVar(&options.Repo, "repo", ".", "repository whose managed delivery should be resolved")
flags.StringVar(&options.Feature, "feature", "", "optional specific active or published feature")
flags.StringVar(&options.Message, "message", "", "exact reported correction")
flags.StringVar(&options.SourceStage, "source-stage", "", "ci, review, publication, or user")
flags.StringVar(&options.Evidence, "evidence", "", "bounded failure or review reference")
flags.StringVar(&options.ObservedHeadSHA, "observed-head-sha", "", "optional PR head tied to the reported evidence")
jsonOutput := flags.Bool("json", false, "print the versioned structured recovery decision")
if err := flags.Parse(arguments); err != nil {
return 2
}
status, err := boatstack.ResolveRecovery(options)
if err != nil {
return fail(err)
}
if *jsonOutput {
value, marshalErr := boatstack.MarshalJSON(status)
if marshalErr != nil {
return fail(marshalErr)
}
fmt.Print(string(value))
} else {
fmt.Printf("Recovery: %s\nFeature: %s\nLifecycle: %s\nNext operation: %s\nReason: %s\n", status.VerificationStatus, status.Feature, status.Lifecycle, status.NextOperation, status.Reason)
}
if status.VerificationStatus == "BLOCKED" {
return 1
}
return 0
}

func runPreflightCommand(arguments []string) int {
flags := flag.NewFlagSet("run-preflight", flag.ContinueOnError)
repo := flags.String("repo", ".", "repository whose Git state should be verified before boatstack run")
Expand Down Expand Up @@ -448,7 +480,10 @@ func recordChangeCommand(arguments []string) int {
if err != nil {
return fail(err)
}
fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, state.Mode, state.ResumeStage)
fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nOUTCOME=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, observation.Outcome, state.Mode, state.ResumeStage)
if observation.Outcome == "CORRECTIVE_CHILD_REQUIRED" {
fmt.Printf("PARENT_DELIVERY=%s\nSUGGESTED_FEATURE_ID=%s\n", observation.ParentDelivery, observation.SuggestedFeatureID)
}
return 0
}

Expand Down Expand Up @@ -783,7 +818,7 @@ func workspaceStatusCommand(arguments []string) int {

func run() int {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|run-preflight|record-change|record-delivery-gate|record-pr-visual-evidence|record-pr-visual-publication|check-safety|migrate-config|safety-hook|diagnose-hook|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-status|doctor|version>")
fmt.Fprintln(os.Stderr, "usage: boatstack-helper <init|update|check-update|release-classify|next-patch|export|check-source-plan|planning-write|check-plan|record-approval|activate-plan|delivery-status|next-status|recovery-status|run-preflight|record-change|record-delivery-gate|record-pr-visual-evidence|record-pr-visual-publication|check-safety|migrate-config|safety-hook|diagnose-hook|pr-context|check-pr|publish-pr|workspace-cut|workspace-cleanup|workspace-status|doctor|version>")
return 2
}
switch os.Args[1] {
Expand Down Expand Up @@ -813,6 +848,8 @@ func run() int {
return deliveryStatusCommand(os.Args[2:])
case "next-status":
return nextStatusCommand(os.Args[2:])
case "recovery-status":
return recoveryStatusCommand(os.Args[2:])
case "run-preflight":
return runPreflightCommand(os.Args[2:])
case "record-change":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
Expand Down Expand Up @@ -108,17 +109,20 @@ type ChangeObservationOptions struct {
}

type ChangeObservation struct {
ID string `json:"id"`
Feature string `json:"feature"`
SliceID string `json:"slice_id,omitempty"`
SourceStage string `json:"source_stage"`
Expected string `json:"expected,omitempty"`
Actual string `json:"actual,omitempty"`
Evidence string `json:"evidence,omitempty"`
Message string `json:"message"`
Classification string `json:"classification"`
ResumeStage string `json:"resume_stage,omitempty"`
RecordedAt string `json:"recorded_at"`
ID string `json:"id"`
Feature string `json:"feature"`
SliceID string `json:"slice_id,omitempty"`
SourceStage string `json:"source_stage"`
Expected string `json:"expected,omitempty"`
Actual string `json:"actual,omitempty"`
Evidence string `json:"evidence,omitempty"`
Message string `json:"message"`
Classification string `json:"classification"`
ResumeStage string `json:"resume_stage,omitempty"`
RecordedAt string `json:"recorded_at"`
Outcome string `json:"outcome,omitempty"`
ParentDelivery string `json:"parent_delivery,omitempty"`
SuggestedFeatureID string `json:"suggested_feature_id,omitempty"`
}

func deliveryEvidenceGateStatus(value, gate, sliceID string, explicit bool) string {
Expand Down Expand Up @@ -376,12 +380,30 @@ func appendChangeObservation(repo string, observation ChangeObservation) error {
if len(existing) == 0 {
existing = []byte("# Change observations\n\nAppend-only observations recorded after build activation.\n")
}
block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Resume stage: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Resolution: pending\n",
block := fmt.Sprintf("\n## %s\n\n- Recorded: `%s`\n- Source stage: `%s`\n- Classification: `%s`\n- Outcome: `%s`\n- Resume stage: `%s`\n- Parent delivery: `%s`\n- Suggested corrective feature: `%s`\n- User message: %s\n- Expected: %s\n- Actual: %s\n- Evidence: %s\n- Resolution: pending\n",
observation.ID, observation.RecordedAt, observation.SourceStage, observation.Classification,
observation.ResumeStage, observation.Message, observation.Expected, observation.Actual, observation.Evidence)
observation.Outcome, observation.ResumeStage, observation.ParentDelivery, observation.SuggestedFeatureID,
observation.Message, observation.Expected, observation.Actual, observation.Evidence)
return atomicWriteMode(path, append(existing, []byte(block)...), 0o644)
}

func nextChangeObservationID(repo, feature string, fallback int) string {
path := filepath.Join(repo, ".product-loop", "features", feature, "changes.md")
value, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("CHG-%03d", fallback)
}
maximum := fallback - 1
pattern := regexp.MustCompile(`(?m)^## CHG-([0-9]+)\s*$`)
for _, match := range pattern.FindAllStringSubmatch(string(value), -1) {
parsed, parseErr := strconv.Atoi(match[1])
if parseErr == nil && parsed > maximum {
maximum = parsed
}
}
return fmt.Sprintf("CHG-%03d", maximum+1)
}

func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservation, DeliveryState, error) {
repo, err := ResolveRepository(options.Repo)
if err != nil {
Expand All @@ -407,22 +429,41 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio
if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" {
return ChangeObservation{}, DeliveryState{}, fmt.Errorf("change observation requires the user message and source stage")
}
state.RepairAttempt++
published := state.ActiveIndex >= len(state.Slices)
if !published {
state.RepairAttempt++
}
id := fmt.Sprintf("CHG-%03d", state.RepairAttempt)
if published {
id = nextChangeObservationID(repo, options.Feature, state.RepairAttempt+1)
}
observation := ChangeObservation{
ID: id, Feature: options.Feature, SourceStage: strings.ToUpper(strings.TrimSpace(options.SourceStage)),
Expected: strings.TrimSpace(options.Expected), Actual: strings.TrimSpace(options.Actual), Evidence: strings.TrimSpace(options.Evidence),
Message: strings.TrimSpace(options.Message), Classification: classification, ResumeStage: resume,
RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339),
}
if state.ActiveIndex < len(state.Slices) {
if !published {
observation.SliceID = state.Slices[state.ActiveIndex].ID
} else if classification != "requirement_amendment" {
return ChangeObservation{}, DeliveryState{}, fmt.Errorf("published delivery changes require requirement_amendment and a corrective child delivery")
observation.Outcome = "RESUME_ACTIVE"
} else {
if len(state.Slices) > 0 {
observation.SliceID = state.Slices[len(state.Slices)-1].ID
}
states, statesErr := allManagedDeliveryStates(repo)
if statesErr != nil {
return ChangeObservation{}, DeliveryState{}, statesErr
}
observation.Outcome = "CORRECTIVE_CHILD_REQUIRED"
observation.ParentDelivery = state.Feature
observation.SuggestedFeatureID = suggestedCorrectionFeature(states, state.Feature)
}
if err := appendChangeObservation(repo, observation); err != nil {
return ChangeObservation{}, DeliveryState{}, err
}
if published {
return observation, state, nil
}
state.ActiveObservationID = id
state.ResumeStage = resume
if classification == "needs_clarification" || classification == "requirement_amendment" {
Expand Down
Loading