diff --git a/IMPORT_PROVENANCE.json b/IMPORT_PROVENANCE.json index 49da42c..bde8f96 100644 --- a/IMPORT_PROVENANCE.json +++ b/IMPORT_PROVENANCE.json @@ -259,7 +259,6 @@ "docs/safety.md": "1a2b84e0a4b9aa6322d35d6677ff52662c306089031295692b569233cdd94d8f", "docs/troubleshooting.md": "252937c97d40197e9122e20f421f90616aecdd5ddf50cee8bdd348bc6c5caf32", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", - "docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09", "install.ps1": "f48d0f26a26e806b780d10fa916c261ff9f84ab39758cf9e229f647836845e86", "install.sh": "2575f82568b76b14e72fb88de4e8af677da1e77b9cf1a085b4ddfbbd766e67f9", "labs/diagram-json/README.md": "f56a120877c8a3b10daa49c6d951481c02e98b8b8bb3f28672e9e092d97a37bb", diff --git a/README.md b/README.md index 2cb1490..db640ba 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Boatstack is a repository-local delivery harness. - **Observed:** benchmark runs exposed failures in protocol handling, context, verification, and recovery — not only model capability. - **Being evaluated:** whether this improves product quality, cost, or delivery time with lower-cost models. -This does not mean every model performs equally. [See the evidence and paired evaluation design](docs/why-these-steps.md#model-choice-and-budget). +This does not mean every model performs equally. [See the evidence and evaluation design](docs/research-and-design.md#evaluation-of-the-finished-node). ## Built from failures observed in real coding work @@ -165,9 +165,9 @@ These behaviors come from coding failures observed in benchmark and product work | A failed write led to an invented reset path | Denies high-confidence destructive recovery | Hook behavior verified; outcome benefit still being evaluated | | A PR lost decisions and accepted gaps | Builds a review brief from scope, diff, and evidence | Projection and stale-preview tests | | A phased plan opened PRs during build | Gates and publishes one delivery slice at a time | Slice-state and bypass tests | -| A worktree inherited no ignored helper | Uses its tracked pinned launcher to verify and activate the exact runtime before command dispatch | Linked-worktree, identity, and tamper tests | +| A feature worktree lost its helper or stranded its validated plan | Verifies the pinned runtime and moves the exact planning package before approval or autonomy | Linked-worktree, identity, rollback, and plan-fingerprint tests | -[Read what happened, what is tested, and what remains open](docs/why-these-steps.md). The [claim record](docs/public-claims.json) keeps every material statement tied to its sources. +The [claim record](docs/public-claims.json) keeps every material statement tied to its sources and tests. ## A small example @@ -204,7 +204,7 @@ The installer previews generated paths, verifies the platform helper, offers opt **Start:** [Getting started](docs/getting-started.md) · [Files](docs/generated-files.md) · [Troubleshooting](docs/troubleshooting.md) -**Inspect:** [Why these steps](docs/why-these-steps.md) · [Validation and evidence](docs/validation-and-evidence.md) · [Safety](docs/safety.md) +**Inspect:** [Research and design](docs/research-and-design.md) · [Validation and evidence](docs/validation-and-evidence.md) · [Safety](docs/safety.md) **Go deeper:** [Coding](docs/evidence-engineered-coding.md) · [Design](docs/research-and-design.md) · [Contributing](CONTRIBUTING.md) diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 8b2c247..7d59f04 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -36,7 +36,7 @@ To see every feature at once, run the read-only `.product-loop/boatstack flow fr ## Run to an explicit goal -For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target from the request. When it is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. Record the selected target with `record-autonomy --plan --target ` after all material questions are answered or every remaining question has a valid `RESOLVED_BY_POLICY` autonomy decision. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy ` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. +For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target from the request. When it is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. When workspace management is enabled, run `workspace-cut` after the plan passes validation and continue every later command from its `destination_repository`. Only then record human approval or the selected autonomy target, so the receipt binds the final feature branch. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy ` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation, verify the resulting repository state, and resolve again. Continue across all declared slices until the selected target is reached. A policy receipt may resolve only a non-material, within-spec, reversible choice with one recommendation, repository evidence, no protected impact, and a runnable oracle. Record it as `RESOLVED_BY_POLICY`, never `ANSWERED`. Any failed or unknown condition pauses for the human. Target `verified` stops after current test and review evidence passes. Target `pr` supplies scoped authority for one normal open or update action recorded in `autonomy.md`; after the exact preview is revalidated, call `publish-pr --autonomy ` without asking for `o` or `u`. A changed plan, repository, branch, PR action, preview, evidence, or target invalidates that path. Same-intent test/review failures may be repaired for at most three complete cycles per active slice. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or exhausted repairs. Never force-push, merge, deploy, or execute a foreign program. @@ -135,10 +135,11 @@ Treat repository-owned product context as canonical. Do not require it to be mig ``` 2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, `PLAN_FINGERPRINT`, and `READINESS_FINGERPRINT` in a reviewable form. A schema-v3 plan must decide `journey_evidence`: `relevant` with complete typed runnable oracles, or `not_relevant` with a reason. -3. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval. -4. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft. -5. When human approval is enabled, invoke `.product-loop/boatstack record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`. -6. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code. +3. When workspace management is enabled, run `workspace-cut` with this feature. Continue from the returned `destination_repository`. The validated plan fingerprint must remain unchanged. +4. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval. +5. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft. +6. When human approval is enabled, invoke `.product-loop/boatstack record-approval` with the destination plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`. +7. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code. All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gstack and Spec Kit may help produce those documents, but their implementation stages and non-Markdown executable state are deferred to `build`. diff --git a/boatstack/autonomy.go b/boatstack/autonomy.go index a85f21a..43b0b47 100644 --- a/boatstack/autonomy.go +++ b/boatstack/autonomy.go @@ -14,6 +14,8 @@ const ( type RunTarget string +var autonomyRecommendedPRAction = RecommendedPRAction + const ( RunTargetPlan RunTarget = "plan" RunTargetVerified RunTarget = "verified" @@ -219,23 +221,23 @@ func RecordAutonomy(options AutonomyRecordOptions) (AutonomyReceipt, error) { if err != nil { return AutonomyReceipt{}, err } + feature := stringValue(check.Plan["feature_id"]) + if workspaceEnabled(repo) && needsFreshCut(repo, feature) { + return AutonomyReceipt{}, fmt.Errorf("autonomy requires the feature workspace; run workspace-cut --repo %s --feature %s and continue from destination_repository", repo, feature) + } branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD") branch = strings.TrimSpace(branch) if err != nil || branch == "" || branch == "HEAD" { return AutonomyReceipt{}, fmt.Errorf("autonomy requires an identifiable current branch") } issuingBranch := branch - feature := stringValue(check.Plan["feature_id"]) - if workspaceEnabled(repo) && needsFreshCut(repo, feature) { - branch = branchForFeature(feature) - } repository, err := gitCommand(repo, "remote", "get-url", "origin") if err != nil { return AutonomyReceipt{}, fmt.Errorf("autonomy requires an origin repository identity") } action := "" if target == RunTargetPR { - action, _, err = RecommendedPRAction(repo) + action, _, err = autonomyRecommendedPRAction(repo) if err != nil { return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action: %w", err) } diff --git a/boatstack/autonomy_conformance_test.go b/boatstack/autonomy_conformance_test.go index 3b767d9..80aa5d1 100644 --- a/boatstack/autonomy_conformance_test.go +++ b/boatstack/autonomy_conformance_test.go @@ -59,28 +59,13 @@ func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(t *testing.T) { } } -// control-law: pre-cut-policy-authority-binds-the-future-managed-branch -func TestAutonomyReceiptBindsFreshWorkspaceBranch(t *testing.T) { +// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch +func TestAutonomyReceiptRequiresFreshWorkspaceBranch(t *testing.T) { root := workspaceRepo(t, defaultWorkspace()) runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git") _, _, planPath := writePlanInputs(t, root, true) - receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified}) - if err != nil { - t.Fatal(err) - } - if receipt.IssuingBranch != "main" || receipt.Branch != "feat/feature-one" { - t.Fatalf("receipt branches = issuing %q target %q", receipt.IssuingBranch, receipt.Branch) - } - check, err := CheckPlan(planPath) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(filepath.Dir(planPath), "autonomy.md") - if _, err := CheckAutonomyReceiptForPlanning(path, check, root, RunTargetPlan); err != nil { - t.Fatalf("pre-cut planning check: %v", err) - } - if _, err := CheckAutonomyReceipt(path, check, root, RunTargetVerified, ""); err == nil || !strings.Contains(err.Error(), "branch identity changed") { - t.Fatalf("activation on issuing branch should fail, got %v", err) + if _, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified}); err == nil || !strings.Contains(err.Error(), "workspace-cut") { + t.Fatalf("pre-cut autonomy should name the workspace transition, got %v", err) } } diff --git a/boatstack/detached.go b/boatstack/detached.go index 55624ea..16392bc 100644 --- a/boatstack/detached.go +++ b/boatstack/detached.go @@ -207,6 +207,85 @@ func saveRegistry(stateRoot string, registry detachedRegistry) error { return os.WriteFile(registryPath(stateRoot), raw, 0o644) } +// registerDetachedWorkspaceAlias binds another worktree path of the same Git +// repository to the existing detached controller. It never creates new +// authority: origin, initial history, repository id, and Git common directory +// must match the already-verified source binding. +func registerDetachedWorkspaceAlias(sourceRepo, destinationRepo string) (bool, error) { + source, ok, err := detachedContextFor(sourceRepo) + if err != nil || !ok { + if err == nil { + err = fmt.Errorf("source repository is not attached in detached mode") + } + return false, err + } + destination, err := repoIdentity(destinationRepo) + if err != nil { + return false, err + } + if destination.RepoID != source.RepoID { + return false, fmt.Errorf("destination repository identity does not match the detached controller") + } + binding, err := loadBinding(filepath.Dir(filepath.Dir(source.controlRoot)), source.RepoID) + if err != nil { + // controlRoot is /repositories/; resolve the state root + // directly when a non-standard layout makes the derivation ambiguous. + stateRoot, rootErr := detachedStateRoot() + if rootErr != nil { + return false, rootErr + } + binding, err = loadBinding(stateRoot, source.RepoID) + } + if err != nil || !bindingMatchesIdentity(binding, destination) || binding.GitCommonIdentity != destination.GitCommonIdentity { + return false, fmt.Errorf("destination worktree does not match the detached binding") + } + stateRoot, err := detachedStateRoot() + if err != nil { + return false, err + } + registry, err := loadRegistry(stateRoot) + if err != nil { + return false, err + } + root := destination.CanonicalRepoPath + if existing, found := registry.Repositories[root]; found { + if existing != source.RepoID { + return false, fmt.Errorf("destination worktree is already bound to another controller") + } + return false, nil + } + registry.Repositories[root] = source.RepoID + if err := saveRegistry(stateRoot, registry); err != nil { + return false, err + } + invalidateWorkspaceCache() + return true, nil +} + +func unregisterDetachedWorkspaceAlias(repo string) error { + root, err := ResolveRepository(repo) + if err != nil { + return err + } + stateRoot, err := detachedStateRoot() + if err != nil { + return err + } + registry, err := loadRegistry(stateRoot) + if err != nil { + return err + } + if _, found := registry.Repositories[root]; !found { + return nil + } + delete(registry.Repositories, root) + if err := saveRegistry(stateRoot, registry); err != nil { + return err + } + invalidateWorkspaceCache() + return nil +} + func loadBinding(stateRoot, repoID string) (DetachedBinding, error) { var binding DetachedBinding raw, err := os.ReadFile(bindingPath(stateRoot, repoID)) diff --git a/boatstack/export.go b/boatstack/export.go index 82c3eb5..fea6199 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -390,14 +390,14 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", - "workspace-cut": "Cut a fresh managed workspace for an approved feature before building, so work never starts on a stale branch. Surfaced by boatstack-next at the approved-to-build transition when workspace.enabled and the working tree is still on the default branch; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-cut --repo . --feature . It fetches origin, creates a new branch from the up-to-date default branch, and in worktree mode adds a linked worktree; it never rewrites history, reuses an existing branch, or names the workspace after the base branch. Report the created branch and, in worktree mode, its path, then continue to build on the new workspace.", + "workspace-cut": "Move a validated planning package into its final feature workspace before approval or autonomy is recorded. Surfaced by boatstack-next after check-plan passes when workspace.enabled; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-cut --repo . --feature . It fetches origin and creates, adopts, or reuses only an unowned exact-base destination with the same plan fingerprint and controller identity. Diverged, dirty, owned, or conflicting destinations fail closed without moving plan authority. Report destination_repository, branch, base_commit, plan_fingerprint, controller_mode, and outcome, then continue every later command from destination_repository.", "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } - operations["boatstack-run"] = "Resolve an explicit target from --to plan|verified|pr or the user's wording; when absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --json and operation-status first and reconcile in-flight work. 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 record-autonomy with the selected target. 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"] = "Resolve an explicit target from --to plan|verified|pr or the user's wording; when absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --json and operation-status first and reconcile in-flight work. 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. 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["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. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." + 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) operations["build"] = strings.Replace(operations["build"], "so all four land", "so all five land", 1) operations["build"] += " Activation repeats readiness atomically and compiles journey-oracles.json into the same immutable authority boundary." @@ -449,7 +449,7 @@ description: Use when the user asks what is next in Boatstack, asks Boatstack to Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. Write every response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word. -Ordinary product intent must first be explored in the host's Plan mode and saved as a file. The host passes that plan to auto-plan explicitly with --plan , which auto-plan validates and records as source_plan_path. Boatstack never scans directories for plans, so --plan is required. The plan must live inside the repository so it stays committed and hash-current through build. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, approval.md records explicit human acceptance, and autonomy.md records invocation-scoped policy authority for $boatstack run --to plan|verified|pr. Repository facts are DISCOVERED, agent suggestions are PROPOSED, human responses are ANSWERED, and eligible autonomous choices are RESOLVED_BY_POLICY. Every material proposal remains blocking. At build, confirm the host can edit product code before activation. A rejected mode transition creates no machine artifacts or lock. Activation compiles machine artifacts and binds either human approval or the exact autonomy receipt before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the authorization lock, diff, and evidence after build. +Ordinary product intent must first be explored in the host's Plan mode and saved as a file. The host passes that plan to auto-plan explicitly with --plan , which auto-plan validates and records as source_plan_path. Boatstack never scans directories for plans, so --plan is required. The plan must live inside the repository so it stays committed and hash-current through build. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, approval.md records explicit human acceptance, and autonomy.md records invocation-scoped policy authority for $boatstack run --to plan|verified|pr. Repository facts are DISCOVERED, agent suggestions are PROPOSED, human responses are ANSWERED, and eligible autonomous choices are RESOLVED_BY_POLICY. Every material proposal remains blocking. When workspace management is enabled, move the validated package with workspace-cut and continue from destination_repository before recording approval or autonomy. At build, confirm the host can edit product code before activation. A rejected mode transition creates no machine artifacts or lock. Activation compiles machine artifacts and binds either human approval or the exact autonomy receipt before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the authorization lock, diff, and evidence after build. Internal phases are ordinary tasks inside one delivery slice. Multiple PRs require explicit ordered delivery_slices with every task assigned exactly once. After activation, read delivery-status and work only on the active slice. Test-gate and review-gate must record slice-scoped receipts bound to the current branches, commit, diff, and evidence. Direct push, PR mutation, and ad-hoc PR routing are denied while managed delivery is active. Successful confirmed publication advances exactly one slice; plan approval never authorizes later slices. diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index fc7f0a9..667b994 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -466,6 +466,10 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri Transition: MarkerPlanningCheckSource, }, fmt.Sprintf("Then run auto-plan with the validated SOURCE_PLAN path; author every feature artifact through one complete literal `%s planning-write` envelope from `%s`.", projectLocalLauncherCommand(), generatedWorkflowReference())) case "DRAFT_PLAN": + if status.NextOperation == "workspace-cut" { + return finish(buildWorkspaceCut(repoArgs, status.Feature), + "Continue from destination_repository, then check and approve the same plan fingerprint.") + } return finish(&PrescribedCommand{ Verb: "check-plan", Args: []string{"--plan", filepath.Join(featureDir, "plan.md")}, diff --git a/boatstack/flow_planning_prescribe_conformance_test.go b/boatstack/flow_planning_prescribe_conformance_test.go index 984dc33..1e8a5c1 100644 --- a/boatstack/flow_planning_prescribe_conformance_test.go +++ b/boatstack/flow_planning_prescribe_conformance_test.go @@ -35,6 +35,7 @@ var planningStages = []NextStatus{ {ObservedStage: "NOT_INITIALIZED", NextOperation: "init"}, {ObservedStage: "NOT_STARTED", NextOperation: "auto-plan"}, {ObservedStage: "DRAFT_PLAN", NextOperation: "plan-gate", Feature: "demo"}, + {ObservedStage: "DRAFT_PLAN", NextOperation: "workspace-cut", Feature: "demo"}, {ObservedStage: "APPROVED", NextOperation: "build", Feature: "demo"}, {ObservedStage: "APPROVED", NextOperation: "workspace-cut", Feature: "demo"}, {ObservedStage: "POLICY_READY", NextOperation: "build", Feature: "demo"}, diff --git a/boatstack/flow_solutions.go b/boatstack/flow_solutions.go index 3e4bc57..518320b 100644 --- a/boatstack/flow_solutions.go +++ b/boatstack/flow_solutions.go @@ -132,6 +132,12 @@ func enumeratePlanningSolutions(repo string, status NextStatus, next FlowNext) S appendSolution(&set, *next.Prescribed) } for _, verb := range stageMutationVerbs[status.ObservedStage] { + // workspace-cut gains authority from a successful plan check, not from + // DRAFT_PLAN alone. Keep it out of the alternative set unless ResolveNext + // selected that exact transition for the current validated package. + if verb == "workspace-cut" && status.NextOperation != "workspace-cut" { + continue + } if cmd, ok := prescribePlanningVerb(repo, status, verb); ok { appendSolution(&set, *cmd) } diff --git a/boatstack/next.go b/boatstack/next.go index 2d461dc..1f9ad1e 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -474,6 +474,15 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr base.ObservedStage = "DRAFT_PLAN" base.NextOperation = "plan-gate" base.Reason = "The saved feature plan has not been approved." + // Bind the plan to its final feature workspace before readiness, + // approval, or autonomy records branch identity. Incomplete plans stay + // on plan-gate and never gain workspace authority. + if workspaceEnabled(repo) && needsFreshCut(repo, feature) { + if _, planErr := CheckPlan(filepath.Join(directory, "plan.md")); planErr == nil { + base.NextOperation = "workspace-cut" + base.Reason = fmt.Sprintf("Feature %q has a valid plan; establish its branch workspace before readiness and approval.", feature) + } + } } return decorateAutonomyStatus(repo, base), nil } diff --git a/boatstack/readiness.go b/boatstack/readiness.go index 616a5b2..07ece05 100644 --- a/boatstack/readiness.go +++ b/boatstack/readiness.go @@ -47,7 +47,11 @@ func CheckPlanReadiness(planPath string) (ReadinessReceipt, error) { if err != nil { return ReadinessReceipt{}, fmt.Errorf("readiness requires valid Boatstack configuration: %w", err) } - if err := guardManagedActivationWorktree(repo, config, stringValue(check.Plan["feature_id"])); err != nil { + feature := stringValue(check.Plan["feature_id"]) + if resolveWorkspace(config.Workspace).Enabled && needsFreshCut(repo, feature) { + return ReadinessReceipt{}, fmt.Errorf("readiness requires the feature workspace; run workspace-cut --repo %s --feature %s and continue from destination_repository", repo, feature) + } + if err := guardManagedActivationWorktree(repo, config, feature); err != nil { return ReadinessReceipt{}, err } preflight := CheckRunPreflight(repo, "") diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 887c050..26b383e 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -139,7 +139,7 @@ The read-only `next` status query is the one exception, because a status questio | `ship-gate` preview / published | **PR ready** -> reply `o` to open or `u` to update the previewed PR; **PR opened** -> review the PR; never imply merge authorization | | `boatstack-update` current / postponed / prepared / published / blocked | **Boatstack is current** -> no action required; **Update postponed** -> finish feature work and rerun from the clean default branch; **Boatstack update ready** -> reply `o` to open the update PR; **Update PR opened** -> review the PR; **Update needs attention** -> address the one reported collision or health failure | | `retro` | **Improvement proposed** -> review or authorize the experiment | -| `workspace-cut` (surfaced by `boatstack-next` at approved -> build) | **Fresh workspace cut** -> build on the new branch/worktree; **Workspace already fresh** -> continue to build | +| `workspace-cut` (surfaced after plan validation and before approval or autonomy) | **Fresh workspace ready** -> continue every later command from the returned destination; **Workspace already current** -> continue there | | `workspace-cleanup` (surfaced by `boatstack-next` after publication) | **Workspace ready to clean up** -> reply `c` to remove the worktree and branch, or `k` to keep; **Workspace kept** -> no action required; **Workspace still open** -> the PR is not merged yet, keep waiting or override explicitly | ### Foreground run coordinator @@ -513,9 +513,11 @@ There is no public `/pr-brief` operation. When the user asks in natural language Adaptive sections for security/privacy, migrations, UI evidence, or operations appear only when relevant. Model attribution belongs inside collapsed provenance. If GitHub CLI authentication is unavailable, keep the validated preview and provide one manual publication action instead of losing the work. -### `PLAN_APPROVED -> WORKSPACE_CUT` +### `DRAFT_PLAN -> WORKSPACE_CUT -> APPROVAL OR AUTONOMY` -When `workspace.enabled` is set and an approved feature is still on the default branch with no branch or worktree of its own, `boatstack-next` routes to `workspace-cut` before `build`. The `workspace-cut` operation fetches `origin`, cuts a fresh branch from the up-to-date default branch, and in `worktree` mode adds a linked worktree; in `branch` mode it switches in place. It never rewrites history, reuses an existing branch, or names the workspace after the base branch. Once the feature already has its own branch or worktree, this step is skipped and the flow proceeds straight to `build`, so a workspace you cut yourself is respected. +When `workspace.enabled` is set and a draft plan passes validation, `boatstack-next` routes to `workspace-cut` before human approval or autonomy is recorded. The operation fetches `origin` and selects the feature branch from that exact base. It creates a branch or worktree, adopts an unowned exact-base branch, or reuses a matching current worktree. It returns the destination repository, branch, base commit, plan fingerprint, controller mode, and outcome. The host continues every later command from that destination, so approval, autonomy, readiness, and activation bind the final feature branch. + +In embedded mode, the complete planning package moves transactionally. Boatstack verifies the destination fingerprint before removing the source. In detached mode, the destination registers against the same repository controller identity. A divergent, dirty, owned, or conflicting destination fails closed before plan authority moves. A failed copy, registration, cleanup, or postcondition check restores the source package and removes partial branch, worktree, and controller authority. Workspace-disabled repositories keep their manual branch policy. ### `PR_OPEN -> WORKSPACE_CLEANUP` diff --git a/boatstack/safety.go b/boatstack/safety.go index 02e1f05..de1fa5c 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -221,7 +221,7 @@ var stageMutationVerbs = map[string][]string{ // 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"}, + "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"}, diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go index 59a42d6..ac4f4d2 100644 --- a/boatstack/solution_closure_conformance_test.go +++ b/boatstack/solution_closure_conformance_test.go @@ -1,6 +1,7 @@ package boatstack import ( + "path/filepath" "strings" "testing" @@ -88,6 +89,36 @@ func TestPlanningSolutionSetIsClosedUnderGuardAdmission(t *testing.T) { } } +// Relation/effect: the prescribed workspace command must not merely pass the +// text guard. Its verified post-state must expose the identical plan on the +// feature branch and make activation the next concrete command after approval. +// control-law: prescriptive-closure-every-stage-names-a-runnable-command +// control-law: workspace-transition-preserves-plan-authority +func TestPrescribedWorkspaceTransitionReachesActivatableState(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + next, err := NextControl(repo, "") + if err != nil || next.Prescribed == nil || next.Prescribed.Verb != "workspace-cut" { + t.Fatalf("valid draft did not prescribe workspace-cut: %+v (%v)", next, err) + } + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if err != nil || result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint { + t.Fatalf("prescribed transition did not establish its post-state: %+v (%v)", result, err) + } + planPath := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md") + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + writeApprovalReceipt(t, filepath.Join(filepath.Dir(planPath), "approval.md"), check.Fingerprint) + after, err := NextControl(result.DestinationRepo, "") + if err != nil || after.Prescribed == nil || after.Prescribed.Verb != "activate-plan" { + t.Fatalf("workspace post-state is not activatable: %+v (%v)", after, err) + } +} + // Positive/Relation: every delivery-state option is either a legal move on the // registry graph from that exact state, or an observation row that accepts the // state. Nothing outside the declared model is ever offered. diff --git a/boatstack/workspace.go b/boatstack/workspace.go index c10b7ac..59a49d3 100644 --- a/boatstack/workspace.go +++ b/boatstack/workspace.go @@ -1,13 +1,16 @@ package boatstack import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io/fs" "os" "path/filepath" "strings" ) -const workspaceSchemaVersion = 1 +const workspaceSchemaVersion = 2 // workspaceGit and workspaceGh are indirected so tests can substitute // deterministic git and GitHub CLI behavior. They default to the same helpers @@ -17,6 +20,11 @@ var ( workspaceGh = func(repo string, arguments ...string) (string, error) { return commandOutput(repo, "gh", arguments...) } + workspacePackageCopy = copyFeaturePackage + workspaceSourcePackageRemove = os.RemoveAll + workspaceDetachedAlias = registerDetachedWorkspaceAlias + workspaceAfterDestination = func(string) error { return nil } + workspaceAfterDetachedAlias = func(string) error { return nil } ) // ResolvedWorkspace is the workspace policy with empty fields filled from the @@ -74,20 +82,17 @@ func reapEnabled(repo string) bool { return policy.Enabled && policy.Reap != "off" } -// needsFreshCut reports whether an approved feature still has to be moved off the -// base branch onto its own fresh workspace. It is a local-only check: true only -// when the feature has no existing branch or worktree and the working tree is -// still on the default branch. +// needsFreshCut reports whether the caller still has to enter the feature's +// branch workspace. Existence is not readiness: a detached HEAD, the base branch, +// another feature branch, or a sibling worktree all require the managed +// transition so the planning package and execution directory move together. func needsFreshCut(repo, feature string) bool { branch := branchForFeature(feature) if branch == "" { return false } - if branchExists(repo, branch) || worktreePathForBranch(repo, branch) != "" { - return false - } current, _ := workspaceGit(repo, "branch", "--show-current") - return strings.TrimSpace(current) == defaultPRBase(repo) + return strings.TrimSpace(current) != branch } // isMainWorktree reports whether repo is checked out in the repository's main @@ -171,9 +176,15 @@ type WorkspaceCut struct { SchemaVersion int `json:"schema_version"` VerificationStatus string `json:"verification_status"` Mode string `json:"mode,omitempty"` + ControllerMode string `json:"controller_mode,omitempty"` + Outcome string `json:"outcome,omitempty"` // created | adopted | current BaseBranch string `json:"base_branch,omitempty"` + BaseCommit string `json:"base_commit,omitempty"` Branch string `json:"branch,omitempty"` WorktreePath string `json:"worktree_path,omitempty"` + SourceRepository string `json:"source_repository,omitempty"` + DestinationRepo string `json:"destination_repository,omitempty"` + PlanFingerprint string `json:"plan_fingerprint,omitempty"` Created bool `json:"created"` Reason string `json:"reason"` } @@ -182,10 +193,217 @@ func blockedCut(reason string) WorkspaceCut { return WorkspaceCut{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} } -// CutFeatureWorkspace creates a fresh branch (and, in worktree mode, a linked -// worktree) rooted at the freshly-fetched default branch. It never switches an -// existing branch's history, never deletes anything, and refuses to reuse a -// branch name that already exists. +type workspaceTransition struct { + branchCreated bool + branchSwitched bool + worktreeCreated bool + detachedAlias bool + originalBranch string + originalHead string +} + +func rollbackWorkspaceTransition(repo, branch, worktreePath string, transition workspaceTransition) { + if transition.detachedAlias { + _ = unregisterDetachedWorkspaceAlias(worktreePath) + } + if transition.worktreeCreated && worktreePath != "" { + _, _ = workspaceGit(repo, "worktree", "remove", "--force", worktreePath) + } + if transition.branchSwitched { + if transition.originalBranch != "" { + _, _ = workspaceGit(repo, "switch", transition.originalBranch) + } else if transition.originalHead != "" { + _, _ = workspaceGit(repo, "checkout", "--detach", transition.originalHead) + } + } + if transition.branchCreated { + _, _ = workspaceGit(repo, "branch", "-D", branch) + } +} + +func featurePackageFingerprint(directory string) (string, error) { + planPath := filepath.Join(directory, "plan.md") + if !fileExists(planPath) { + return "", nil + } + check, err := CheckPlan(planPath) + if err != nil { + return "", err + } + return check.Fingerprint, nil +} + +func featurePackageDigest(directory string) (string, error) { + digest := sha256.New() + err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(directory, path) + if err != nil || relative == "." { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) { + return fmt.Errorf("planning package contains unsupported entry %s", relative) + } + kind := "file" + if info.IsDir() { + kind = "directory" + } + _, _ = digest.Write([]byte(kind + "\x00" + filepath.ToSlash(relative) + "\x00")) + if info.IsDir() { + return nil + } + value, err := os.ReadFile(path) + if err != nil { + return err + } + _, _ = digest.Write(value) + _, _ = digest.Write([]byte{0}) + return nil + }) + if err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func copyFeaturePackage(source, destination string) error { + parent := filepath.Dir(destination) + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + temporary, err := os.MkdirTemp(parent, ".boatstack-workspace-transfer-") + if err != nil { + return err + } + defer os.RemoveAll(temporary) + err = filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(source, path) + if err != nil || relative == "." { + return err + } + target := filepath.Join(temporary, relative) + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) { + return fmt.Errorf("planning package contains unsupported entry %s", relative) + } + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + value, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + return os.WriteFile(target, value, info.Mode().Perm()) + }) + if err != nil { + return err + } + return os.Rename(temporary, destination) +} + +func dirtyOutsideFeature(repo, feature string) (bool, error) { + output, err := workspaceGit(repo, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + return false, err + } + prefix := filepath.ToSlash(filepath.Join(productLoopDirName, "features", feature)) + "/" + for _, line := range strings.Split(output, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + path := strings.TrimSpace(line[2:]) + if arrow := strings.LastIndex(path, " -> "); arrow >= 0 { + path = path[arrow+4:] + } + path = strings.Trim(path, "\"") + normalized := filepath.ToSlash(path) + if !strings.HasPrefix(normalized, prefix) { + return true, nil + } + } + return false, nil +} + +// transferFeaturePackage moves a validated embedded planning package into the +// destination worktree. The source remains authoritative until the destination +// fingerprint verifies. A failed source cleanup removes the new copy, so the +// boundary never leaves two authoritative packages. +func transferFeaturePackage(sourceRepo, destinationRepo, feature string, controllerMode SupervisionMode) (string, error) { + if feature == "" { + return "", nil + } + source := WorkspaceFor(sourceRepo).FeatureDir(feature) + if source == "" || !dirExists(source) { + destination := WorkspaceFor(destinationRepo).FeatureDir(feature) + if destination == "" || !dirExists(destination) { + return "", nil + } + return featurePackageFingerprint(destination) + } + sourceFingerprint, err := featurePackageFingerprint(source) + if err != nil { + return "", fmt.Errorf("source planning package is invalid: %w", err) + } + if sourceFingerprint == "" { + return "", nil + } + sourceDigest, err := featurePackageDigest(source) + if err != nil { + return "", fmt.Errorf("source planning package cannot be fingerprinted: %w", err) + } + destination := WorkspaceFor(destinationRepo).FeatureDir(feature) + if filepath.Clean(source) == filepath.Clean(destination) { + return sourceFingerprint, nil + } + if dirExists(destination) { + destinationFingerprint, fingerprintErr := featurePackageFingerprint(destination) + destinationDigest, digestErr := featurePackageDigest(destination) + if fingerprintErr != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest { + return "", fmt.Errorf("destination workspace contains a conflicting planning package") + } + if controllerMode == SupervisionEmbedded { + if err := workspaceSourcePackageRemove(source); err != nil { + return "", fmt.Errorf("remove transferred source package: %w", err) + } + } + return sourceFingerprint, nil + } + if err := workspacePackageCopy(source, destination); err != nil { + return "", fmt.Errorf("copy planning package: %w", err) + } + destinationFingerprint, err := featurePackageFingerprint(destination) + destinationDigest, digestErr := featurePackageDigest(destination) + if err != nil || digestErr != nil || destinationFingerprint != sourceFingerprint || destinationDigest != sourceDigest { + _ = os.RemoveAll(destination) + return "", fmt.Errorf("destination planning package fingerprint did not verify") + } + if controllerMode == SupervisionEmbedded { + if err := workspaceSourcePackageRemove(source); err != nil { + _ = os.RemoveAll(destination) + return "", fmt.Errorf("remove transferred source package: %w", err) + } + } + return sourceFingerprint, nil +} + +// CutFeatureWorkspace establishes the feature's execution workspace and carries +// its validated planning package across the boundary. Existing branches are +// adopted only when unowned and byte-identical to the fetched base. func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) { repo, err := ResolveRepository(options.Repo) if err != nil { @@ -227,36 +445,142 @@ func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) { return blockedCut(err.Error()), nil } - if _, existsErr := workspaceGit(repo, "rev-parse", "--verify", "refs/heads/"+branch+"^{commit}"); existsErr == nil { - return blockedCut(fmt.Sprintf("Branch %q already exists; choose a new feature or clean up the old workspace first.", branch)), nil + sourceContext, err := ResolveWorkspaceContext(repo) + if err != nil { + return blockedCut(err.Error()), nil } - result := WorkspaceCut{ SchemaVersion: workspaceSchemaVersion, VerificationStatus: "VERIFIED", Mode: policy.Mode, + ControllerMode: string(sourceContext.Mode), BaseBranch: base, + BaseCommit: strings.TrimSpace(baseCommit), Branch: branch, - Created: true, + SourceRepository: repo, + } + originalBranch, _ := workspaceGit(repo, "branch", "--show-current") + originalHead, _ := workspaceGit(repo, "rev-parse", "HEAD^{commit}") + transition := workspaceTransition{ + originalBranch: strings.TrimSpace(originalBranch), + originalHead: strings.TrimSpace(originalHead), } + destinationRepo := repo switch policy.Mode { case "branch": - if _, err := workspaceGit(repo, "switch", "-c", branch, baseCommit); err != nil { - return blockedCut("Boatstack could not create the branch: " + err.Error()), nil + current, _ := workspaceGit(repo, "branch", "--show-current") + if strings.TrimSpace(current) == branch { + headCommit, _ := workspaceGit(repo, "rev-parse", "HEAD^{commit}") + if strings.TrimSpace(headCommit) != strings.TrimSpace(baseCommit) { + return blockedCut(fmt.Sprintf("Current branch %q diverges from the fetched base.", branch)), nil + } + result.Outcome = "current" + } else if branchExists(repo, branch) { + branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}") + if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) { + return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be adopted.", branch)), nil + } + owner, ownerErr := activeDeliveryOwningBranch(repo, branch) + if ownerErr != nil { + return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil + } + if owner != "" { + return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be adopted.", branch)), nil + } + if _, err := workspaceGit(repo, "switch", branch); err != nil { + return blockedCut("Boatstack could not adopt the branch: " + err.Error()), nil + } + transition.branchSwitched = true + result.Outcome = "adopted" + } else { + if _, err := workspaceGit(repo, "switch", "-c", branch, baseCommit); err != nil { + return blockedCut("Boatstack could not create the branch: " + err.Error()), nil + } + transition.branchCreated = true + transition.branchSwitched = true + result.Created = true + result.Outcome = "created" } - result.Reason = fmt.Sprintf("Cut fresh branch %q from %s.", branch, base) default: // "worktree" - path := filepath.Join(repo, ".product-loop", "worktrees", previewSlug(branch)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return blockedCut("Boatstack could not prepare the worktree directory: " + err.Error()), nil - } - if _, err := workspaceGit(repo, "worktree", "add", "-b", branch, path, baseCommit); err != nil { - return blockedCut("Boatstack could not add the worktree: " + err.Error()), nil + path := worktreePathForBranch(repo, branch) + if path != "" { + branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}") + if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) { + return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be reused.", branch)), nil + } + owner, ownerErr := activeDeliveryOwningBranch(repo, branch) + if ownerErr != nil { + return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil + } + if owner != "" { + return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be reused.", branch)), nil + } + destinationRepo = path + result.Outcome = "current" + } else { + path = filepath.Join(repo, ".product-loop", "worktrees", previewSlug(branch)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return blockedCut("Boatstack could not prepare the worktree directory: " + err.Error()), nil + } + if branchExists(repo, branch) { + branchCommit, _ := workspaceGit(repo, "rev-parse", "refs/heads/"+branch+"^{commit}") + if strings.TrimSpace(branchCommit) != strings.TrimSpace(baseCommit) { + return blockedCut(fmt.Sprintf("Branch %q diverges from the fetched base and cannot be adopted.", branch)), nil + } + owner, ownerErr := activeDeliveryOwningBranch(repo, branch) + if ownerErr != nil { + return blockedCut(fmt.Sprintf("Boatstack could not verify whether branch %q has an active owner: %v", branch, ownerErr)), nil + } + if owner != "" { + return blockedCut(fmt.Sprintf("Branch %q is owned by an active delivery and cannot be adopted.", branch)), nil + } + if _, err := workspaceGit(repo, "worktree", "add", path, branch); err != nil { + return blockedCut("Boatstack could not adopt the branch into a worktree: " + err.Error()), nil + } + result.Outcome = "adopted" + } else { + if _, err := workspaceGit(repo, "worktree", "add", "-b", branch, path, baseCommit); err != nil { + return blockedCut("Boatstack could not add the worktree: " + err.Error()), nil + } + transition.branchCreated = true + result.Created = true + result.Outcome = "created" + } + transition.worktreeCreated = true + destinationRepo = path } result.WorktreePath = path - result.Reason = fmt.Sprintf("Cut fresh worktree for branch %q from %s at %s.", branch, base, path) } + + if err := workspaceAfterDestination(destinationRepo); err != nil { + rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition) + return blockedCut("Boatstack could not verify the destination workspace: " + err.Error()), nil + } + if dirty, dirtyErr := dirtyOutsideFeature(destinationRepo, options.Feature); dirtyErr != nil || dirty { + rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition) + return blockedCut("Destination workspace contains changes outside the managed feature package."), nil + } + if sourceContext.Mode == SupervisionDetached && filepath.Clean(destinationRepo) != filepath.Clean(repo) { + added, err := workspaceDetachedAlias(repo, destinationRepo) + if err != nil { + rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition) + return blockedCut("Boatstack could not bind detached controller state to the destination: " + err.Error()), nil + } + transition.detachedAlias = added + if err := workspaceAfterDetachedAlias(destinationRepo); err != nil { + rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition) + return blockedCut("Boatstack could not verify detached controller state after registration: " + err.Error()), nil + } + } + fingerprint, err := transferFeaturePackage(repo, destinationRepo, options.Feature, sourceContext.Mode) + if err != nil { + rollbackWorkspaceTransition(repo, branch, result.WorktreePath, transition) + return blockedCut("Boatstack could not transfer the planning package: " + err.Error()), nil + } + result.PlanFingerprint = fingerprint + result.DestinationRepo = destinationRepo + result.Reason = fmt.Sprintf("Workspace %q is ready at %s; continue Boatstack from that repository path.", branch, destinationRepo) return result, nil } diff --git a/boatstack/workspace_test.go b/boatstack/workspace_test.go index 3610528..ae850ec 100644 --- a/boatstack/workspace_test.go +++ b/boatstack/workspace_test.go @@ -132,6 +132,7 @@ func TestCutFeatureWorkspaceBranchMode(t *testing.T) { ws := defaultWorkspace() ws.Mode = "branch" repo := workspaceRepo(t, ws) + commitWorkspaceController(t, repo) result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Branch: "feat/inline"}) if err != nil { t.Fatal(err) @@ -145,12 +146,12 @@ func TestCutFeatureWorkspaceBranchMode(t *testing.T) { } } -func TestCutFeatureWorkspaceRefusesExistingBranch(t *testing.T) { +func TestCutFeatureWorkspaceAdoptsExactBaseBranch(t *testing.T) { repo := workspaceRepo(t, defaultWorkspace()) workspaceGitDo(t, repo, "branch", "feat/dupe") result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "dupe"}) - if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "already exists") { - t.Fatalf("expected existing-branch block: %+v", result) + if result.VerificationStatus != "VERIFIED" || result.Outcome != "adopted" || result.WorktreePath == "" { + t.Fatalf("expected exact-base branch adoption: %+v", result) } } @@ -354,18 +355,25 @@ func TestResolveNextRoutesToWorkspaceCutWhenApprovedOnBase(t *testing.T) { } } -func TestResolveNextApprovedBuildsWhenWorkspaceExists(t *testing.T) { +func TestResolveNextRoutesSourceCheckoutToExistingWorkspace(t *testing.T) { repo := workspaceRepo(t, defaultWorkspace()) writeApprovedFeature(t, repo, "cutdone") - if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "cutdone"}); err != nil { + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "cutdone"}) + if err != nil || cut.VerificationStatus != "BLOCKED" { + // The dummy historical plan is intentionally invalid. The branch/worktree + // was rolled back, proving invalid packages never cross the boundary. + t.Fatalf("invalid package should block and roll back: %+v (%v)", cut, err) + } + workspaceGitDo(t, repo, "branch", "feat/cutdone") + if _, err := workspaceGit(repo, "worktree", "add", filepath.Join(repo, ".product-loop", "worktrees", "cutdone"), "feat/cutdone"); err != nil { t.Fatal(err) } status, err := ResolveNext(repo, "") if err != nil { t.Fatal(err) } - if status.NextOperation != "build" { - t.Fatalf("expected build once workspace exists: %+v", status) + if status.NextOperation != "workspace-cut" { + t.Fatalf("source checkout must route into the existing workspace: %+v", status) } } diff --git a/boatstack/workspace_transition_conformance_test.go b/boatstack/workspace_transition_conformance_test.go new file mode 100644 index 0000000..442db11 --- /dev/null +++ b/boatstack/workspace_transition_conformance_test.go @@ -0,0 +1,467 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Boundary: validated plan package -> feature workspace. +// Control law: the transition preserves the exact plan fingerprint, repository +// identity, and fresh base branch or leaves the source authoritative unchanged. + +func addWorkspaceOrigin(t *testing.T, repo string) string { + t.Helper() + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + workspaceGitDo(t, repo, "remote", "add", "origin", remote) + workspaceGitDo(t, repo, "push", "-u", "origin", "main") + return remote +} + +func commitWorkspaceController(t *testing.T, repo string) { + t.Helper() + workspaceGitDo(t, repo, "add", ".product-loop/project.json") + workspaceGitDo(t, repo, "commit", "-m", "install controller fixture") +} + +func writeWorkspacePlanPackage(t *testing.T, repo, feature string) (string, string) { + t.Helper() + directory := WorkspaceFor(repo).FeatureDir(feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["feature_id"] = feature + planPath := filepath.Join(directory, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + return planPath, check.Fingerprint +} + +func writeWorkspaceSchema3Package(t *testing.T, repo, feature string) (string, string) { + t.Helper() + directory := WorkspaceFor(repo).FeatureDir(feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["schema_version"] = float64(3) + plan["feature_id"] = feature + plan["architecture_facts"] = []any{} + plan["architecture_unknowns"] = []any{} + task := plan["tasks"].([]any)[0].(map[string]any) + task["requires_facts"] = []any{} + task["affected_paths"] = []any{"README.md"} + task["side_effects"] = []any{} + task["rollback_boundary"] = "revert the workspace fixture" + plan["journey_evidence"] = map[string]any{ + "relevance": "not_relevant", "reason": "workspace control-only fixture", "oracles": []any{}, + } + planPath := filepath.Join(directory, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + return planPath, check.Fingerprint +} + +func withWorkspaceTransitionSeams(t *testing.T) { + t.Helper() + copyFn := workspacePackageCopy + removeFn := workspaceSourcePackageRemove + aliasFn := workspaceDetachedAlias + afterDestinationFn := workspaceAfterDestination + afterAliasFn := workspaceAfterDetachedAlias + t.Cleanup(func() { + workspacePackageCopy = copyFn + workspaceSourcePackageRemove = removeFn + workspaceDetachedAlias = aliasFn + workspaceAfterDestination = afterDestinationFn + workspaceAfterDetachedAlias = afterAliasFn + }) +} + +// control-law: workspace-transition-preserves-plan-authority +func TestWorkspaceTransitionMovesValidatedPackageFromBase(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + sourcePlan, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + + status, err := ResolveNext(repo, "") + if err != nil || status.NextOperation != "workspace-cut" || status.ObservedStage != "DRAFT_PLAN" { + t.Fatalf("valid draft did not route through workspace transition: %+v (%v)", status, err) + } + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("transition failed: %+v (%v)", result, err) + } + if result.Outcome != "created" || result.PlanFingerprint != fingerprint || result.DestinationRepo == "" || result.BaseCommit == "" { + t.Fatalf("transition result lacks verified destination facts: %+v", result) + } + if fileExists(sourcePlan) { + t.Fatal("source package remained authoritative after verified relocation") + } + destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md") + check, err := CheckPlan(destinationPlan) + if err != nil || check.Fingerprint != fingerprint { + t.Fatalf("destination plan fingerprint changed: %v %#v", err, check) + } + next, err := ResolveNext(result.DestinationRepo, "") + if err != nil || next.NextOperation != "plan-gate" || next.ObservedStage != "DRAFT_PLAN" { + t.Fatalf("destination did not continue at approval boundary: %+v (%v)", next, err) + } +} + +// control-law: workspace-transition-preserves-plan-authority +func TestWorkspaceTransitionAcceptsDetachedHEAD(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + workspaceGitDo(t, repo, "checkout", "--detach") + _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if err != nil || result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint { + t.Fatalf("detached HEAD did not reach a verified workspace: %+v (%v)", result, err) + } + branch, _ := workspaceGit(result.DestinationRepo, "branch", "--show-current") + if strings.TrimSpace(branch) != "feat/feature-one" { + t.Fatalf("destination branch = %q", branch) + } +} + +// control-law: activation-requires-current-readiness-bound-to-exact-authority +func TestWorkspaceTransitionPrecedesSchema3ApprovalAndActivation(t *testing.T) { + previousHealth := runInstallationHealth + runInstallationHealth = func(string) error { return nil } + t.Cleanup(func() { runInstallationHealth = previousHealth }) + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + planPath, fingerprint := writeWorkspaceSchema3Package(t, repo, "feature-one") + if _, err := CheckPlanReadiness(planPath); err == nil || !strings.Contains(err.Error(), "workspace-cut") { + t.Fatalf("pre-transition readiness did not name workspace-cut: %v", err) + } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "VERIFIED" || result.PlanFingerprint != fingerprint { + t.Fatalf("workspace transition failed: %+v", result) + } + destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md") + check, err := CheckPlan(destinationPlan) + if err != nil { + t.Fatal(err) + } + readiness, err := CheckPlanReadiness(destinationPlan) + if err != nil { + t.Fatal(err) + } + approvalPath := filepath.Join(filepath.Dir(destinationPlan), "approval.md") + if err := RecordApproval(ApprovalRecordOptions{ + PlanPath: destinationPlan, OutputPath: approvalPath, ApprovedBy: "Test Human", + ApprovedAt: "2026-08-09T12:00:00Z", Fingerprint: check.Fingerprint, + }); err != nil { + t.Fatal(err) + } + receipt, err := LoadApprovalReceipt(approvalPath) + if err != nil || receipt.Readiness.Fingerprint != readiness.Fingerprint || receipt.Readiness.HeadBranch != "feat/feature-one" { + t.Fatalf("approval did not bind destination readiness: %+v (%v)", receipt, err) + } + if err := ActivatePlan(ActivationOptions{ + PlanPath: destinationPlan, ApprovalPath: approvalPath, + OutDir: filepath.Join(filepath.Dir(destinationPlan), "compiled"), + OutputPath: filepath.Join(filepath.Dir(destinationPlan), "plan.lock.json"), + }); err != nil { + t.Fatalf("destination approval did not activate: %v", err) + } +} + +// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch +func TestWorkspaceTransitionPrecedesAutonomyTargets(t *testing.T) { + for _, target := range []RunTarget{RunTargetVerified, RunTargetPR} { + t.Run(string(target), func(t *testing.T) { + previousAction := autonomyRecommendedPRAction + autonomyRecommendedPRAction = func(string) (string, string, error) { return "open", "", nil } + t.Cleanup(func() { autonomyRecommendedPRAction = previousAction }) + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + planPath, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + if _, err := RecordAutonomy(AutonomyRecordOptions{Repo: repo, PlanPath: planPath, Target: target}); err == nil || !strings.Contains(err.Error(), "workspace-cut") { + t.Fatalf("pre-transition autonomy did not name workspace-cut: %v", err) + } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "VERIFIED" { + t.Fatalf("workspace transition failed: %+v", result) + } + destinationPlan := filepath.Join(WorkspaceFor(result.DestinationRepo).FeatureDir("feature-one"), "plan.md") + receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: result.DestinationRepo, PlanPath: destinationPlan, Target: target}) + if err != nil { + t.Fatal(err) + } + if receipt.Branch != "feat/feature-one" || receipt.IssuingBranch != "feat/feature-one" || receipt.PlanFingerprint != result.PlanFingerprint { + t.Fatalf("autonomy did not bind destination: %+v", receipt) + } + }) + } +} + +// control-law: workspace-transition-adopts-only-pristine-base +func TestWorkspaceTransitionAdoptsOnlyExactBaseBranch(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + workspaceGitDo(t, repo, "branch", "feat/feature-one") + _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "VERIFIED" || result.Outcome != "adopted" || result.PlanFingerprint != fingerprint { + t.Fatalf("exact-base branch was not adopted: %+v", result) + } + + other := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, other) + addWorkspaceOrigin(t, other) + workspaceGitDo(t, other, "switch", "-c", "feat/feature-one") + if err := os.WriteFile(filepath.Join(other, "diverged.txt"), []byte("change\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, other, "add", "diverged.txt") + workspaceGitDo(t, other, "commit", "-m", "diverge feature branch") + workspaceGitDo(t, other, "switch", "main") + sourcePlan, _ := writeWorkspacePlanPackage(t, other, "feature-one") + blocked, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: other, Feature: "feature-one"}) + if blocked.VerificationStatus != "BLOCKED" || !strings.Contains(blocked.Reason, "diverges") || !fileExists(sourcePlan) { + t.Fatalf("divergent branch changed source authority: %+v", blocked) + } +} + +// control-law: workspace-transition-adopts-only-pristine-base +func TestWorkspaceTransitionRejectsDirtyOwnedAndConflictingDestinations(t *testing.T) { + setup := func(t *testing.T) (string, string) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + workspaceGitDo(t, repo, "branch", "feat/feature-one") + path := filepath.Join(repo, ".product-loop", "worktrees", "existing-feature-one") + workspaceGitDo(t, repo, "worktree", "add", path, "feat/feature-one") + return repo, path + } + + t.Run("dirty", func(t *testing.T) { + repo, destination := setup(t) + sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + if err := os.WriteFile(filepath.Join(destination, "unrelated.txt"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "changes outside") || !fileExists(sourcePlan) { + t.Fatalf("dirty destination changed source authority: %+v", result) + } + }) + + t.Run("owned", func(t *testing.T) { + repo, destination := setup(t) + sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + if err := saveDeliveryState(destination, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "other-delivery", ActiveIndex: 0, + Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: StatusBuild, HeadBranch: "feat/feature-one"}}, + }); err != nil { + t.Fatal(err) + } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "owned") || !fileExists(sourcePlan) { + t.Fatalf("owned destination changed source authority: %+v", result) + } + }) + + t.Run("conflicting_plan", func(t *testing.T) { + repo, destination := setup(t) + sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + destinationDir := WorkspaceFor(destination).FeatureDir("feature-one") + if err := os.MkdirAll(destinationDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destinationDir, "source-plan.md"), []byte("# Other source\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destinationDir, "spec.md"), []byte("# Other spec\n"), 0o644); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["feature_id"] = "feature-one" + plan["acceptance_criteria"].([]any)[0].(map[string]any)["text"] = "different result" + writeMarkdownPlan(t, filepath.Join(destinationDir, "plan.md"), plan, true) + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, "conflicting") || !fileExists(sourcePlan) { + t.Fatalf("conflicting destination changed source authority: %+v", result) + } + }) +} + +// control-law: workspace-transition-preserves-plan-authority +func TestWorkspaceTransitionReusesMatchingWorktree(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + sourcePlan, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + workspaceGitDo(t, repo, "branch", "feat/feature-one") + destination := filepath.Join(repo, ".product-loop", "worktrees", "existing-feature-one") + workspaceGitDo(t, repo, "worktree", "add", destination, "feat/feature-one") + destinationDir := WorkspaceFor(destination).FeatureDir("feature-one") + if err := copyFeaturePackage(filepath.Dir(sourcePlan), destinationDir); err != nil { + t.Fatal(err) + } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + expectedDestination, err := filepath.EvalSymlinks(destination) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || result.Outcome != "current" || !samePath(result.DestinationRepo, expectedDestination) || result.PlanFingerprint != fingerprint { + t.Fatalf("matching worktree was not reused: %+v", result) + } + if fileExists(sourcePlan) { + t.Fatal("matching current worktree left the embedded source package authoritative") + } +} + +// control-law: workspace-transition-preserves-plan-authority +func TestWorkspaceTransitionRollbackPreservesSource(t *testing.T) { + tests := []struct { + name string + fail func() + }{ + {"copy", func() { + workspacePackageCopy = func(string, string) error { return fmt.Errorf("injected copy failure") } + }}, + {"copied_package_drift", func() { + workspacePackageCopy = func(source, destination string) error { + if err := copyFeaturePackage(source, destination); err != nil { + return err + } + return os.WriteFile(filepath.Join(destination, "spec.md"), []byte("# Drifted spec\n"), 0o644) + } + }}, + {"source_cleanup", func() { + workspaceSourcePackageRemove = func(string) error { return fmt.Errorf("injected cleanup failure") } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + withWorkspaceTransitionSeams(t) + repo := workspaceRepo(t, defaultWorkspace()) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + test.fail() + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" || !fileExists(sourcePlan) { + t.Fatalf("failure did not preserve source package: %+v", result) + } + if branchExists(repo, "feat/feature-one") || worktreePathForBranch(repo, "feat/feature-one") != "" { + t.Fatal("failed transition left branch or worktree authority") + } + }) + } +} + +// control-law: workspace-transition-preserves-plan-authority +func TestBranchWorkspaceTransitionRollbackRestoresOriginalHead(t *testing.T) { + withWorkspaceTransitionSeams(t) + repo := workspaceRepo(t, Workspace{Enabled: true, Mode: "branch"}) + commitWorkspaceController(t, repo) + addWorkspaceOrigin(t, repo) + sourcePlan, _ := writeWorkspacePlanPackage(t, repo, "feature-one") + originalHead, err := workspaceGit(repo, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + workspaceAfterDestination = func(string) error { return fmt.Errorf("injected post-branch failure") } + + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" || !fileExists(sourcePlan) { + t.Fatalf("branch failure did not preserve source authority: %+v", result) + } + current, err := workspaceGit(repo, "branch", "--show-current") + if err != nil { + t.Fatal(err) + } + if current = strings.TrimSpace(current); current != "main" { + t.Fatalf("rollback left caller on %q", current) + } + head, err := workspaceGit(repo, "rev-parse", "HEAD") + if err != nil { + t.Fatal(err) + } + if head = strings.TrimSpace(head); head != strings.TrimSpace(originalHead) { + t.Fatalf("rollback changed original head: got %s want %s", head, originalHead) + } + if branchExists(repo, "feat/feature-one") { + t.Fatal("branch rollback left partial feature authority") + } +} + +// control-law: detached-state-controls-only-its-bound-repository +func TestDetachedWorkspaceTransitionReusesControllerIdentity(t *testing.T) { + repo := detachedTestRepo(t, "") + remote := addWorkspaceOrigin(t, repo) + _ = remote + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" { + t.Fatalf("attach failed: %+v (%v)", attached, err) + } + _, fingerprint := writeWorkspacePlanPackage(t, repo, "feature-one") + result, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if err != nil || result.VerificationStatus != "VERIFIED" || result.ControllerMode != "detached" || result.PlanFingerprint != fingerprint { + t.Fatalf("detached transition failed: %+v (%v)", result, err) + } + destination := WorkspaceFor(result.DestinationRepo) + if destination.Mode != SupervisionDetached || destination.RepoID != attached.RepoID || destination.WorktreeID == WorkspaceFor(repo).WorktreeID { + t.Fatalf("destination controller identity drifted: source=%+v destination=%+v", WorkspaceFor(repo), destination) + } +} + +// control-law: detached-state-controls-only-its-bound-repository +func TestDetachedWorkspaceRegistrationRollsBack(t *testing.T) { + withWorkspaceTransitionSeams(t) + repo := detachedTestRepo(t, "") + addWorkspaceOrigin(t, repo) + attached, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || attached.VerificationStatus != "VERIFIED" { + t.Fatalf("attach failed: %+v (%v)", attached, err) + } + writeWorkspacePlanPackage(t, repo, "feature-one") + workspaceAfterDetachedAlias = func(string) error { return fmt.Errorf("injected post-registration failure") } + result, _ := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "feature-one"}) + if result.VerificationStatus != "BLOCKED" { + t.Fatalf("post-registration failure was accepted: %+v", result) + } + if branchExists(repo, "feat/feature-one") || worktreePathForBranch(repo, "feat/feature-one") != "" { + t.Fatal("detached registration failure left branch or worktree authority") + } + registry, err := loadRegistry(filepath.Join(os.Getenv(stateRootEnv), "boatstack")) + if err != nil || len(registry.Repositories) != 1 { + t.Fatalf("detached alias rollback changed source binding: %+v (%v)", registry, err) + } +} diff --git a/docs/account-recovery-walkthrough.md b/docs/account-recovery-walkthrough.md index 449d88f..5345b19 100644 --- a/docs/account-recovery-walkthrough.md +++ b/docs/account-recovery-walkthrough.md @@ -61,4 +61,4 @@ vague request The safeguard behavior is covered by planning, approval, review, and PR-projection tests. Whether the complete Boatstack workflow improves product-delivery success remains a separate paired evaluation. -Next: [install and ship a first feature](getting-started.md) or read [why these steps exist](why-these-steps.md). +Next: [install and ship a first feature](getting-started.md) or read the [research and design notes](research-and-design.md). diff --git a/docs/public-claims.json b/docs/public-claims.json index ba74680..54b13bb 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -8,7 +8,7 @@ "status": "verified", "originating_observation": "Coding-host configuration, model choice, and specialist skills otherwise become separate places where a product-development process and its working state can fragment.", "safeguard": "Portable host adapters project one workflow contract while canonical feature artifacts remain repository-owned and model-neutral.", - "readable_evidence": "why-these-steps.md#portable-workflow-and-state", + "readable_evidence": "research-and-design.md#host-portability", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], "last_verified_version": "v0.7.120" @@ -19,7 +19,7 @@ "status": "verified", "originating_observation": "A password-reset request conflicted with a passwordless product model and required a human product choice.", "safeguard": "Question states, explicit Markdown approval, approval fingerprints, and stale-plan rejection.", - "readable_evidence": "why-these-steps.md#human-decisions", + "readable_evidence": "research-and-design.md#commands-and-the-approval-boundary", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], "last_verified_version": "v0.7.120" @@ -30,7 +30,7 @@ "status": "verified", "originating_observation": "Stronger verification wording and same-model checks did not reliably distinguish correct completion from unsupported claims.", "safeguard": "Criterion coverage, validation origin, named falsifier, and independence level.", - "readable_evidence": "why-these-steps.md#validation-provenance", + "readable_evidence": "validation-and-evidence.md#the-validation-contract", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], "last_verified_version": "v0.7.120" @@ -42,7 +42,7 @@ "evaluation_status": "still_being_evaluated", "originating_observation": "A failed external schema apply led to an invented reset path before human review removed it.", "safeguard": "Fail-closed host hooks, immutable destructive classes, and read-only diagnosis after failure.", - "readable_evidence": "why-these-steps.md#irreversible-operations", + "readable_evidence": "safety.md#evidence-status", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], "last_verified_version": "v0.7.120" @@ -53,7 +53,7 @@ "status": "verified", "originating_observation": "A generated PR summary omitted product decisions, accepted gaps, review findings, rollout, and rollback context.", "safeguard": "Managed and evidence-limited PR projection, stale-preview checks, and explicit open or update confirmation.", - "readable_evidence": "why-these-steps.md#reviewer-ready-pr", + "readable_evidence": "evidence-engineered-coding.md#what-is-evidence-backed", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], "last_verified_version": "v0.7.120" @@ -64,7 +64,7 @@ "status": "verified", "originating_observation": "A coding host treated an approved multi-phase plan as standing permission to open several PRs during build without returning through the gates.", "safeguard": "Explicit task-to-slice partitioning, affected-path scope, diff-bound gate receipts, active-slice publication, and direct push/PR hook denial.", - "readable_evidence": "why-these-steps.md#phase-scoped-delivery", + "readable_evidence": "evidence-engineered-coding.md#the-graph-follows-the-work", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], "last_verified_version": "v0.7.120" @@ -75,7 +75,7 @@ "status": "verified", "originating_observation": "Changing models relocated the dominant benchmark bottleneck instead of producing one stable lower-cost-model or frontier-model failure profile.", "safeguard": "One model-neutral workflow that branches on observable decisions, risk, tool outcomes, convergence, and evidence rather than model identity.", - "readable_evidence": "why-these-steps.md#model-choice-and-budget", + "readable_evidence": "research-and-design.md#why-the-workflow-has-no-model-conditions", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], "last_verified_version": "v0.7.120" @@ -86,7 +86,7 @@ "status": "observed", "originating_observation": "The audited Gemini, Qwen, and submission runs recorded malformed responses, context-window regression, unsupported verification, step exhaustion, near misses, and unsafe recovery behavior.", "safeguard": "Trace safeguards to observed failure mechanics without treating model provider or price as the failure state.", - "readable_evidence": "why-these-steps.md#model-choice-and-budget", + "readable_evidence": "research-and-design.md#outcome-sizing-and-where-value-emerges", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], "last_verified_version": "v0.7.120" @@ -97,20 +97,20 @@ "status": "still_being_evaluated", "originating_observation": "The existing corpus supports specific failure mechanisms but contains no paired product-feature result proving a lower-cost-model outcome uplift.", "safeguard": "Keep the outcome claim unpromoted until the same feature, model, budget, environment, and host are evaluated with and without Boatstack.", - "readable_evidence": "why-these-steps.md#model-choice-and-budget", + "readable_evidence": "research-and-design.md#evaluation-of-the-finished-node", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], "last_verified_version": "v0.7.120" }, { "id": "git-worktree-activation", - "public_claim": "Boatstack tracks exact-runtime launchers in every generated repository. A linked worktree verifies and activates only its pinned Git-common runtime before command dispatch, independently of hook trust.", + "public_claim": "Boatstack tracks exact-runtime launchers in every generated repository. A linked worktree verifies its pinned Git-common runtime, and a managed transition verifies the exact planning package and controller identity before approval or autonomy.", "status": "verified", - "originating_observation": "A linked Claude Code worktree inherited committed fail-closed hooks but not the ignored helper, causing every shell call—including installation—to be denied.", - "safeguard": "Tracked launchers with baked release identity, versioned Git-common runtime slots, manifest and checksum verification, atomic local activation, and fail-closed mismatch handling.", - "readable_evidence": "why-these-steps.md#git-worktree-activation", - "implementation": ["../boatstack/launcher.go", "../boatstack/runtime_cache.go"], - "verification": ["../boatstack/launcher_test.go", "../boatstack/runtime_cache_test.go", "../boatstack/hooks_hydrate_test.go"], + "originating_observation": "A linked Claude Code worktree inherited committed fail-closed hooks but not the ignored helper, causing every shell call—including installation—to be denied. A later transition could strand a validated plan in the source checkout before approval or autonomy.", + "safeguard": "Tracked launchers verify the pinned runtime. Transactional workspace cuts verify the fetched base, complete plan fingerprint, destination ownership, and controller identity before authority moves.", + "readable_evidence": "generated-files.md#worktrees-fresh-clones-and-updates", + "implementation": ["../boatstack/launcher.go", "../boatstack/runtime_cache.go", "../boatstack/workspace.go", "../boatstack/detached.go"], + "verification": ["../boatstack/launcher_test.go", "../boatstack/runtime_cache_test.go", "../boatstack/hooks_hydrate_test.go", "../boatstack/workspace_transition_conformance_test.go", "../boatstack/solution_closure_conformance_test.go"], "last_verified_version": "v0.7.120" }, { @@ -119,7 +119,7 @@ "status": "verified", "originating_observation": "Installer-based updates depended on remembered commands and could be mixed into feature work or reset optional integration choices.", "safeguard": "Cached post-ship discovery, clean-default-branch enforcement, exact release verification, integration preservation, diff preview, and explicit update-PR publication.", - "readable_evidence": "why-these-steps.md#visible-updates", + "readable_evidence": "generated-files.md#worktrees-fresh-clones-and-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], "last_verified_version": "v0.7.120" diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md deleted file mode 100644 index 13d4804..0000000 --- a/docs/why-these-steps.md +++ /dev/null @@ -1,116 +0,0 @@ -# Why Boatstack has these steps - -**For:** anyone who wants to see the work behind Boatstack's safeguards. -**Outcome:** understand what was observed, what Boatstack now does, and what has—or has not—been proven. - -Boatstack was not designed by writing a long list of ideal engineering practices. Its safeguards were traced from benchmark trajectories and two product repositories, then turned into behavior that can be inspected and tested. This page keeps three different kinds of evidence separate: - -- **Observed:** the problem appeared in recorded work. -- **Verified:** Boatstack's implementation behaves as stated in automated tests. -- **Still being evaluated:** the safeguard exists, but its effect on overall product-delivery outcomes has not yet been established. - -Those labels prevent an implementation test from being presented as proof that the whole product improves engineering performance. - -## Portable workflow and state - -**What happened.** Coding hosts, model choices, and specialist skills can each become a separate place where the development process fragments. Plans and decisions that exist only in one agent conversation are difficult for another supported host—or the next feature—to inspect and continue. - -**What Boatstack does.** Cursor, Codex, and Claude Code receive adapters for the same path from planning through PR preparation. The durable state behind that path—source plan, specification, human answers, accepted gaps, approval, evidence, and review findings—lives in the repository instead of belonging to one model or chat session. Models and skills may contribute work without changing the completion requirements. - -**How we check it.** Export tests verify that all supported host adapters expose the same lifecycle and reference the same canonical repository artifact contract. Repository-contract tests verify the public workflow, adapters, and artifact definitions directly in Boatstack. - -**What it does not mean.** Boatstack does not copy private chat history or move a command already in progress between agents. Portability covers the workflow and saved repository state available at the next transition. - -**Status:** cross-host workflow and artifact portability verified in automated tests. The effect on product-delivery outcomes remains part of the planned paired evaluation. - -## Human decisions - -**What happened.** A product request asked for a password-reset button in a passwordless product. A literal implementation would have created an interface for a capability that did not exist. Repository inspection could discover the conflict, but only a human could choose whether to introduce passwords or preserve the existing model. - -**What Boatstack does.** Material product choices remain open until a human answers them. The reviewed plan is fingerprinted, explicit approval is recorded, and any subsequent planning change makes that approval stale. - -**How we check it.** Planning, approval, and stale-plan tests verify that unanswered decisions block progress, approval cannot be inferred from silence, and changed inputs cannot reuse an old approval. - -**Status:** observed in a sanitized product workflow; enforcement verified in automated tests. This does not yet quantify a change in feature success rate. - -## Validation provenance - -**What happened.** Terminal-Bench experiments showed that stronger self-verification wording and same-model repair did not reliably create truth. In another experiment, model-authored tests helped a development slice while the frozen evaluator had low fidelity, and the apparent gain did not transfer to the full board. - -**What Boatstack does.** Every acceptance criterion must name a validation procedure and explain what makes that procedure meaningful. A test written with the implementation remains useful evidence, but it is not silently promoted into an independent source of truth. - -**How we check it.** The plan compiler rejects uncovered criteria, incomplete validation records, and checks attached to work that does not serve the claimed outcome. - -**Status:** experimental problem observed; compiler behavior verified. The best validation mix for different product risks remains an open evaluation question. - -## Irreversible operations - -**What happened.** After a partial database-schema apply failed, an agent introduced a reset path that could drop the public schema. The human stopped execution and the capability was removed in favor of target checks and transactional or fix-forward behavior. - -**What Boatstack does.** Project hooks deny high-confidence destructive operations before execution and require read-only diagnosis after an external-write failure. There is no in-session bypass. - -**How we check it.** Host-event fixtures cover direct and indirect destructive commands, malformed events, missing helpers, and safe controls. A blocked command must not create its sentinel side effect. - -**Status:** incident observed and enforcement verified in fixtures. The net benefit, false-denial rate, and host coverage are **still being evaluated**; the safety documentation keeps that limitation visible. - -## Reviewer-ready PR - -**What happened.** Ordinary generated PR summaries described edited files and test commands but omitted important product decisions, accepted gaps, review findings, rollout, and rollback context accumulated during the feature. - -**What Boatstack does.** At ship time, Boatstack projects the approved intent, committed diff, recorded evidence, decisions, gaps, rollout, and rollback into a reviewer-first preview. The human sees the exact title and body before GitHub is changed. - -**How we check it.** Tests cover managed and ad-hoc branches, evidence-limited wording, stale previews, conditional risk sections, and explicit open/update confirmation. - -**Status:** product-workflow problem observed; projection behavior verified. Reviewer speed and acceptance quality still need blinded product-delivery evaluation. - -## Phase-scoped delivery - -**What happened.** A coding host received an approved plan describing several implementation phases and PRs. During `/build`, it treated that parent-plan approval as standing authority to commit, push, and open each PR. After context compression, “already-approved five-PR plan” became the surviving instruction and the test, review, and ship transitions were skipped. - -**What Boatstack does.** Internal phases remain tasks in one delivery slice. A plan that intentionally requires multiple PRs must partition every task into ordered delivery slices with explicit affected paths. Only one slice is active. Test and review create machine-local receipts bound to that slice's branches, commit, product diff, and evidence. The hook denies direct pushes and PR mutations while managed delivery is active; only the confirmed publisher can advance one slice and activate the next. - -**How we check it.** Plan tests reject missing, duplicate, and forward task assignments. Delivery tests reject review before test, stale diffs, out-of-slice paths, reuse of a prior slice, direct shell pushes, direct GitHub CLI PR creation, and equivalent GitHub tool mutations. - -**Status:** bypass trajectory observed; phase partitioning, receipt ordering, and publication interception verified in automated tests. Host hooks remain defense in depth and still depend on supported host event coverage. - -## Model choice and budget - -**What happened.** Across the audited benchmark runs, changing the model relocated the dominant bottleneck instead of removing failure. Gemini runs were dominated by near misses in one comparison, while Qwen runs exposed step exhaustion. Other recorded failures involved malformed protocol responses, context loss, unsupported verification claims, and unsafe recovery. A model name, provider, or price was not itself a reliable description of the active engineering problem. - -**What Boatstack does.** Boatstack keeps one planning, approval, validation, review, and shipping contract across models. It reacts to observable conditions such as unanswered decisions, risk, reversibility, tool outcomes, convergence, and evidence. This lets a repository owner choose a lower-cost, general, or frontier model without silently changing what “ready” means. - -**How we check it.** Export and repository-contract tests verify the same workflow and gate vocabulary across supported coding hosts. The benchmark audits preserve the recorded trial coverage and the research record traces the model-dependent bottlenecks and failure mechanisms behind this design. - -**Status:** the model-neutral contract is verified and the cross-model failure patterns are observed. Whether Boatstack improves correctness, cost, or delivery time for lower-cost models is **still being evaluated**; it is not a claim that models perform equally. - -The paired product evaluation will use the same feature, lower-cost model, budget, environment, and coding host with and without Boatstack. An independent evaluator will compare correctness, regressions, review findings, cost, completion time, and the evidence available to support completion claims. - -## Visible updates - -**What happened.** Boatstack updates were possible by rerunning the installer, but users had to remember the command and could accidentally mix regenerated infrastructure into product work. A non-interactive rerun could also silently select core instead of preserving optional integrations. - -**What Boatstack does.** Release discovery occurs only after successful PR publication and is cached outside Git. An available release is informational; it never mutates the feature branch. `/boatstack-update` requires a clean current default branch, prepares a versioned infrastructure branch, preserves integration choices, verifies the release, runs `doctor`, and shows the exact diff before a separate `open update PR` confirmation. - -**How we check it.** Update tests cover release parsing, bounded caching and reminders, failed-network isolation, branch and drift rejection, integration preservation, generated-file scope, checksums, and the publication confirmation boundary. - -**Status:** release notification and update preparation behavior are verified in automated tests. This is not a claim that updates install themselves or may be merged without review. - -**Repair boundary.** The checksum-verified target helper classifies installed control state instead of requiring the old helper to certify itself. Exact owned migrations are automatic; recoverable owned drift receives a fingerprinted `--repair` preview and Git-common backup in the update PR. User-owned changes and downgrades retain separate explicit boundaries. - -## Git worktree activation - -**What happened.** A Claude Code worktree contained the committed fail-closed hook but not `.product-loop/bin/`, which Git intentionally ignores. Every shell call was denied because the helper was absent, including the installer command that could have repaired it. - -**What Boatstack does.** One verified, versioned runtime is stored under the clone's Git common directory. Every worktree inherits a tracked launcher with the exact release version and source commit. On first use, the launcher verifies only that shared slot, atomically restores the ignored local helper and lock, and dispatches the command. Hook trust remains a separate safety boundary. The launcher never searches sibling worktrees or selects `latest`. - -**How we check it.** Real linked-worktree tests cover safe first use, destructive first use, paths with spaces, concurrent activation, version and source drift, checksum tampering, symlinks, malformed events, and clean Git state after hydration. - -**Status:** bootstrap deadlock observed; tracked-launcher activation, independent fresh-worktree dispatch, and exact pinned hydration are verified in automated tests. - -## What the experiments do and do not support - -The current research covers thousands of locally available benchmark result records, preregistered comparisons, product-repository studies, and targeted trajectory inspection. It supports the mechanisms that Boatstack is designed to address. It does **not** yet support a claim that Boatstack improves feature success, cost, or delivery speed. - -The next product evaluation compares the same model, task, budget, and coding host with and without Boatstack on a feature-building benchmark. Until that result exists, the homepage describes implemented behavior and evidence lineage—not performance uplift. - -Technical readers can inspect the [research and design record](research-and-design.md), [validation model](validation-and-evidence.md), [safety evaluation status](safety.md), and [benchmark corpus audit](benchmark-corpus-audit.md). diff --git a/release-notes/2026-08-09-workspace-transition-coreachability.md b/release-notes/2026-08-09-workspace-transition-coreachability.md new file mode 100644 index 0000000..6c2bbc1 --- /dev/null +++ b/release-notes/2026-08-09-workspace-transition-coreachability.md @@ -0,0 +1,3 @@ +### Plans now enter the feature workspace before approval + +Boatstack moves a validated planning package to its final feature branch or worktree before it records approval or autonomy. It reuses only a matching safe destination and restores the original plan when the transition fails.