diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index d90ad3c..f831d85 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -175,7 +175,7 @@ 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 `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. +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. If active work enters `AMENDMENT_REQUIRED` or `PLAN_INVALID`, treat that composite state as authoritative over the slice status. Save the accepted amendment as a durable source plan, obtain a fresh lifecycle-bound `flow bootstrap` prescription for the same feature, execute only its returned envelope, then require normal approval and activation before delivery resumes. 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. diff --git a/boatstack/bootstrap.go b/boatstack/bootstrap.go index 9e38fa1..9a28800 100644 --- a/boatstack/bootstrap.go +++ b/boatstack/bootstrap.go @@ -9,6 +9,10 @@ import ( const bootstrapPrescriptionSchemaVersion = 1 +// Tests may replace this seam. Nil selects the production installation health +// check immediately before a bootstrap prescription is rendered. +var bootstrapInstallationHealth func(string) error + type BootstrapShell string const ( @@ -45,6 +49,10 @@ type BootstrapPrescription struct { Artifact string `json:"artifact"` ArtifactPath string `json:"artifact_path"` DocumentSHA256 string `json:"document_sha256"` + LifecycleState string `json:"lifecycle_state,omitempty"` + LifecycleSHA256 string `json:"lifecycle_sha256,omitempty"` + ObservationID string `json:"observation_id,omitempty"` + PreviousPlanLock string `json:"previous_plan_lock_sha256,omitempty"` Shell BootstrapShell `json:"shell"` Argv []string `json:"argv"` PlanningEnvelope string `json:"planning_envelope"` @@ -62,22 +70,23 @@ func normalizedPlanningDocument(document []byte) ([]byte, error) { return value, nil } -func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, error) { +func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, *LifecycleSnapshot, error) { directory := workspace.FeatureDir(feature) info, err := os.Lstat(directory) if os.IsNotExist(err) { - return "CREATE_CANDIDATE", nil + return "CREATE_CANDIDATE", nil, nil } if err != nil { - return "", err + return "", nil, err } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) + return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) } statePath, stateErr := deliveryStatePath(repo, feature) if stateErr != nil { - return "", stateErr + return "", nil, stateErr } + managed := false for _, path := range []string{ statePath, filepath.Join(directory, "plan.lock.json"), @@ -86,24 +95,35 @@ func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, featur filepath.Join(directory, "autonomy.md"), } { if fileExists(path) { - return "", fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature) + managed = true + break + } + } + if managed { + snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature) + if snapshotErr != nil { + return "", nil, fmt.Errorf("feature %s carries managed authority that cannot be verified: %w", feature, snapshotErr) + } + if !amendmentLifecycleState(snapshot.State) { + return "", nil, fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature) } + return "AMEND_ACTIVE", &snapshot, nil } entries, err := os.ReadDir(directory) if err != nil { - return "", err + return "", nil, err } for _, entry := range entries { if entry.IsDir() || !planningArtifacts[entry.Name()] { - return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) + return "", nil, fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) } } if fileExists(filepath.Join(directory, "plan.md")) { if _, err := CheckPlan(filepath.Join(directory, "plan.md")); err != nil { - return "", fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err) + return "", nil, fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err) } } - return "RESUME_CANDIDATE", nil + return "RESUME_CANDIDATE", nil, nil } func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string { @@ -113,8 +133,8 @@ func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string { return workspace.LauncherPath(shell == BootstrapShellPowerShell) } -func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string) []string { - return []string{ +func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string, lifecycle *LifecycleSnapshot) []string { + argv := []string{ program, "planning-write", "--repo", repo, "--feature", feature, @@ -122,6 +142,14 @@ func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string "--source-plan", sourcePlan, "--source-plan-sha256", sourceSHA, } + if lifecycle != nil { + argv = append(argv, + "--expected-lifecycle-sha256", lifecycle.Fingerprint, + "--expected-plan-lock-sha256", lifecycle.PlanLockSHA256, + "--expected-observation", lifecycle.ObservationID, + ) + } + return argv } func posixPlanningEnvelopeFor(argv []string, document []byte) string { @@ -176,7 +204,11 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, if err != nil { return BootstrapPrescription{}, err } - if err := CheckInstallationHealth(repo); err != nil { + healthCheck := CheckInstallationHealth + if bootstrapInstallationHealth != nil { + healthCheck = bootstrapInstallationHealth + } + if err := healthCheck(repo); err != nil { return BootstrapPrescription{}, fmt.Errorf("bootstrap requires a healthy Boatstack installation: %w", DoctorRepairHint(err)) } workspace, err := ResolveWorkspaceContext(repo) @@ -198,12 +230,12 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, if err != nil { return BootstrapPrescription{}, err } - disposition, err := bootstrapFeatureDisposition(repo, workspace, options.Feature) + disposition, lifecycle, err := bootstrapFeatureDisposition(repo, workspace, options.Feature) if err != nil { return BootstrapPrescription{}, err } program := bootstrapProgram(workspace, options.Shell) - argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA) + argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA, lifecycle) if options.Shell == BootstrapShellPOSIX { // Git Bash accepts Windows drive paths in slash form. Keep the typed argv // identical to the bytes rendered for that shell. @@ -219,7 +251,7 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, if err != nil { return BootstrapPrescription{}, err } - return BootstrapPrescription{ + prescription := BootstrapPrescription{ SchemaVersion: bootstrapPrescriptionSchemaVersion, VerificationStatus: "VERIFIED", Disposition: disposition, SupervisionMode: workspace.Mode, Repository: repo, RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID, @@ -228,5 +260,12 @@ func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, Artifact: options.Artifact, ArtifactPath: filepath.Join(workspace.FeatureDir(options.Feature), options.Artifact), DocumentSHA256: SHA256Bytes(document), Shell: options.Shell, Argv: argv, PlanningEnvelope: envelope, - }, nil + } + if lifecycle != nil { + prescription.LifecycleState = string(lifecycle.State) + prescription.LifecycleSHA256 = lifecycle.Fingerprint + prescription.ObservationID = lifecycle.ObservationID + prescription.PreviousPlanLock = lifecycle.PlanLockSHA256 + } + return prescription, nil } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index 13f31cf..04cb736 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -47,8 +47,6 @@ var nonDeliveryVerbs = map[string]bool{ // Planning phase, before a plan is activated into a delivery. "check-source-plan": true, "check-plan": true, - "planning-write": true, - "record-approval": true, "record-autonomy": true, // Read-only status / diagnostics (observe helpers, not modeled transitions). "repair-status": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 56eda08..815be56 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -581,6 +581,9 @@ func planningWriteCommand(arguments []string) int { artifact := flags.String("artifact", "", "known Markdown planning artifact name") sourcePlan := flags.String("source-plan", "", "in-repo source plan bound by flow bootstrap") sourcePlanSHA256 := flags.String("source-plan-sha256", "", "source-plan digest bound by flow bootstrap") + expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "active-delivery lifecycle fingerprint bound by flow bootstrap") + expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "active plan-lock digest bound by flow bootstrap") + expectedObservation := flags.String("expected-observation", "", "active amendment observation bound by flow bootstrap") if err := flags.Parse(arguments); err != nil { return 2 } @@ -594,6 +597,9 @@ func planningWriteCommand(arguments []string) int { path, err := boatstack.WritePlanningArtifact(boatstack.PlanningWriteOptions{ Repo: *repo, Feature: *feature, Artifact: *artifact, Content: content, SourcePlan: *sourcePlan, SourcePlanSHA256: *sourcePlanSHA256, + ExpectedLifecycleSHA256: *expectedLifecycleSHA256, + ExpectedPlanLockSHA256: *expectedPlanLockSHA256, + ExpectedObservation: *expectedObservation, }) if err != nil { return fail(err) @@ -610,6 +616,9 @@ func recordApprovalCommand(arguments []string) int { approvedAt := flags.String("approved-at", "", "RFC3339 approval timestamp") fingerprint := flags.String("fingerprint", "", "exact fingerprint displayed before approval") baselineDiffSHA256 := flags.String("baseline-diff-sha256", "", "exact product baseline fingerprint displayed before approval; omit only when clean") + expectedLifecycleSHA256 := flags.String("expected-lifecycle-sha256", "", "exact active lifecycle fingerprint displayed before amendment approval") + expectedPlanLockSHA256 := flags.String("expected-plan-lock-sha256", "", "exact prior plan-lock fingerprint displayed before amendment approval") + expectedObservation := flags.String("expected-observation", "", "exact active amendment observation displayed before approval") if err := flags.Parse(arguments); err != nil { return 2 } @@ -619,6 +628,8 @@ func recordApprovalCommand(arguments []string) int { if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{ PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy, ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256, + ExpectedLifecycleSHA256: *expectedLifecycleSHA256, ExpectedPlanLockSHA256: *expectedPlanLockSHA256, + ExpectedObservation: *expectedObservation, }); err != nil { return fail(err) } diff --git a/boatstack/config_event_registry_test.go b/boatstack/config_event_registry_test.go index 640c6f7..8cb90b1 100644 --- a/boatstack/config_event_registry_test.go +++ b/boatstack/config_event_registry_test.go @@ -80,7 +80,7 @@ func TestConfigurationEventRegistryIsComplete(t *testing.T) { } sort.Strings(entries) digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) - const expected = "dbccf0d0263b056656e1626a56966b0c9dea5b67187b8e0f1980cd5745f4d6c5" + const expected = "dfcbdc50fdfbda0d505ce7b04f55f6289622adad74a8fd01fc5fb13e6a00979b" if digest != expected { _ = os.WriteFile(filepath.Join(t.TempDir(), "config-events.txt"), []byte(strings.Join(entries, "\n")+"\n"), 0o644) t.Fatalf("configuration event registry changed: got %s; classify the new or removed site and update the reviewed digest", digest) diff --git a/boatstack/deliverycontrol_parity_test.go b/boatstack/deliverycontrol_parity_test.go index 0bef9fb..f5214f0 100644 --- a/boatstack/deliverycontrol_parity_test.go +++ b/boatstack/deliverycontrol_parity_test.go @@ -31,6 +31,8 @@ var realDeliveryHandlers = map[string]any{ "CheckDeliveryReadyForShip": CheckDeliveryReadyForShip, "ResolveNext": ResolveNext, "ResolveRecovery": ResolveRecovery, + "WritePlanningArtifact": WritePlanningArtifact, + "RecordApproval": RecordApproval, } func TestRegistryHandlerRefsAreRealFunctions(t *testing.T) { diff --git a/boatstack/export.go b/boatstack/export.go index fd67e6c..1e56019 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -375,14 +375,14 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte operations := map[string]string{ "boatstack-next": "Run the tracked .product-loop/boatstack launcher next-status --repo . --format response and present its output as the response. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. The helper renders the canonical response contract deterministically — the outcome line and the single ### Next step block with the exact runnable command when one is prescribable; never override, re-derive, or add a second next action. The helper also types the step's actor: when the rendered step is marked \"This step is mine to do\", the step is the agent's, and the one next action is the delegation reply g. Only after the exact reply g, execute the prescribed step, re-render next-status --repo . --format response, and continue through further agent-owned steps until the next step belongs to the operator (an approval, a publish or cleanup reply, a feature choice, a product fact) or no action is required. Stop immediately when a step does not change the prescribed next step — repetition without progress is a stall; report the block and hand the turn to the operator. Never end a response by describing work the agent still has to do. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result.", - "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the tracked .product-loop/boatstack launcher run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", + "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the tracked .product-loop/boatstack launcher run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. For AMENDMENT_REQUIRED or PLAN_INVALID, obtain the amended source plan, execute only a fresh lifecycle-bound flow bootstrap prescription, pass through plan-gate, and reactivate before resuming delivery. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "insight-capture": "Treat the complete invocation argument as the exact untrusted source message. Require insights.enabled before continuing. Run the available Value Map skill as a read-only conversational projection and preserve its canonical lineage: user, current state, value gap, desired outcome, mechanism, smallest proof, evidence, unknowns, grade, and verdict. When insights.suggest_features is true, inspect only the minimal relevant product slice to suggest one primary feature topic and optional related topics; label suggestions PROPOSED and do not bind them to a delivery. When it is false, leave topics for explicit human classification. Serialize the full proposed capture, including the exact source bytes and SHA-256, then pipe those bytes to the tracked .product-loop/boatstack launcher insight check --repo . --json. Display the complete Value Map, suggested topics, unknowns, returned preview fingerprint, and a prominent warning that the exact source and Value Map will enter the repository and may become public through Git history. Respond Insight ready to save and make the one next action: Reply `s` to save this exact insight as a repository diff. Only an exact state-scoped s for the currently displayed fingerprint authorizes piping the unchanged draft to insight save with the same preview nonce and fingerprint. If any source byte, map field, topic, nonce, or fingerprint changes, check again and require a new s. Never save on the initial request, on r, or when Value Map is unavailable. After a successful save respond Insight saved as a repository diff and show its ID and repository path. Do not create a feature, plan, branch, commit, or PR; publication remains a separate explicit action.", "insight-frontier": "Run the tracked .product-loop/boatstack launcher insight frontier --repo . and present the independent captures needing classification, delivery, evidence, terminal observation, or human completion. This operation is strictly read-only: do not append events, change associations, bind deliveries, evaluate by mutation, disposition captures, or alter the authoritative delivery frontier. Respond Insight frontier ready and show one suggested pending action per capture without presenting any insight as Boatstack's single delivery next action.", "root-cause": "Perform failure-mode elimination on a bug, not a patch. This operation is strictly read-only: do not edit product code, create or update artifacts, advance a gate, or contact GitHub; the user supplies the symptom, stack trace, error log, or failing signal as the argument. Locate the failure below its surface symptom and classify it against the failure classes in @.product-loop/failure-moves.md; name the failure CLASS, not the one instance, and if no class fits, name the new class in that vocabulary. Investigate with read-only tools and produce a numbered root-cause chain in which every step is cited to file:line and which distinguishes the crashing frame (the victim) from the true origin (the cause); label authoritative repository facts DISCOVERED and any inference PROPOSED. State the blast radius: every other call site or path exposed to the same class. Propose the minimal STRUCTURAL elimination that makes the whole class unreachable and covers every exposed site, reusing an existing repository pattern or utility where one exists, rather than a local guard on the single line in the trace. Present this as a material product decision with the same tiered paths auto-plan uses under boundary_analysis: [1a] Symptom Patch or [1b] Programmatic Enforcement (a boundary that eliminates the class), and recommend one. Require a regression that reproduces the failure mode before the fix plus the project's own gates as the proof the class is gone, and name related latent hazards left out of scope as non-goals. Then format the result as a host Plan-mode source plan (symptom, root-cause chain, failure mode, blast radius, elimination, non-goals, verification, delivery base branch) and respond Root cause found, making the one next action: save this plan to a durable in-repo path and run auto-plan with it via --plan. Do not implement the fix; hand off to the plan gate.", "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Resolve every planning write through flow bootstrap with the selected feature, source plan, artifact, and shell; execute only its returned planning_envelope and resolve again after workspace-cut. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If a selected hand-authored draft cannot be verified, resolve it through repair-state, then re-author it through a fresh flow bootstrap prescription. The invalid draft never controls unrelated repository tools. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. activate-plan promotes the compiled task graph, test matrix, evidence ledger, and the plan lock together through the transactional mutation boundary as one mutation, so all four land all-or-nothing with a reversible receipt and a failed or interrupted promote leaves the prior state unchanged rather than half-written. The boundary is closed under inversion: mutation-status lists the receipts and undo --mutation reverses a managed-artifact promotion (redo is undo of the undo receipt), with undo refusing to reverse an activation once a delivery gate would be stranded; this governs Boatstack-generated artifacts only, never source code. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", - "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", + "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. When record-change returns AMENDMENT_REQUIRED or PLAN_INVALID, do not edit product code or call ordinary bootstrap: save the accepted amended intent as a durable in-repository source plan, resolve flow bootstrap for the same managed feature, execute only its returned lifecycle-bound planning envelope, run plan-gate, and reactivate the exact amended plan before resuming the returned delivery stage. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant, pr-context runs the registered repository capture command itself whenever evidence is missing or stale, so capturing is not your step: review the exact fingerprinted local PNGs for secrets and private data, show the external-host privacy warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Fall back to manual capture (host browser, capture-evidence, record-pr-visual-evidence) only when the context reports the capture capability unavailable or names a harness failure in pr_visual_evidence_capture_detail. Require human privacy review, then let Boatstack upload through external hosting (Litterbox for 72 hours by default), verify every returned URL, and place only hosted Markdown image links in the existing evidence comment; never attach PNG files directly or commit them to an evidence branch. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and retry the same fingerprint and comment from visual_pending after upload, URL-verification, or comment failure. Add security/privacy, migration, or operations sections only when relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", @@ -396,6 +396,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations["boatstack-run"] = "Resolve an explicit target and feature slug from --to plan|verified|pr, the user's wording, or the supplied plan; when the target is absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run operation-status first and reconcile in-flight work. For a new feature with a supplied source plan, resolve flow bootstrap before calling feature-scoped next-status; an absent candidate is creation intent, not stale delivery state. Execute its returned planning_envelope, then carry the feature through every later status and planning call so unrelated saved drafts cannot redirect the run. For an existing saved or active feature, run next-status --repo . --feature --json normally. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run workspace-cut when prescribed and continue from its destination_repository. Discard every earlier bootstrap prescription and resolve again there. Only then run record-autonomy with the selected target, so the receipt binds the final feature branch. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action." operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan, but first run run-preflight --repo . --health-only --json before auto-plan writes any feature artifact; planning and plan-gate do not require delivery preflight beyond this pure health check. Stop without writing when installation or generated state is unhealthy. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action." + operations["boatstack-run"] += " If lifecycle_state is AMENDMENT_REQUIRED, AMENDMENT_DRAFTED, AMENDMENT_APPROVED, or PLAN_INVALID, treat that state as authoritative over the active slice status. Use the owned amend-plan, plan-gate, and activation transitions; never route directly back to test or publication." operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason." operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. When workspace-cut is prescribed, complete it and continue from destination_repository before recording approval or autonomy. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." operations["build"] = strings.Replace(operations["build"], "compiled task graph, test matrix, evidence ledger, and the plan lock", "compiled task graph, test matrix, evidence ledger, journey-oracle manifest, and the plan lock", 1) diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 9814ddb..4189cdd 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -54,6 +54,14 @@ func flowStateFromStage(stage string) (deliverycontrol.StateID, bool) { switch stage { case "BUILD": return deliverycontrol.StateBuild, true + case "AMENDMENT_REQUIRED": + return deliverycontrol.StateAmendmentRequired, true + case "AMENDMENT_DRAFTED": + return deliverycontrol.StateAmendmentDrafted, true + case "AMENDMENT_APPROVED": + return deliverycontrol.StateAmendmentApproved, true + case "PLAN_INVALID": + return deliverycontrol.StatePlanInvalid, true case "TEST_PASSED": return deliverycontrol.StateTestPassed, true case "REVIEW_PASSED", "PR_PREVIEW": @@ -305,11 +313,21 @@ func posixPlanningWord(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } +func powerShellCommandWord(value string) string { + if value == "" { + return value + } + if posixPlanningWord(value) == value { + return value + } + return powerShellPlanningWord(value) +} + // CommandLine renders the auto-derivable part of the prescribed command as a // runnable string. Human-required inputs use explicit placeholders; // planning Markdown is placed inside the same literal envelope the hook admits. // The rendering is never fabricated or runnable as-is while input is still owed. -func (p PrescribedCommand) CommandLine() string { +func (p PrescribedCommand) commandLineForOS(goos string) string { literalPlanningInput := false for _, input := range p.RequiresHumanInput { if input == planningMarkdownInput { @@ -333,21 +351,42 @@ func (p PrescribedCommand) CommandLine() string { } parts = append(parts, flag, "") } + if goos == "windows" { + if literalPlanningInput { + envelope, err := powerShellPlanningEnvelopeFor(parts, []byte("\n")) + if err == nil { + return strings.TrimSuffix(envelope, "\n") + } + // A single quote in an argv word cannot cross the deliberately small + // PowerShell grammar. Preserve a valid Git Bash prescription instead + // of manufacturing a hybrid command that neither shell owns. + return strings.TrimSuffix(posixPlanningEnvelopeFor(parts, []byte("\n")), "\n") + } + for index := range parts { + parts[index] = powerShellCommandWord(parts[index]) + } + line := strings.Join(parts, " ") + if filepath.IsAbs(program) { + return "& " + line + } + return line + } for index := range parts { if parts[index] != "" { parts[index] = posixPlanningWord(parts[index]) } } line := strings.Join(parts, " ") - if filepath.IsAbs(program) && runtime.GOOS == "windows" { - line = "& " + line - } if literalPlanningInput { return line + " <<'BOATSTACK_PLAN_EOF'\n\nBOATSTACK_PLAN_EOF" } return line } +func (p PrescribedCommand) CommandLine() string { + return p.commandLineForOS(runtime.GOOS) +} + // prescribeCommand assembles the runnable command for a forward delivery // transition. It returns (nil, false) for any transition it cannot assemble // faithfully — so the caller emits nothing rather than a guessed command. The @@ -386,6 +425,24 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive repoArgs = []string{"--repo", repo} } switch transition { + case deliverycontrol.TransitionID("delivery.amend_write"), deliverycontrol.TransitionID("delivery.invalid_plan_rewrite"): + cmd.Verb = "flow" + cmd.Args = append([]string{"bootstrap"}, repoArgs...) + cmd.Args = append(cmd.Args, "--feature", feature) + cmd.RequiresHumanInput = []string{"--source-plan", "--artifact", "--shell", planningMarkdownInput} + case deliverycontrol.TransitionID("delivery.amend_approve"): + featureDir := planningFeatureDir(repo, feature) + cmd.Args = []string{ + "--plan", filepath.Join(featureDir, "plan.md"), + "--expected-lifecycle-sha256", status.LifecycleSHA256, + "--expected-plan-lock-sha256", status.PlanLockSHA256, + "--expected-observation", status.ObservationID, + } + cmd.RequiresHumanInput = []string{"--approved-by", "--approved-at", "--fingerprint"} + case deliverycontrol.TransitionID("delivery.amend_activate"): + featureDir := planningFeatureDir(repo, feature) + cmd = buildActivatePlan(featureDir, "AMENDMENT_APPROVED") + cmd.Transition = transition case deliverycontrol.TransitionID("delivery.record_gate_test"): cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "test") cmd.RequiresHumanInput = []string{"--status", "--evidence"} @@ -655,7 +712,7 @@ func buildActivatePlan(featureDir, stage string) *PrescribedCommand { "--out-dir", filepath.Join(featureDir, "compiled"), "--output", filepath.Join(featureDir, "plan.lock.json"), } - if stage == "APPROVED" { + if stage == "APPROVED" || (stage == "AMENDMENT_APPROVED" && fileExists(filepath.Join(featureDir, "approval.md"))) { args = append(args, "--approval", filepath.Join(featureDir, "approval.md")) } return &PrescribedCommand{Verb: "activate-plan", Args: args, Transition: MarkerPlanningActivate} diff --git a/boatstack/flow_frontier_conformance_test.go b/boatstack/flow_frontier_conformance_test.go index 851a216..11f676a 100644 --- a/boatstack/flow_frontier_conformance_test.go +++ b/boatstack/flow_frontier_conformance_test.go @@ -104,14 +104,7 @@ func TestFrontierShowsEarlierPublishedOpenSlices(t *testing.T) { if err := os.MkdirAll(directory, 0o755); err != nil { t.Fatal(err) } - lockPath := filepath.Join(directory, "plan.lock.json") - if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { - t.Fatal(err) - } - hash, err := SHA256File(lockPath) - if err != nil { - t.Fatal(err) - } + hash := writeNextPlanLock(t, directory) if err := saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: "layered", PlanLockHash: hash, ActiveIndex: 1, Slices: []DeliverySlice{ diff --git a/boatstack/internal/deliverycontrol/liveness_test.go b/boatstack/internal/deliverycontrol/liveness_test.go index 7d12f1d..81aea08 100644 --- a/boatstack/internal/deliverycontrol/liveness_test.go +++ b/boatstack/internal/deliverycontrol/liveness_test.go @@ -14,7 +14,10 @@ func TestRegistryGraphIsLive(t *testing.T) { t.Fatalf("delivery graph is not live: deadlocks=%v goal-unreachable=%v", result.Deadlocks, result.GoalUnreachable) } // The core lifecycle states must all be reachable from the entries. - want := []StateID{StateUninitialized, StateBuild, StateTestPassed, StateReviewPassed, StatePublished} + want := []StateID{ + StateUninitialized, StateBuild, StateTestPassed, StateReviewPassed, StatePublished, + StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved, + } reachable := map[StateID]bool{} for _, s := range result.Reachable { reachable[s] = true diff --git a/boatstack/internal/deliverycontrol/registry.go b/boatstack/internal/deliverycontrol/registry.go index 6d24c96..1d74289 100644 --- a/boatstack/internal/deliverycontrol/registry.go +++ b/boatstack/internal/deliverycontrol/registry.go @@ -34,6 +34,36 @@ var registry = []TransitionDescriptor{ HandlerRef: "RecordChangeObservation", CLIVerb: "record-change", Note: "Rework resets the addressable slice to BUILD (bounded by the typed failure-class counter and a changed mechanism); amendment/plan-invalid set Mode without consuming repair authority; a fully-published delivery emits a corrective child with no state mutation.", }, + { + ID: "delivery.requirement_amendment", From: []StateID{StateBuild, StateTestPassed, StateReviewPassed}, To: StateAmendmentRequired, + Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, + HandlerRef: "RecordChangeObservation", CLIVerb: "record-change", + Note: "A requirement_amendment observation pauses ordinary gates and enters the owned amendment planning path while preserving the active plan lock.", + }, + { + ID: "delivery.amend_write", From: []StateID{StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved}, To: StateAmendmentDrafted, + Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, + HandlerRef: "WritePlanningArtifact", CLIVerb: "planning-write", + Note: "Writes one lifecycle-fingerprinted amendment artifact through a fresh flow-bootstrap prescription; stale observation, lock, worktree, branch, source-plan, or prior plan bytes refuse before mutation.", + }, + { + ID: "delivery.amend_approve", From: []StateID{StateAmendmentDrafted}, To: StateAmendmentApproved, + Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, + HandlerRef: "RecordApproval", CLIVerb: "record-approval", + Note: "Records approval for the exact amended plan and current product baseline. Policy-authorized repositories derive the same state without a receipt.", + }, + { + ID: "delivery.amend_activate", From: []StateID{StateAmendmentApproved}, To: StateBuild, + Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, + HandlerRef: "ActivatePlan", CLIVerb: "activate-plan", + Note: "Revalidates the amended plan and authority, preserves the published prefix, compiles the tail, commits the new plan lock, and clears amendment mode only after successful activation.", + }, + { + ID: "delivery.invalid_plan_rewrite", From: []StateID{StatePlanInvalid}, To: StateAmendmentDrafted, + Kind: KindRecovery, CostClass: CostRecovery, Reversible: true, + HandlerRef: "WritePlanningArtifact", CLIVerb: "planning-write", + Note: "Re-authors an invalid active plan only through lifecycle-bound planning transport; raw managed-path writes remain denied.", + }, { ID: "delivery.record_journey_results", From: []StateID{StateBuild, StateTestPassed}, To: StateBuild, Kind: KindCommittedMutation, CostClass: CostMutation, Reversible: true, diff --git a/boatstack/internal/deliverycontrol/state.go b/boatstack/internal/deliverycontrol/state.go index 35fd4b6..ee3d85a 100644 --- a/boatstack/internal/deliverycontrol/state.go +++ b/boatstack/internal/deliverycontrol/state.go @@ -10,6 +10,16 @@ const ( StateReviewPassed StateID = "REVIEW_PASSED" StatePublished StateID = "PUBLISHED" + // Composite exceptional states. These are real durable delivery positions, + // not presentation aliases: DeliveryState.Mode removes the ordinary gate + // actuators even while the active slice still says BUILD. Keeping them out of + // this vocabulary made the old liveness proof project a deadlocked delivery + // back to BUILD and therefore prove the wrong machine. + StateAmendmentRequired StateID = "AMENDMENT_REQUIRED" + StateAmendmentDrafted StateID = "AMENDMENT_DRAFTED" + StateAmendmentApproved StateID = "AMENDMENT_APPROVED" + StatePlanInvalid StateID = "PLAN_INVALID" + // Boundary states — needed to describe transitions faithfully; not stored as // a slice Status. StateUninitialized StateID = "UNINITIALIZED" // no managed delivery yet @@ -33,6 +43,7 @@ func SliceStatusStates() []StateID { func States() []StateID { return []StateID{ StatePending, StateBuild, StateTestPassed, StateReviewPassed, StatePublished, + StateAmendmentRequired, StateAmendmentDrafted, StateAmendmentApproved, StatePlanInvalid, StateUninitialized, StateFeatureComplete, StateInvalid, StateDiscarded, StateUnresolved, } } diff --git a/boatstack/lifecycle.go b/boatstack/lifecycle.go new file mode 100644 index 0000000..c427c48 --- /dev/null +++ b/boatstack/lifecycle.go @@ -0,0 +1,169 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// LifecycleSnapshot is the canonical control projection for one managed +// delivery. It deliberately includes every durable dimension that can remove +// or grant an actuator. Callers may render or enforce this answer; they must not +// reconstruct authority from DeliverySlice.Status alone. +// control-law: lifecycle-authority-includes-every-controlling-dimension +type LifecycleSnapshot struct { + SchemaVersion int `json:"schema_version"` + Feature string `json:"feature"` + State deliverycontrol.StateID `json:"state"` + Mode string `json:"mode"` + ResumeStage string `json:"resume_stage,omitempty"` + ActiveSlice string `json:"active_slice,omitempty"` + SliceStatus string `json:"slice_status,omitempty"` + ActiveIndex int `json:"active_index"` + TotalSlices int `json:"total_slices"` + PlanLockSHA256 string `json:"plan_lock_sha256"` + LockedPlanSHA256 string `json:"locked_plan_sha256,omitempty"` + PlanSHA256 string `json:"plan_sha256,omitempty"` + ObservationID string `json:"observation_id,omitempty"` + Repository string `json:"repository"` + RepositoryID string `json:"repository_id,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + Branch string `json:"branch,omitempty"` + ConfigurationSHA256 string `json:"configuration_sha256,omitempty"` + HumanApproval bool `json:"human_approval"` + ApprovalCurrent bool `json:"approval_current"` + Fingerprint string `json:"fingerprint"` +} + +func lockPlanSHA256(path string) (string, error) { + value, err := os.ReadFile(path) + if err != nil { + return "", err + } + var lock map[string]any + if err := DecodeJSON("inspect plan lock", path, value, &lock); err != nil { + return "", err + } + sha := strings.TrimSpace(stringValue(lock["plan_sha256"])) + if sha == "" { + return "", fmt.Errorf("plan lock does not bind plan_sha256") + } + return sha, nil +} + +func lifecycleStateForSlice(status string) (deliverycontrol.StateID, error) { + switch status { + case StatusPending: + return deliverycontrol.StatePending, nil + case StatusBuild: + return deliverycontrol.StateBuild, nil + case StatusTestPassed: + return deliverycontrol.StateTestPassed, nil + case StatusReviewPassed: + return deliverycontrol.StateReviewPassed, nil + case StatusPublished: + return deliverycontrol.StatePublished, nil + default: + return deliverycontrol.StateUnresolved, fmt.Errorf("unsupported delivery slice status %q", status) + } +} + +func lifecycleFingerprint(snapshot LifecycleSnapshot) (string, error) { + snapshot.Fingerprint = "" + value, err := MarshalJSON(snapshot) + if err != nil { + return "", err + } + return SHA256Bytes(value), nil +} + +// ResolveLifecycleSnapshot reads one verified delivery and returns the exact +// composite state consumed by next-status, flow, bootstrap, and safety +// admission. It is read-only. +func ResolveLifecycleSnapshot(repoPath, feature string) (LifecycleSnapshot, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return LifecycleSnapshot{}, err + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + return LifecycleSnapshot{}, err + } + state, err := CurrentDeliveryState(repo, feature) + if err != nil { + return LifecycleSnapshot{}, err + } + slice, err := activeDeliverySlice(state) + if err != nil { + return LifecycleSnapshot{}, err + } + branch, _ := gitCommand(repo, "branch", "--show-current") + config, _, configErr := LoadConfig(workspace.ProjectConfigPath()) + if configErr != nil { + return LifecycleSnapshot{}, fmt.Errorf("managed lifecycle requires a valid Boatstack configuration: %w", configErr) + } + configSHA, _ := SHA256File(workspace.ProjectConfigPath()) + planPath := filepath.Join(workspace.FeatureDir(feature), "plan.md") + lockPath := filepath.Join(workspace.FeatureDir(feature), "plan.lock.json") + planSHA, _ := SHA256File(planPath) + lockedPlanSHA, err := lockPlanSHA256(lockPath) + if err != nil { + return LifecycleSnapshot{}, err + } + + snapshot := LifecycleSnapshot{ + SchemaVersion: 1, Feature: feature, Mode: strings.TrimSpace(state.Mode), + ResumeStage: strings.TrimSpace(state.ResumeStage), ActiveSlice: slice.ID, + SliceStatus: slice.Status, ActiveIndex: state.ActiveIndex, TotalSlices: len(state.Slices), + PlanLockSHA256: state.PlanLockHash, LockedPlanSHA256: lockedPlanSHA, PlanSHA256: planSHA, + ObservationID: strings.TrimSpace(state.ActiveObservationID), Repository: repo, + RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID, + Branch: strings.TrimSpace(branch), ConfigurationSHA256: configSHA, + HumanApproval: config.Workflow.HumanPlanApproval, + } + + switch snapshot.Mode { + case "AMENDMENT_REQUIRED", "PLAN_INVALID": + if snapshot.Mode == "PLAN_INVALID" { + snapshot.State = deliverycontrol.StatePlanInvalid + } else { + snapshot.State = deliverycontrol.StateAmendmentRequired + } + if snapshot.PlanSHA256 != "" && snapshot.PlanSHA256 != snapshot.LockedPlanSHA256 { + snapshot.State = deliverycontrol.StateAmendmentDrafted + if check, checkErr := CheckPlan(planPath); checkErr == nil { + if !snapshot.HumanApproval { + snapshot.ApprovalCurrent = true + snapshot.State = deliverycontrol.StateAmendmentApproved + } else if receipt, approvalErr := CheckApprovalReceipt(filepath.Join(filepath.Dir(planPath), "approval.md"), check); approvalErr == nil { + preApprovalSHA, fingerprintErr := lifecycleFingerprint(snapshot) + if fingerprintErr == nil && receipt.LifecycleSHA256 == preApprovalSHA && + receipt.PlanLockSHA256 == snapshot.PlanLockSHA256 && receipt.ObservationID == snapshot.ObservationID { + snapshot.ApprovalCurrent = true + snapshot.State = deliverycontrol.StateAmendmentApproved + } + } + } + } + default: + snapshot.State, err = lifecycleStateForSlice(slice.Status) + if err != nil { + return LifecycleSnapshot{}, err + } + } + snapshot.Fingerprint, err = lifecycleFingerprint(snapshot) + if err != nil { + return LifecycleSnapshot{}, err + } + return snapshot, nil +} + +func amendmentLifecycleState(state deliverycontrol.StateID) bool { + return state == deliverycontrol.StateAmendmentRequired || + state == deliverycontrol.StateAmendmentDrafted || + state == deliverycontrol.StateAmendmentApproved || + state == deliverycontrol.StatePlanInvalid +} diff --git a/boatstack/lifecycle_authority_conformance_test.go b/boatstack/lifecycle_authority_conformance_test.go new file mode 100644 index 0000000..5bb1be8 --- /dev/null +++ b/boatstack/lifecycle_authority_conformance_test.go @@ -0,0 +1,269 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +func amendmentPlanDocument(t *testing.T, feature, sourcePlan string) []byte { + t.Helper() + plan := twoSlicePlan() + plan["feature_id"] = feature + plan["source_plan_path"] = sourcePlan + plan["spec_path"] = "feature-spec.md" + plan["acceptance_criteria"].([]any)[0].(map[string]any)["text"] = "amended observable result" + value, err := MarshalJSON(plan) + if err != nil { + t.Fatal(err) + } + return []byte("# Amended structured plan\n\n" + planMarkerStart + "\n```json\n" + strings.TrimSpace(string(value)) + "\n```\n" + planMarkerEnd + "\n") +} + +func allowLifecyclePlanningHealth(t *testing.T) { + t.Helper() + previousBootstrap := bootstrapInstallationHealth + previousPlanning := planningInstallationHealth + bootstrapInstallationHealth = func(string) error { return nil } + planningInstallationHealth = func(string) error { return nil } + t.Cleanup(func() { + bootstrapInstallationHealth = previousBootstrap + planningInstallationHealth = previousPlanning + }) +} + +func TestLifecycleAuthorityMakesRequirementAmendmentReachable(t *testing.T) { + allowLifecyclePlanningHealth(t) + repo, feature := activateTwoSliceDelivery(t) + observation, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "acceptance criteria changed", + SourceStage: "build", Expected: "original result", Actual: "amended result", + Classification: "requirement_amendment", + }) + if err != nil { + t.Fatal(err) + } + + required, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if required.State != deliverycontrol.StateAmendmentRequired || required.ObservationID != observation.ID { + t.Fatalf("requirement amendment projected as %+v", required) + } + next, err := nextForDelivery(repo, feature) + if err != nil { + t.Fatal(err) + } + if next.ObservedStage != string(deliverycontrol.StateAmendmentRequired) || next.NextOperation != "amend-plan" { + t.Fatalf("requirement amendment has no owned planning transition: %+v", next) + } + + if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil { + t.Fatal(err) + } + sourcePlan := "docs/amendment-source.md" + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Accepted amendment\n"), 0o644); err != nil { + t.Fatal(err) + } + document := amendmentPlanDocument(t, feature, sourcePlan) + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md", + Shell: BootstrapShellPOSIX, Document: document, + }) + if err != nil { + t.Fatal(err) + } + if prescription.Disposition != "AMEND_ACTIVE" || prescription.LifecycleSHA256 != required.Fingerprint || prescription.PreviousPlanLock != required.PlanLockSHA256 { + t.Fatalf("amendment bootstrap was not bound to current lifecycle authority: %+v", prescription) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, prescription.PlanningEnvelope)}); denied { + t.Fatalf("%s denied the canonical amendment planning envelope: %s", host, output) + } + } + if _, err := WritePlanningArtifact(PlanningWriteOptions{ + Repo: repo, Feature: feature, Artifact: "plan.md", Content: document, + SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256, + ExpectedLifecycleSHA256: prescription.LifecycleSHA256, + ExpectedPlanLockSHA256: prescription.PreviousPlanLock, + ExpectedObservation: prescription.ObservationID, + }); err != nil { + t.Fatal(err) + } + + drafted, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if drafted.State != deliverycontrol.StateAmendmentDrafted { + t.Fatalf("amended plan did not enter approval state: %+v", drafted) + } + planPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.md") + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + baseline, err := PlanningBaselineForPlan(planPath) + if err != nil { + t.Fatal(err) + } + if err := RecordApproval(ApprovalRecordOptions{ + PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-08-10T12:00:00Z", + Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256, + ExpectedLifecycleSHA256: drafted.Fingerprint, ExpectedPlanLockSHA256: drafted.PlanLockSHA256, + ExpectedObservation: drafted.ObservationID, + }); err != nil { + t.Fatal(err) + } + + approved, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if approved.State != deliverycontrol.StateAmendmentApproved || !approved.ApprovalCurrent { + t.Fatalf("approved amendment was not projected as activatable: %+v", approved) + } + directory := filepath.Dir(planPath) + stateWithChangedObservation, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + stateWithChangedObservation.ActiveObservationID = "CHG-999" + if err := saveDeliveryState(repo, stateWithChangedObservation); err != nil { + t.Fatal(err) + } + if err := ActivatePlan(ActivationOptions{ + PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"), + OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), + SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }); err == nil || !strings.Contains(err.Error(), "not currently approved") { + t.Fatalf("activation accepted approval for a different amendment observation: %v", err) + } + stateWithChangedObservation.ActiveObservationID = observation.ID + if err := saveDeliveryState(repo, stateWithChangedObservation); err != nil { + t.Fatal(err) + } + if err := ActivatePlan(ActivationOptions{ + PlanPath: planPath, ApprovalPath: filepath.Join(directory, "approval.md"), + OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), + SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }); err != nil { + t.Fatal(err) + } + + active, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if active.State != deliverycontrol.StateBuild || active.Mode != "NORMAL" || active.ObservationID != "" { + t.Fatalf("activation did not restore ordinary delivery: %+v", active) + } + state, err := CurrentDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if len(state.PreviousPlanLocks) != 1 || state.PreviousPlanLocks[0] != required.PlanLockSHA256 { + t.Fatalf("reactivation lost prior plan authority: %+v", state.PreviousPlanLocks) + } +} + +func TestAmendmentPlanningPrescriptionRejectsLifecycleDrift(t *testing.T) { + allowLifecyclePlanningHealth(t) + repo, feature := activateTwoSliceDelivery(t) + if _, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "requirements changed", SourceStage: "build", + Classification: "requirement_amendment", + }); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil { + t.Fatal(err) + } + sourcePlan := "docs/amendment-source.md" + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Accepted amendment\n"), 0o644); err != nil { + t.Fatal(err) + } + document := amendmentPlanDocument(t, feature, sourcePlan) + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md", + Shell: BootstrapShellPOSIX, Document: document, + }) + if err != nil { + t.Fatal(err) + } + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + state.ActiveObservationID = "CHG-999" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + _, err = WritePlanningArtifact(PlanningWriteOptions{ + Repo: repo, Feature: feature, Artifact: "plan.md", Content: document, + SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256, + ExpectedLifecycleSHA256: prescription.LifecycleSHA256, + ExpectedPlanLockSHA256: prescription.PreviousPlanLock, + ExpectedObservation: prescription.ObservationID, + }) + if err == nil || !strings.Contains(err.Error(), "lifecycle changed") { + t.Fatalf("stale lifecycle prescription was not rejected: %v", err) + } +} + +func TestInvalidActivePlanUsesTheSameOwnedRewritePath(t *testing.T) { + allowLifecyclePlanningHealth(t) + repo, feature := activateTwoSliceDelivery(t) + observation, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "active plan is structurally invalid", + SourceStage: "build", Classification: "plan_invalid", + }) + if err != nil { + t.Fatal(err) + } + invalid, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if invalid.State != deliverycontrol.StatePlanInvalid || invalid.ObservationID != observation.ID { + t.Fatalf("invalid active plan did not enter the owned rewrite state: %+v", invalid) + } + if err := os.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil { + t.Fatal(err) + } + sourcePlan := "docs/plan-repair-source.md" + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Corrected plan intent\n"), 0o644); err != nil { + t.Fatal(err) + } + document := amendmentPlanDocument(t, feature, sourcePlan) + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: feature, SourcePlan: sourcePlan, Artifact: "plan.md", + Shell: BootstrapShellPOSIX, Document: document, + }) + if err != nil { + t.Fatal(err) + } + if prescription.Disposition != "AMEND_ACTIVE" || prescription.LifecycleState != string(deliverycontrol.StatePlanInvalid) { + t.Fatalf("invalid active plan did not receive a lifecycle-bound rewrite prescription: %+v", prescription) + } + if _, err := WritePlanningArtifact(PlanningWriteOptions{ + Repo: repo, Feature: feature, Artifact: "plan.md", Content: document, + SourcePlan: sourcePlan, SourcePlanSHA256: prescription.SourcePlanSHA256, + ExpectedLifecycleSHA256: prescription.LifecycleSHA256, + ExpectedPlanLockSHA256: prescription.PreviousPlanLock, + ExpectedObservation: prescription.ObservationID, + }); err != nil { + t.Fatal(err) + } + drafted, err := ResolveLifecycleSnapshot(repo, feature) + if err != nil { + t.Fatal(err) + } + if drafted.State != deliverycontrol.StateAmendmentDrafted { + t.Fatalf("corrected active plan did not enter the common approval path: %+v", drafted) + } +} diff --git a/boatstack/lifecycle_event_registry_test.go b/boatstack/lifecycle_event_registry_test.go new file mode 100644 index 0000000..a083f13 --- /dev/null +++ b/boatstack/lifecycle_event_registry_test.go @@ -0,0 +1,143 @@ +package boatstack + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// lifecycleFieldClasses is the reviewed projection of every durable delivery +// dimension. Adding a field without deciding whether it controls authority is +// a CI failure, preventing a slice-only model from silently becoming stale. +var lifecycleFieldClasses = map[string]string{ + "SchemaVersion": "identity", + "Feature": "identity", + "PlanLockHash": "authority", + "PreviousPlanLocks": "history", + "ActiveIndex": "control", + "Slices": "control", + "Mode": "control", + "ResumeStage": "control", + "ActiveObservationID": "authority", + "RepairCounters": "control", + "RepairAttempt": "derived", + "SupersededReceipts": "evidence", + "ParentDelivery": "lineage", + "Goal": "control", +} + +// lifecycleEventClasses inventories the code sites that read, resolve, render, +// admit, or mutate lifecycle authority. The reviewed digest below changes when +// a new entry path bypasses the canonical composite resolver. +var lifecycleEventClasses = map[string]string{ + "LoadDeliveryState": "reader", + "CurrentDeliveryState": "verified-reader", + "saveDeliveryState": "writer", + "ResolveLifecycleSnapshot": "resolver", + "ResolveNext": "status", + "nextForDelivery": "status", + "NextControl": "renderer", + "nextControlFromStatus": "renderer", + "ResolvePlanningBootstrap": "renderer", + "controlledPhaseTransition": "admission", + "HookDecision": "host-admission", + "WritePlanningArtifact": "writer", + "ActivatePlan": "writer", + "RecordApproval": "writer", + "RecordChangeObservation": "writer", + "RecordDeliveryGate": "writer", +} + +func TestEveryDeliveryStateFieldHasReviewedLifecycleSemantics(t *testing.T) { + set := token.NewFileSet() + parsed, err := parser.ParseFile(set, "delivery.go", nil, 0) + if err != nil { + t.Fatal(err) + } + fields := map[string]bool{} + ast.Inspect(parsed, func(node ast.Node) bool { + typeSpec, ok := node.(*ast.TypeSpec) + if !ok || typeSpec.Name.Name != "DeliveryState" { + return true + } + structure, ok := typeSpec.Type.(*ast.StructType) + if !ok { + t.Fatal("DeliveryState is not a struct") + } + for _, field := range structure.Fields.List { + for _, name := range field.Names { + fields[name.Name] = true + } + } + return false + }) + for field := range fields { + if lifecycleFieldClasses[field] == "" { + t.Errorf("DeliveryState.%s has no reviewed lifecycle classification", field) + } + } + for field := range lifecycleFieldClasses { + if !fields[field] { + t.Errorf("lifecycle field registry contains removed DeliveryState.%s", field) + } + } +} + +func TestLifecycleEventRegistryIsComplete(t *testing.T) { + entries := []string{} + set := token.NewFileSet() + files := []string{} + if err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + files = append(files, path) + } + return nil + }); err != nil { + t.Fatal(err) + } + for _, path := range files { + if strings.HasSuffix(path, "_test.go") { + continue + } + parsed, err := parser.ParseFile(set, path, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, declaration := range parsed.Decls { + function, ok := declaration.(*ast.FuncDecl) + if !ok || function.Body == nil { + continue + } + counts := map[string]int{} + ast.Inspect(function.Body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + name := calledName(call) + class, tracked := lifecycleEventClasses[name] + if !tracked { + return true + } + counts[name]++ + entries = append(entries, filepath.ToSlash(path)+":"+function.Name.Name+":"+name+":"+class+":"+strconv.Itoa(counts[name])) + return true + }) + } + } + sort.Strings(entries) + digest := SHA256Bytes([]byte(strings.Join(entries, "\n"))) + const expected = "98c48b24b87e5c95fcbd9d64e93ce05b42a484e221df01b57e5f7bf3d7be4ec4" + if digest != expected { + t.Fatalf("lifecycle event registry changed: got %s; classify the new or removed site and update the reviewed digest\n%s", digest, strings.Join(entries, "\n")) + } +} diff --git a/boatstack/next.go b/boatstack/next.go index 7ace770..4d1d366 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -7,6 +7,8 @@ import ( "sort" "strings" "unicode/utf8" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) const nextStatusSchemaVersion = 2 @@ -25,6 +27,10 @@ type NextStatus struct { TotalSlices int `json:"total_slices,omitempty"` ObservedStage string `json:"observed_stage"` NextOperation string `json:"next_operation"` + LifecycleState string `json:"lifecycle_state,omitempty"` + LifecycleSHA256 string `json:"lifecycle_sha256,omitempty"` + ObservationID string `json:"observation_id,omitempty"` + PlanLockSHA256 string `json:"plan_lock_sha256,omitempty"` Operator DecisionOperator `json:"operator,omitempty"` Reason string `json:"reason"` BlockingAmbiguity []string `json:"blocking_ambiguity,omitempty"` @@ -135,20 +141,27 @@ func orphanedFeatureArtifacts(repo string) ([]string, error) { } func nextForDelivery(repo, feature string) (NextStatus, error) { - state, err := CurrentDeliveryState(repo, feature) - if err != nil { - return NextStatus{}, err - } - slice, err := activeDeliverySlice(state) + snapshot, err := ResolveLifecycleSnapshot(repo, feature) if err != nil { return NextStatus{}, err } 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 { + Feature: feature, ActiveSlice: snapshot.ActiveSlice, ObservedStage: string(snapshot.State), + SliceIndex: snapshot.ActiveIndex + 1, TotalSlices: snapshot.TotalSlices, + LifecycleState: string(snapshot.State), LifecycleSHA256: snapshot.Fingerprint, + ObservationID: snapshot.ObservationID, PlanLockSHA256: snapshot.PlanLockSHA256, + } + switch snapshot.State { + case deliverycontrol.StateAmendmentRequired, deliverycontrol.StatePlanInvalid: + status.NextOperation = "amend-plan" + status.Reason = "The active delivery requires a lifecycle-bound plan amendment before its gates may continue." + case deliverycontrol.StateAmendmentDrafted: + status.NextOperation = "plan-gate" + status.Reason = "The amended plan differs from the active lock and must pass validation and exact approval before reactivation." + case deliverycontrol.StateAmendmentApproved: + status.NextOperation = "build" + status.Reason = "The amended plan is current and authorized; reactivate it to install the replacement lock and resume delivery." case StatusBuild: status.NextOperation = "build" status.Reason = "The approved delivery slice is active and has no current test-gate receipt." @@ -157,7 +170,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { status.Reason = "The active delivery slice has current test evidence and still requires review." case StatusReviewPassed: previewPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "pr.md") - if preview, previewErr := ParsePRPreview(previewPath); previewErr == nil && preview.Feature == feature && preview.SliceID == slice.ID { + if preview, previewErr := ParsePRPreview(previewPath); previewErr == nil && preview.Feature == feature && preview.SliceID == snapshot.ActiveSlice { status.ObservedStage = "PR_PREVIEW" status.Reason = "A reviewer-ready PR preview exists for the reviewed active slice and must be reconfirmed through the ship gate." } else { @@ -165,7 +178,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { } status.NextOperation = "ship-gate" default: - return NextStatus{}, fmt.Errorf("managed delivery slice %s has unsupported status %q", slice.ID, slice.Status) + return NextStatus{}, fmt.Errorf("managed delivery slice %s has unsupported lifecycle state %q", snapshot.ActiveSlice, snapshot.State) } return decorateAutonomyStatus(repo, status), nil } diff --git a/boatstack/next_response_conformance_test.go b/boatstack/next_response_conformance_test.go index f310772..1864d5c 100644 --- a/boatstack/next_response_conformance_test.go +++ b/boatstack/next_response_conformance_test.go @@ -47,7 +47,11 @@ func TestResponseContractPerStage(t *testing.T) { repo := nextTestRepo(t) status, output := renderedResponse(t, repo) assertResponseShape(t, status, output) - if !strings.Contains(output, "Run: .product-loop/boatstack flow bootstrap") { + next, err := nextControlFromStatus(repo, status) + if err != nil { + t.Fatal(err) + } + if next.Prescribed == nil || !strings.Contains(output, "Run: "+next.Prescribed.CommandLine()) { t.Fatalf("NOT_STARTED must carry the prescribed command: %q", output) } }) diff --git a/boatstack/next_test.go b/boatstack/next_test.go index ca8da7f..7fbfc86 100644 --- a/boatstack/next_test.go +++ b/boatstack/next_test.go @@ -35,20 +35,38 @@ func writeNextDelivery(t *testing.T, repo, feature, status string, activeIndex i if err := os.MkdirAll(directory, 0o755); err != nil { t.Fatal(err) } - lockPath := filepath.Join(directory, "plan.lock.json") - if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + hash := writeNextPlanLock(t, directory) + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash, + ActiveIndex: activeIndex, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: status}}, + }); err != nil { t.Fatal(err) } - hash, err := SHA256File(lockPath) +} + +func writeNextPlanLock(t *testing.T, directory string) string { + t.Helper() + planPath := filepath.Join(directory, "plan.md") + if err := os.WriteFile(planPath, []byte("# Synthetic managed plan\n"), 0o644); err != nil { + t.Fatal(err) + } + planSHA, err := SHA256File(planPath) if err != nil { t.Fatal(err) } - if err := saveDeliveryState(repo, DeliveryState{ - SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: hash, - ActiveIndex: activeIndex, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: status}}, - }); err != nil { + value, err := MarshalJSON(map[string]any{"plan_sha256": planSHA}) + if err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, value, 0o644); err != nil { + t.Fatal(err) + } + hash, err := SHA256File(lockPath) + if err != nil { t.Fatal(err) } + return hash } func writeSavedFeaturePlan(t *testing.T, repo, feature string) { diff --git a/boatstack/plan.go b/boatstack/plan.go index e99e41d..3bff886 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -8,6 +8,8 @@ import ( "sort" "strings" "time" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) const ( @@ -987,6 +989,9 @@ type ApprovalReceipt struct { BaselineDiffSHA256 string BaselineChangedPaths []string Readiness ReadinessReceipt + LifecycleSHA256 string + PlanLockSHA256 string + ObservationID string } func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { @@ -1005,6 +1010,9 @@ func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { Fingerprint: stringValue(value["approval_fingerprint"]), BaselineDiffSHA256: stringValue(value["baseline_diff_sha256"]), BaselineChangedPaths: []string{}, + LifecycleSHA256: stringValue(value["lifecycle_sha256"]), + PlanLockSHA256: stringValue(value["plan_lock_sha256"]), + ObservationID: stringValue(value["observation_id"]), Readiness: ReadinessReceipt{ Fingerprint: stringValue(value["readiness_fingerprint"]), BaseBranch: stringValue(value["base_branch"]), HeadBranch: stringValue(value["head_branch"]), @@ -1121,6 +1129,22 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err) } + feature := strings.TrimSpace(stringValue(check.Plan["feature_id"])) + if statePath, statePathErr := deliveryStatePath(repo, feature); statePathErr == nil && fileExists(statePath) { + state, loadErr := LoadDeliveryState(repo, feature) + if loadErr != nil { + return loadErr + } + if state.Mode == "AMENDMENT_REQUIRED" || state.Mode == "PLAN_INVALID" { + snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature) + if snapshotErr != nil { + return snapshotErr + } + if snapshot.State != deliverycontrol.StateAmendmentApproved { + return fmt.Errorf("active delivery amendment is not currently approved for activation: %s", snapshot.State) + } + } + } // Once a feature's workspace worktree is cut, activation must happen inside it, // never from the main worktree on the base branch — otherwise the compiled // artifacts and delivery ledger land on the base branch and compete with the diff --git a/boatstack/planning.go b/boatstack/planning.go index fbcb147..e3966e0 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -11,6 +11,8 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) var featureSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) @@ -39,21 +41,27 @@ func planningArtifactNames() []string { } type PlanningWriteOptions struct { - Repo string - Feature string - Artifact string - Content []byte - SourcePlan string - SourcePlanSHA256 string + Repo string + Feature string + Artifact string + Content []byte + SourcePlan string + SourcePlanSHA256 string + ExpectedLifecycleSHA256 string + ExpectedPlanLockSHA256 string + ExpectedObservation string } type ApprovalRecordOptions struct { - PlanPath string - OutputPath string - ApprovedBy string - ApprovedAt string - Fingerprint string - BaselineDiffSHA256 string + PlanPath string + OutputPath string + ApprovedBy string + ApprovedAt string + Fingerprint string + BaselineDiffSHA256 string + ExpectedLifecycleSHA256 string + ExpectedPlanLockSHA256 string + ExpectedObservation string } type PlanningBaseline struct { @@ -261,6 +269,33 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if err != nil { return "", err } + statePath, err := deliveryStatePath(repo, options.Feature) + if err != nil { + return "", err + } + hasManagedDelivery := fileExists(statePath) + hasLifecycleEvidence := strings.TrimSpace(options.ExpectedLifecycleSHA256) != "" || + strings.TrimSpace(options.ExpectedPlanLockSHA256) != "" || + strings.TrimSpace(options.ExpectedObservation) != "" + if hasManagedDelivery { + if strings.TrimSpace(options.ExpectedLifecycleSHA256) == "" || strings.TrimSpace(options.ExpectedPlanLockSHA256) == "" { + return "", fmt.Errorf("an active delivery planning write requires a lifecycle-bound flow bootstrap prescription") + } + snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, options.Feature) + if snapshotErr != nil { + return "", snapshotErr + } + if !amendmentLifecycleState(snapshot.State) { + return "", fmt.Errorf("active delivery %s is not in an amendment planning state", options.Feature) + } + if snapshot.Fingerprint != strings.TrimSpace(options.ExpectedLifecycleSHA256) || + snapshot.PlanLockSHA256 != strings.TrimSpace(options.ExpectedPlanLockSHA256) || + snapshot.ObservationID != strings.TrimSpace(options.ExpectedObservation) { + return "", fmt.Errorf("active delivery lifecycle changed after bootstrap; resolve a fresh flow bootstrap prescription") + } + } else if hasLifecycleEvidence { + return "", fmt.Errorf("lifecycle-bound planning evidence does not match an active managed delivery") + } featureDirectory := ctx.FeatureDir(options.Feature) _, featureErr := os.Lstat(featureDirectory) firstWrite := os.IsNotExist(featureErr) @@ -329,6 +364,33 @@ func RecordApproval(options ApprovalRecordOptions) error { if err != nil { return err } + feature := strings.TrimSpace(stringValue(check.Plan["feature_id"])) + statePath, statePathErr := deliveryStatePath(repo, feature) + if statePathErr != nil { + return statePathErr + } + hasLifecycleEvidence := strings.TrimSpace(options.ExpectedLifecycleSHA256) != "" || + strings.TrimSpace(options.ExpectedPlanLockSHA256) != "" || + strings.TrimSpace(options.ExpectedObservation) != "" + if fileExists(statePath) { + snapshot, snapshotErr := ResolveLifecycleSnapshot(repo, feature) + if snapshotErr != nil { + return snapshotErr + } + if snapshot.State != deliverycontrol.StateAmendmentDrafted { + return fmt.Errorf("active delivery approval requires a drafted amendment, got %s", snapshot.State) + } + if strings.TrimSpace(options.ExpectedLifecycleSHA256) == "" || strings.TrimSpace(options.ExpectedPlanLockSHA256) == "" { + return fmt.Errorf("active delivery approval requires current lifecycle and plan-lock fingerprints") + } + if snapshot.Fingerprint != strings.TrimSpace(options.ExpectedLifecycleSHA256) || + snapshot.PlanLockSHA256 != strings.TrimSpace(options.ExpectedPlanLockSHA256) || + snapshot.ObservationID != strings.TrimSpace(options.ExpectedObservation) { + return fmt.Errorf("active delivery lifecycle changed before approval; resolve flow next again") + } + } else if hasLifecycleEvidence { + return fmt.Errorf("lifecycle-bound approval evidence does not match an active managed delivery") + } expectedOutput := filepath.Join(filepath.Dir(options.PlanPath), "approval.md") output := options.OutputPath if output == "" { @@ -372,6 +434,11 @@ func RecordApproval(options ApprovalRecordOptions) error { "baseline_diff_sha256": baseline.DiffSHA256, "baseline_changed_paths": baseline.ChangedPaths, } + if hasLifecycleEvidence { + payloadValue["lifecycle_sha256"] = strings.TrimSpace(options.ExpectedLifecycleSHA256) + payloadValue["plan_lock_sha256"] = strings.TrimSpace(options.ExpectedPlanLockSHA256) + payloadValue["observation_id"] = strings.TrimSpace(options.ExpectedObservation) + } if version, _ := check.Plan["schema_version"].(float64); version >= 3 { readiness, readinessErr := CheckPlanReadiness(options.PlanPath) if readinessErr != nil { diff --git a/boatstack/planning_transport.go b/boatstack/planning_transport.go index 878fa1f..0e65c47 100644 --- a/boatstack/planning_transport.go +++ b/boatstack/planning_transport.go @@ -214,7 +214,8 @@ func planningWriteHeader(value string) (planningWriteInvocation, bool) { if bootstrap { allowed = allowed || flag == "--shell" || flag == "--json" } else { - allowed = allowed || flag == "--source-plan-sha256" + allowed = allowed || flag == "--source-plan-sha256" || flag == "--expected-lifecycle-sha256" || + flag == "--expected-plan-lock-sha256" || flag == "--expected-observation" } if !allowed { return planningWriteInvocation{}, false @@ -237,6 +238,13 @@ func planningWriteHeader(value string) (planningWriteInvocation, bool) { if (sourcePlan == "") != (sourceSHA == "") || (sourceSHA != "" && !planningSHA256.MatchString(sourceSHA)) { return planningWriteInvocation{}, false } + lifecycleSHA := values["--expected-lifecycle-sha256"] + planLockSHA := values["--expected-plan-lock-sha256"] + observation := values["--expected-observation"] + hasLifecycle := lifecycleSHA != "" || planLockSHA != "" || observation != "" + if hasLifecycle && (!planningSHA256.MatchString(lifecycleSHA) || !planningSHA256.MatchString(planLockSHA) || observation == "") { + return planningWriteInvocation{}, false + } } repository := values["--repo"] if repository == "" { diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go index f0a2645..acc6244 100644 --- a/boatstack/planning_transport_conformance_test.go +++ b/boatstack/planning_transport_conformance_test.go @@ -369,6 +369,56 @@ func TestPlanningPrescriptionRendersACompleteGuardAdmittedEnvelope(t *testing.T) } } +func TestPlanningPrescriptionUsesOneCompleteShellGrammar(t *testing.T) { + repo, workspace, _ := detachedPolicyReadyFixture(t) + command := PrescribedCommand{ + Program: workspace.HelperPath(), + Verb: "planning-write", + Args: []string{"--repo", repo, "--feature", "feature-one"}, + RequiresHumanInput: []string{ + "--artifact", + planningMarkdownInput, + }, + } + + for _, test := range []struct { + name string + goos string + }{ + {name: "POSIX", goos: "linux"}, + {name: "PowerShell", goos: "windows"}, + } { + t.Run(test.name, func(t *testing.T) { + line := substituteOwedFlags(command.commandLineForOS(test.goos)) + inspection := inspectPlanningWriteTransport(line) + if !inspection.Matched || inspection.InvalidReason != "" || string(inspection.Content) != "test-value\n" { + t.Fatalf("%s prescription is not one complete planning envelope: %q %#v", test.name, line, inspection) + } + if reason := planningTransportBinding(repo, inspection); reason != "" { + t.Fatalf("%s transport lost its detached workspace binding: %s", test.name, reason) + } + }) + } + + hybrid := "& " + substituteOwedFlags(command.commandLineForOS("linux")) + if inspection := inspectPlanningWriteTransport(hybrid); inspection.Matched && inspection.InvalidReason == "" { + t.Fatalf("hybrid PowerShell/POSIX command crossed the planning transport boundary: %#v", inspection) + } + findings := ClassifyCommand(repo, hybrid) + if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" { + t.Fatalf("hybrid command did not fail closed at managed-state admission: %#v", findings) + } + + ordinary := PrescribedCommand{ + Program: "gh", + Verb: "pr", + Args: []string{"merge", "https://example.invalid/pr/9", "--squash"}, + }.commandLineForOS("windows") + if ordinary != "gh pr merge https://example.invalid/pr/9 --squash" { + t.Fatalf("safe ordinary argv lost its stable cross-platform rendering: %q", ordinary) + } +} + func TestPlanningPrescriptionQuotesRepositoryPath(t *testing.T) { repo := filepath.Join(t.TempDir(), "repo with 'quoted' space") if err := os.MkdirAll(repo, 0o755); err != nil { diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index f7d9245..7eeea0d 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -146,7 +146,7 @@ The read-only `next` status query is the one exception, because a status questio `run` is an opt-in foreground coordinator over the existing operations, not a second state machine. It accepts `--to plan|verified|pr`; when the request names no target, the host asks once. The target is recorded in a fingerprinted `autonomy.md` receipt bound to the plan, repository, branch, eligible policy decisions, and, for `pr`, one open or update action. `plan` stops at a valid reviewable plan. `verified` uses policy activation and stops after test and review gates. `pr` continues through exact preview validation and one normal publication without a second confirmation. Receipt drift fails closed. Human-driven runs without an autonomy receipt retain the existing approval and publication confirmations. The coordinator never merges, rebases, switches or creates constrained branches, discards changes, force-pushes, merges a PR, or deploys. -After preflight, resolve the repository-backed next operation, execute exactly that canonical operation, verify the resulting state, and resolve again through all declared delivery slices. When the resolved block names only past deliveries, the coordinator may offer to ignore a named past delivery (adding its slug to `workflow.ignored_deliveries`) only after explicit user confirmation; any new, unlisted ambiguous delivery still pauses. Pause for `a`, a material product answer, and `o` or `u`; after the valid state-scoped reply, continue in the current host session. The invocation does not replace either human authorization. Automatically record and repair same-intent test or review failures for at most three complete repair-and-gate cycles per active slice per invocation. Stop immediately for requirement amendments, ambiguous or stale state, unsafe capability, unsupported recovery, branch mismatch, or exhausted repairs. Store no durable run/autopilot mode; re-invocation reconstructs progress from canonical repository state. +After preflight, resolve the repository-backed next operation, execute exactly that canonical operation, verify the resulting state, and resolve again through all declared delivery slices. When the resolved block names only past deliveries, the coordinator may offer to ignore a named past delivery (adding its slug to `workflow.ignored_deliveries`) only after explicit user confirmation; any new, unlisted ambiguous delivery still pauses. Pause for `a`, a material product answer, and `o` or `u`; after the valid state-scoped reply, continue in the current host session. The invocation does not replace either human authorization. Automatically record and repair same-intent test or review failures for at most three complete repair-and-gate cycles per active slice per invocation. A requirement amendment pauses product edits, makes the composite amendment lifecycle authoritative over the slice status, and routes through the owned plan-write, approval, and activation transitions before delivery resumes. Stop immediately for ambiguous or stale state, unsafe capability, unsupported recovery, branch mismatch, or exhausted repairs. Store no durable run/autopilot mode; re-invocation reconstructs progress from canonical repository state. ### Reply shortcuts @@ -269,7 +269,7 @@ If gstack is installed, its review skills can execute these lenses. If Spec Kit ### Literal planning transport -Feature artifacts are authored only from the read-only, mode-aware `flow bootstrap` oracle. Supply the selected feature, durable in-repo source-plan path, artifact name, target shell, and complete Markdown through the current Boatstack operation entrypoint. The oracle verifies the worktree and source-plan digest and returns a `planning_envelope` bound to the exact embedded launcher or detached helper. Execute that envelope unchanged. +Feature artifacts are authored only from the read-only, mode-aware `flow bootstrap` oracle. Supply the selected feature, durable in-repo source-plan path, artifact name, target shell, and complete Markdown through the current Boatstack operation entrypoint. For an active amendment, the oracle also binds the current lifecycle fingerprint, observation, and prior plan lock. The writer rejects any drift before mutation. The oracle verifies the worktree and source-plan digest and returns a `planning_envelope` bound to the exact embedded launcher or detached helper. Execute that envelope unchanged. The oracle emits a non-colliding single-quoted heredoc for Bash, zsh, and Git Bash. For Windows PowerShell it emits a UTF-8-scoped single-quoted here-string. A document containing a PowerShell closing marker must use `--shell posix` with Git Bash. Do not select, append, or rewrite an executable path yourself. diff --git a/boatstack/safety.go b/boatstack/safety.go index bfefa9c..90dedae 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -232,12 +232,16 @@ var stageMutationVerbs = map[string][]string{ // nothing to protect before init), but the prescription layer names init // there — declaring the row keeps the admission tables total over every // stage the solution set can emit (guard-never-prescribes-what-it-would-deny). - "NOT_INITIALIZED": {"init"}, - "DRAFT_PLAN": {"planning-write", "record-approval", "record-autonomy", "workspace-cut"}, - "INVALID_STATE": {"planning-write", "record-approval", "record-autonomy"}, - "APPROVED": {"activate-plan", "workspace-cut"}, - "POLICY_READY": {"activate-plan", "workspace-cut"}, - "NOT_STARTED": {"planning-write"}, + "NOT_INITIALIZED": {"init"}, + "DRAFT_PLAN": {"planning-write", "record-approval", "record-autonomy", "workspace-cut"}, + "INVALID_STATE": {"planning-write", "record-approval", "record-autonomy"}, + "APPROVED": {"activate-plan", "workspace-cut"}, + "POLICY_READY": {"activate-plan", "workspace-cut"}, + "NOT_STARTED": {"planning-write"}, + "AMENDMENT_REQUIRED": {"planning-write"}, + "AMENDMENT_DRAFTED": {"planning-write", "record-approval"}, + "AMENDMENT_APPROVED": {"planning-write", "record-approval", "activate-plan"}, + "PLAN_INVALID": {"planning-write"}, } func controlledPhaseTransition(command, stage string) bool { diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go index b22237a..ea37b14 100644 --- a/boatstack/solution_closure_conformance_test.go +++ b/boatstack/solution_closure_conformance_test.go @@ -24,6 +24,10 @@ import ( // as shell metacharacters). The closure property is defined over substituted // lines: what the user runs after filling the owed input. func substituteOwedFlags(line string) string { + line = strings.ReplaceAll(line, "'--feature' ''", "'--feature' 'demo'") + line = strings.ReplaceAll(line, "'--artifact' ''", "'--artifact' 'plan.md'") + line = strings.ReplaceAll(line, "'--source-plan' ''", "'--source-plan' 'README.md'") + line = strings.ReplaceAll(line, "'--shell' ''", "'--shell' 'posix'") line = strings.ReplaceAll(line, "--feature ''", "--feature demo") line = strings.ReplaceAll(line, "--artifact ''", "--artifact plan.md") line = strings.ReplaceAll(line, "--source-plan ''", "--source-plan README.md") @@ -52,6 +56,10 @@ var deliveryStages = []NextStatus{ {VerificationStatus: "VERIFIED", ObservedStage: "TEST_PASSED", NextOperation: "review-gate", Feature: "demo", ActiveSlice: "s1"}, {VerificationStatus: "VERIFIED", ObservedStage: "REVIEW_PASSED", NextOperation: "ship-gate", Feature: "demo", ActiveSlice: "s1"}, {VerificationStatus: "VERIFIED", ObservedStage: "PUBLISHED", NextOperation: "none", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_REQUIRED", NextOperation: "amend-plan", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_DRAFTED", NextOperation: "plan-gate", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "AMENDMENT_APPROVED", NextOperation: "build", Feature: "demo", ActiveSlice: "s1"}, + {VerificationStatus: "VERIFIED", ObservedStage: "PLAN_INVALID", NextOperation: "amend-plan", Feature: "demo", ActiveSlice: "s1"}, } // Positive/Relation: every pre-activation option is admitted by diff --git a/release-notes/2026-08-10-composite-delivery-lifecycle.md b/release-notes/2026-08-10-composite-delivery-lifecycle.md new file mode 100644 index 0000000..c7e7b55 --- /dev/null +++ b/release-notes/2026-08-10-composite-delivery-lifecycle.md @@ -0,0 +1,3 @@ +### Active plan amendments now have a complete recovery path + +Boatstack now resolves delivery mode, slice position, plan authority, observation, workspace, and approval as one lifecycle state. Requirement amendments and invalid active plans route through lifecycle-bound planning, approval, and reactivation before delivery gates resume. CI inventories every durable delivery field and lifecycle entry path so a new control dimension cannot be omitted silently.