From df9f726879bd017f9b89a4da503f485e537a2a2e Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 03:12:21 +0100 Subject: [PATCH 1/2] fix: scope Boatstack workflow control explicitly --- boatstack/SKILL.md | 10 +- .../delivery_boundary_conformance_test.go | 25 +-- boatstack/delivery_test.go | 18 ++- boatstack/export.go | 10 +- boatstack/export_test.go | 2 +- boatstack/init.go | 8 +- boatstack/next.go | 27 +++- boatstack/next_test.go | 25 +++ boatstack/operation_test.go | 22 +++ boatstack/publication_ignored_repro_test.go | 14 +- boatstack/references/failure-moves.md | 2 +- boatstack/repair_state_test.go | 25 +-- boatstack/safety.go | 150 +++++------------- boatstack/safety_test.go | 56 ++++--- boatstack/safety_update_publisher_test.go | 2 +- boatstack/supervisory_control_test.go | 19 ++- .../2026-08-10-explicit-workflow-scope.md | 3 + 17 files changed, 231 insertions(+), 187 deletions(-) create mode 100644 release-notes/2026-08-10-explicit-workflow-scope.md diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 7d59f04..64bac9a 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -1,12 +1,14 @@ --- name: boatstack -description: Turn a product request into a question-led, specification-first implementation with test, review, and ship gates, then learn from the evidence without silently changing project rules. Use when planning or building a feature, creating an implementation PR, reviewing work against product intent, diagnosing repeated coding-agent failures, updating Boatstack itself, or exporting the same engineering loop to Cursor, Claude Code, Codex, and GitHub. +description: Use when the user explicitly asks for Boatstack, asks what is next in Boatstack, or works on an active or current-branch published Boatstack delivery. Do not activate from installation, attachment, saved or approved drafts, or repository presence alone. --- # Boatstack Build the smallest complete product slice that can be independently verified. Implementation methods remain open: project facts, approval, and gate evidence are canonical; host-specific prompts are adapters. You are free in how you build. Only claims of completion require evidence. +Boatstack starts only from an explicit user request or a managed delivery bound to the current worktree and branch. A saved, approved, policy-ready, ambiguous, stale, or invalid draft is repository evidence, not ambient workflow authority. Before explicit use, only the irreversible-operation safety floor and Boatstack-owned state boundaries apply. + ## Start by selecting the operation Map the request to one operation: @@ -30,15 +32,15 @@ For the full state machine, read [workflow.md](references/workflow.md). For arti ## Report what is next -Run the project-local helper's read-only `next-status --repo . --json` inspection. Repository artifacts, managed delivery state, gate receipts, and the recorded PR identity are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. `NOT_STARTED` points to `auto-plan` (run it with the plan path via `--plan`); `PUBLISHED` means a PR exists but is not a verified merge; only `FEATURE_COMPLETE` requires no action. If state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. When an `AMBIGUOUS` block names only past deliveries the user no longer cares about, name the ignorable delivery slug(s) and offer to exclude them from ambiguity resolution; only after explicit user confirmation, add each slug with `.product-loop/boatstack ignore-delivery --repo . --feature ` (a bounded, provenance-safe write to `workflow.ignored_deliveries` — never hand-edit config or delivery state). Any new, unlisted ambiguous delivery still pauses the workflow. +Run the project-local helper's read-only `next-status --repo . --json` inspection. Once an explicit request identifies a feature slug, pass `--feature ` on every status call so unrelated saved drafts cannot redirect the run. Repository artifacts, managed delivery state, gate receipts, and the recorded PR identity are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. `NOT_STARTED` points to `auto-plan` (run it with the plan path via `--plan`); `PUBLISHED` means a PR exists but is not a verified merge; only `FEATURE_COMPLETE` requires no action. If unscoped state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. When an `AMBIGUOUS` block names only past deliveries the user no longer cares about, name the ignorable delivery slug(s) and offer to exclude them from ambiguity resolution; only after explicit user confirmation, add each slug with `.product-loop/boatstack ignore-delivery --repo . --feature ` (a bounded, provenance-safe write to `workflow.ignored_deliveries` — never hand-edit config or delivery state). Any new, unlisted ambiguous delivery still pauses the unscoped workflow. To see every feature at once, run the read-only `.product-loop/boatstack flow frontier --repo .`. It lists each delivery, its observed position, and who owes the next step. To wait for a published PR to move (checks finish, a review lands, a merge happens), run the read-only `.product-loop/boatstack flow watch --repo .`. The watch observes on an interval and exits when the frontier changes, when nothing can move, or at its timeout. It never acts on what it sees. When it exits, run `next-status` again and continue from the fresh state. ## 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. 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. +For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target and feature slug from the request or supplied plan. When the target is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --feature --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. Carry the same feature slug through every later status and planning call. Return **Feature complete** only for a verified completed feature, and stop on unverified, stale, or invalid selected 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. +After preflight, repeatedly run `next-status --repo . --feature --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. When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message, classification, evidence, and changed repair mechanism from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. diff --git a/boatstack/delivery_boundary_conformance_test.go b/boatstack/delivery_boundary_conformance_test.go index 9b40f1f..1a27190 100644 --- a/boatstack/delivery_boundary_conformance_test.go +++ b/boatstack/delivery_boundary_conformance_test.go @@ -123,29 +123,20 @@ func TestActiveManagedDeliveriesStaysFailClosedOnInvalid(t *testing.T) { } } -// Same-Relation-Same-Law + Coreachability at the MUTATION boundary: invalid -// delivery state fails closed even when ignored (no laundering corrupt state), but -// the block is actionable — it prescribes discard-delivery (a reachable verb that -// clears it), not the opaque error or a verb that refuses. Ignoring quiets status; -// discarding unblocks mutation. This is the mutation-path twin of the read-only -// ResolveNext discard-remedy conformance above. -func TestPreActivationBlockOnInvalidDeliveryPrescribesDiscard(t *testing.T) { +// Negative conformance for +// control-law: ambient-plans-never-activate-workflow-control. Invalid delivery +// observations remain actionable through ResolveNext, but they do not acquire +// ambient authority over unrelated product tools. +func TestInvalidUnselectedDeliveryDoesNotBlockOrdinaryMutation(t *testing.T) { repo := nextTestRepo(t) writeInvalidDelivery(t, repo, "stale-one") if _, err := IgnoreDelivery(repo, "stale-one"); err != nil { t.Fatal(err) } - finding, blocked := preActivationFinding(repo, "product.go") - if !blocked { - t.Fatal("invalid delivery state did not block mutation") + if finding, blocked := preActivationFinding(repo, "product.go"); blocked { + t.Fatalf("invalid unselected delivery controlled an ordinary product path: %+v", finding) } - if finding.NextOperation != "discard-delivery" { - t.Fatalf("block prescribed %q, want the reachable discard-delivery", finding.NextOperation) - } - if !controlledPhaseTransition("boatstack-helper discard-delivery --repo . --feature stale-one", finding.WorkflowStage) { - t.Fatal("prescribed discard-delivery is not admitted for the block it was prescribed for") - } - // The prescribed verb clears the state, and mutation is then unblocked. + // The bounded recovery remains available when Boatstack is explicitly used. if _, err := DiscardDelivery(repo, "stale-one", true); err != nil { t.Fatalf("discard-delivery refused the invalid delivery it was prescribed for: %v", err) } diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index 51692ca..368a209 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -489,7 +489,7 @@ func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { runGit(t, repo, "init", "-b", "main") if err := saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64), - ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}}, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD", HeadBranch: "main"}}, }); err != nil { t.Fatal(err) } @@ -561,6 +561,22 @@ func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { } } +// Relation conformance for +// control-law: managed-publication-scope-follows-current-branch. +func TestManagedDeliveryPublicationGuardIgnoresUnrelatedBranch(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64), + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD", HeadBranch: "feature/phased-feature"}}, + }); err != nil { + t.Fatal(err) + } + if findings := ClassifyCommand(repo, "git push origin main"); len(findings) != 0 { + t.Fatalf("delivery on another branch controlled publication here: %#v", findings) + } +} + func TestManagedDeliveryStateDoesNotBlockUnrelatedWorktrees(t *testing.T) { repo := t.TempDir() runGit(t, repo, "init", "-b", "main") diff --git a/boatstack/export.go b/boatstack/export.go index fea6199..b2d5b2d 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -379,7 +379,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "insight-capture": "Treat the complete invocation argument as the exact untrusted source message. Require insights.enabled before continuing. Run the available Value Map skill as a read-only conversational projection and preserve its canonical lineage: user, current state, value gap, desired outcome, mechanism, smallest proof, evidence, unknowns, grade, and verdict. When insights.suggest_features is true, inspect only the minimal relevant product slice to suggest one primary feature topic and optional related topics; label suggestions PROPOSED and do not bind them to a delivery. When it is false, leave topics for explicit human classification. Serialize the full proposed capture, including the exact source bytes and SHA-256, then pipe those bytes to the tracked .product-loop/boatstack launcher insight check --repo . --json. Display the complete Value Map, suggested topics, unknowns, returned preview fingerprint, and a prominent warning that the exact source and Value Map will enter the repository and may become public through Git history. Respond Insight ready to save and make the one next action: Reply `s` to save this exact insight as a repository diff. Only an exact state-scoped s for the currently displayed fingerprint authorizes piping the unchanged draft to insight save with the same preview nonce and fingerprint. If any source byte, map field, topic, nonce, or fingerprint changes, check again and require a new s. Never save on the initial request, on r, or when Value Map is unavailable. After a successful save respond Insight saved as a repository diff and show its ID and repository path. Do not create a feature, plan, branch, commit, or PR; publication remains a separate explicit action.", "insight-frontier": "Run the tracked .product-loop/boatstack launcher insight frontier --repo . and present the independent captures needing classification, delivery, evidence, terminal observation, or human completion. This operation is strictly read-only: do not append events, change associations, bind deliveries, evaluate by mutation, disposition captures, or alter the authoritative delivery frontier. Respond Insight frontier ready and show one suggested pending action per capture without presenting any insight as Boatstack's single delivery next action.", "root-cause": "Perform failure-mode elimination on a bug, not a patch. This operation is strictly read-only: do not edit product code, create or update artifacts, advance a gate, or contact GitHub; the user supplies the symptom, stack trace, error log, or failing signal as the argument. Locate the failure below its surface symptom and classify it against the failure classes in @.product-loop/failure-moves.md; name the failure CLASS, not the one instance, and if no class fits, name the new class in that vocabulary. Investigate with read-only tools and produce a numbered root-cause chain in which every step is cited to file:line and which distinguishes the crashing frame (the victim) from the true origin (the cause); label authoritative repository facts DISCOVERED and any inference PROPOSED. State the blast radius: every other call site or path exposed to the same class. Propose the minimal STRUCTURAL elimination that makes the whole class unreachable and covers every exposed site, reusing an existing repository pattern or utility where one exists, rather than a local guard on the single line in the trace. Present this as a material product decision with the same tiered paths auto-plan uses under boundary_analysis: [1a] Symptom Patch or [1b] Programmatic Enforcement (a boundary that eliminates the class), and recommend one. Require a regression that reproduces the failure mode before the fix plus the project's own gates as the proof the class is gone, and name related latent hazards left out of scope as non-goals. Then format the result as a host Plan-mode source plan (symptom, root-cause chain, failure mode, blast radius, elimination, non-goals, verification, delivery base branch) and respond Root cause found, making the one next action: save this plan to a durable in-repo path and run auto-plan with it via --plan. Do not implement the fix; hand off to the plan gate.", - "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If an earlier hand-authored draft was never registered and its plan cannot be verified, the guard denies every product mutation at INVALID_STATE with next operation repair-state; run repair-state to quarantine that unregistered malformed draft and return to auto-plan, then re-author the planning Markdown through the complete literal planning-write envelope in .product-loop/workflow.md, never a raw file write or manual shell paste. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", + "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If a selected hand-authored draft cannot be verified, resolve it through repair-state, then re-author its planning Markdown through the complete literal planning-write envelope in .product-loop/workflow.md. The invalid draft never controls unrelated repository tools. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. activate-plan promotes the compiled task graph, test matrix, evidence ledger, and the plan lock together through the transactional mutation boundary as one mutation, so all four land all-or-nothing with a reversible receipt and a failed or interrupted promote leaves the prior state unchanged rather than half-written. The boundary is closed under inversion: mutation-status lists the receipts and undo --mutation reverses a managed-artifact promotion (redo is undo of the undo receipt), with undo refusing to reverse an activation once a delivery gate would be stranded; this governs Boatstack-generated artifacts only, never source code. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", @@ -394,7 +394,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "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 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"] = "Resolve an explicit target and feature slug from --to plan|verified|pr, the user's wording, or the supplied plan; when the target is absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --feature --json and operation-status first and reconcile in-flight work. Carry that feature through every later status and planning call so unrelated saved drafts cannot redirect the run. 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. 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." @@ -418,7 +418,7 @@ The source of truth is @.product-loop/workflow.md and @.product-loop/project.jso Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. Ordinary product intent starts in the host's Plan mode. Pass the resulting plan to auto-plan explicitly with --plan ; Boatstack never scans directories for plans, and must not invent a substitute. Keep that plan file present and current through build. Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation. -Before modifying product code, resolve complete Boatstack state. A saved feature plan latches managed authority: draft, approved, policy-ready, ambiguous, stale, or invalid pre-activation state cannot mutate product files, and only exact planning or activation transitions remain available. Once a current plan lock exists, preserve active managed delivery and published-delivery recovery behavior. Async task completion and conversation state never grant authority. When active or published work receives a product behavior, implementation, test, review, delivery-evidence, CI, or publication problem or modification, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no saved plan or managed delivery exists, continue ordinary conversation. +Before modifying product code for an active delivery, resolve complete Boatstack state. Installation, attachment, saved drafts, approvals, policy-ready plans, ambiguity, and invalid candidates are observations, not ambient workflow authority. They never control ordinary repository tools. Managed workflow control begins only with a current plan lock bound to this worktree and branch. Async task completion and conversation state never grant authority. When active or published work receives a product behavior, implementation, test, review, delivery-evidence, CI, or publication problem or modification, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no current-worktree managed delivery exists, continue ordinary conversation unless the user explicitly invokes Boatstack. %s Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. @@ -438,12 +438,12 @@ Boatstack's repository hooks deny high-confidence irreversible operations across adapterSkill := fmt.Sprintf(`--- name: %s -description: Use when the user asks what is next in Boatstack, asks Boatstack to capture or list product insights, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when product behavior, implementation, test, review, delivery-evidence, CI, or publication changes target an active or current-branch published managed delivery. Do not use for repository administration such as branch sync, status, switching, worktree maintenance, or discarding local changes. +description: Use when the user explicitly asks for Boatstack or when work targets an active or current-branch published Boatstack delivery. Do not activate from installation, attachment, saved or approved drafts, repository presence, or repository administration alone. --- # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, insight-capture, insight-frontier, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route requests to preserve a vague idea or customer message to insight-capture, and requests for pending ideas to the read-only insight-frontier operation. Insight capture is independent of managed delivery: it requires an exact Value Map preview and a separate state-scoped save confirmation, then creates a tracked artifact below docs/insights so the information can cross into engineering through a review PR. No insight content or event may be stored in detached or Git control state. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation, which classifies the failure and produces a source plan to hand to auto-plan; it never edits code or advances a gate. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, insight-capture, insight-frontier, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route requests to preserve a vague idea or customer message to insight-capture, and requests for pending ideas to the read-only insight-frontier operation. Insight capture is independent of managed delivery: it requires an exact Value Map preview and a separate state-scoped save confirmation, then creates a tracked artifact below docs/insights so the information can cross into engineering through a review PR. No insight content or event may be stored in detached or Git control state. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation, which classifies the failure and produces a source plan to hand to auto-plan; it never edits code or advances a gate. Route run and requests such as "run Boatstack through ship" to boatstack-run. Boatstack begins only on an explicit user request or a managed delivery bound to the current worktree and branch. Installation, attachment, saved or approved drafts, ambiguity, and invalid candidates never control ordinary repository tools. After explicit invocation, carry the selected feature slug through status and planning calls. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. %s diff --git a/boatstack/export_test.go b/boatstack/export_test.go index d770e98..268d012 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -375,7 +375,7 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatal("PR brief must remain natural-language behavior, not a public command") } run := string(bundle.Files[".cursor/commands/boatstack-run.md"]) - for _, expected := range []string{"run-preflight --repo . --json", "fetch", "next-status --repo . --json", "three complete automated repair-and-gate cycles", "automatically continue the run", "never merge or deploy"} { + for _, expected := range []string{"run-preflight --repo . --json", "fetch", "next-status --repo . --feature --json", "three complete automated repair-and-gate cycles", "automatically continue the run", "never merge or deploy"} { if !strings.Contains(run, expected) { t.Fatalf("boatstack-run adapter is missing %q", expected) } diff --git a/boatstack/init.go b/boatstack/init.go index 381d47f..1e3ba31 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -751,10 +751,10 @@ func RunInit(options InitOptions) (returnErr error) { const ExecutionBoundaryDX = ` **Boatstack Execution Boundary:** -When the user approves a plan within your native Plan Mode, **do not immediately transition to Auto-Edit or begin mutating product files.** Because this repository is managed by Boatstack, execution must pass through verifiable gates. Instead of executing the code: -1. Save your proposed plan as a durable file inside the repository and pass its path to auto-plan with ` + "`--plan `" + ` (Boatstack does not scan directories for plans; an out-of-repo path is rejected so the plan stays hash-current through build). -2. Before auto-plan succeeds, the user may still choose an unmanaged workflow. Once auto-plan creates a saved feature plan, do not offer direct product editing: resolve Boatstack state and continue through plan-gate, approval when configured, and build activation. -3. Async task completion, conversation state, or an execution-mode transition never creates implementation authority. Only a current plan lock does. +Boatstack workflow control begins only when the user explicitly invokes Boatstack or the current worktree and branch own an active managed delivery. Installation, repository presence, native plan approval, and saved or approved Boatstack drafts do not activate it. +1. On explicit Boatstack invocation, save the proposed plan as a durable file inside the repository and pass its path to auto-plan with ` + "`--plan `" + ` (Boatstack does not scan directories for plans; an out-of-repo path is rejected so the plan stays hash-current through build). +2. Carry the selected feature slug through every status and planning call. After workspace-cut, continue only from its returned destination repository. +3. Async task completion, conversation state, or an execution-mode transition never creates implementation authority. Only a current plan lock bound to this worktree and branch does. ` const interceptorHeader = "\n" diff --git a/boatstack/next.go b/boatstack/next.go index 1f9ad1e..7ace770 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -367,6 +367,7 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr return blockedNextStatus("INVALID_STATE", "discard-delivery", "Boatstack found managed delivery state it cannot verify. Restore its evidence, add it to workflow.ignored_deliveries, or run discard-delivery to clear it before continuing.", invalidDeliveries...), nil } + selectedCandidate := "" if explicitFeature != "" { found := false for _, f := range active { @@ -380,9 +381,26 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr } else if completedState, completedErr := CurrentDeliveryState(repo, explicitFeature); completedErr == nil && completedState.ActiveIndex >= len(completedState.Slices) { return nextForPublished(repo, completedState), nil } else { - // Unverifiable named delivery: discard-delivery accepts and archives it - // (repair-state refuses registered/tracked dirs). Coreachability. - return blockedNextStatus("INVALID_STATE", "discard-delivery", fmt.Sprintf("Feature %s is not a verifiable active or published managed delivery; clear it with discard-delivery.", explicitFeature), explicitFeature), nil + // An explicit Boatstack invocation may select one saved, unactivated + // candidate even when other drafts exist. Selection is routing context, + // not activation authority: the candidate still passes the normal plan, + // workspace, approval, and activation checks below. + // control-law: explicit-selection-scopes-draft-resolution + candidates, candidatesErr := featurePlanCandidates(repo) + if candidatesErr != nil { + return NextStatus{}, candidatesErr + } + for _, candidate := range candidates { + if candidate == explicitFeature { + selectedCandidate = candidate + break + } + } + if selectedCandidate == "" { + // Unverifiable named delivery: discard-delivery accepts and archives it + // (repair-state refuses registered/tracked dirs). Coreachability. + return blockedNextStatus("INVALID_STATE", "discard-delivery", fmt.Sprintf("Feature %s is not a verifiable active, published, or saved managed feature; clear it with discard-delivery.", explicitFeature), explicitFeature), nil + } } } @@ -420,6 +438,9 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr if err != nil { return NextStatus{}, err } + if selectedCandidate != "" { + candidates = []string{selectedCandidate} + } if len(candidates) > 1 { base.VerificationStatus = "BLOCKED" base.ObservedStage = "AMBIGUOUS" diff --git a/boatstack/next_test.go b/boatstack/next_test.go index 380bb2b..ca8da7f 100644 --- a/boatstack/next_test.go +++ b/boatstack/next_test.go @@ -421,6 +421,31 @@ func TestResolveNextPrefersNewDraftOverCompletedHistory(t *testing.T) { } } +// Positive and negative conformance for +// control-law: explicit-selection-scopes-draft-resolution. Explicit selection +// resolves the named draft; the unscoped surface remains honestly ambiguous. +func TestResolveNextExplicitlySelectsOneOfMultipleDrafts(t *testing.T) { + repo := nextTestRepo(t) + writeValidSavedFeaturePlan(t, repo, "first-draft") + writeValidSavedFeaturePlan(t, repo, "second-draft") + + unscoped, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if unscoped.ObservedStage != "AMBIGUOUS" || !reflect.DeepEqual(unscoped.BlockingAmbiguity, []string{"first-draft", "second-draft"}) { + t.Fatalf("unscoped status hid draft ambiguity: %+v", unscoped) + } + + selected, err := ResolveNext(repo, "second-draft") + if err != nil { + t.Fatal(err) + } + if selected.VerificationStatus != "VERIFIED" || selected.Feature != "second-draft" || selected.ObservedStage != "DRAFT_PLAN" || selected.NextOperation != "plan-gate" { + t.Fatalf("explicit draft selection did not resolve deterministically: %+v", selected) + } +} + func TestResolveNextBlocksMultipleActiveFeaturesWithoutMutation(t *testing.T) { repo := nextTestRepo(t) writeNextDelivery(t, repo, "first", "BUILD", 0) diff --git a/boatstack/operation_test.go b/boatstack/operation_test.go index 6e1eb24..5a85794 100644 --- a/boatstack/operation_test.go +++ b/boatstack/operation_test.go @@ -89,6 +89,28 @@ func TestHostHooksCreateAndCompleteOneDurableAttempt(t *testing.T) { } } +// Bypass conformance for control-law: active-delivery-effects-are-supervised. +// Generic query tools and live SQL clients are not proven read-only by their +// names, so both enter durable operation supervision while a delivery is active. +func TestAmbiguousQueryAndLiveSQLAreSupervisedDuringActiveDelivery(t *testing.T) { + tests := map[string][]byte{ + "generic MCP query": []byte(`{"hook_event_name":"PreToolUse","tool_name":"mcp__database__query","tool_input":{"query":"SELECT account_id FROM accounts WHERE account_id = 1"}}`), + "live SQL client": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"psql -c 'SELECT account_id FROM accounts WHERE account_id = 1'"}}`), + } + for name, input := range tests { + t.Run(name, func(t *testing.T) { + repo := activeOperationTestRepo(t) + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: input}); denied { + t.Fatalf("first supervised operation was denied: %s", output) + } + status, err := ResolveOperationStatus(repo, "") + if err != nil || status.Operation == nil || status.Operation.State != OperationExecuting { + t.Fatalf("ambiguous effect bypassed active-delivery supervision: %+v %v", status, err) + } + }) + } +} + func TestAsyncCompletionCannotInitiateAnOperation(t *testing.T) { repo := activeOperationTestRepo(t) post := []byte(`{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"never-started.go","content":"x"},"tool_response":"ok"}`) diff --git a/boatstack/publication_ignored_repro_test.go b/boatstack/publication_ignored_repro_test.go index 89bae28..96d4c2a 100644 --- a/boatstack/publication_ignored_repro_test.go +++ b/boatstack/publication_ignored_repro_test.go @@ -11,13 +11,21 @@ import "testing" // delivery (agentic-l3-full: APPROVED lock, never published) therefore poisons // publication authority for every other delivery. // -// This mirrors the `next` test: two active deliveries, one ignored, neither on -// the current branch. With the ignore list honored, only one active delivery -// remains and the finding must not be ambiguous. +// This mirrors the `next` test: two active deliveries, one ignored, with the +// remaining delivery bound to the current branch. The finding must not be +// ambiguous. func TestPublicationBypassHonorsIgnoredDeliveries(t *testing.T) { repo := nextTestRepo(t) writeNextDelivery(t, repo, "agentic-l3-full", "BUILD", 0) // stale, ignored blocker writeNextDelivery(t, repo, "roles-access-policies", "BUILD", 0) + state, err := LoadDeliveryState(repo, "roles-access-policies") + if err != nil { + t.Fatal(err) + } + state.Slices[0].HeadBranch = "main" + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } setIgnoredDeliveries(t, repo, "agentic-l3-full") finding, blocked := publicationBypassFinding(repo, "denied push", "tool-input") diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index f7d608e..716016b 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -29,7 +29,7 @@ The `root-cause` operation operationalizes this taxonomy for a single bug: it cl | Post-publication correction routing | CI, review, or a denied push targets work already marked published — including a published-but-open *earlier slice* inside a still-active delivery, which the pointer-based advisors mis-routed to the active slice | Resolve the target through the same addressable-slice set the actuators use ({active slice} ∪ {published slices whose PR is not terminal}), keyed off the correction's branch: route a non-terminal published slice to an in-place re-gate/`publish-pr --action update` of *that* slice (not the active one), and only a terminal PR to an independently approved corrective child. Run recovery routing, change recording, and the publication-bypass finding through that one resolver so the advisor layer cannot drift from the actuator layer | Treating PR creation as completion, asking the user to bypass the guard, or an advisor/actuator addressability split-brain that repairs the wrong slice | | Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success | | Postcondition-regressed terminal receipt | A durable local operation once reached `SUCCEEDED`, but its repository postcondition was later restored or regressed while the detached receipt survived; a retry consumes the terminal identity and returns success without rebuilding the missing state | Before consuming terminal success, verify the operation's declared postcondition against current repository and runtime state; keep success when it holds, otherwise reopen only the same bounded atomic-local operation and re-execute under its existing authority | Globally reopening terminal receipts, retrying irreversible external effects, or trusting the receipt instead of the current postcondition | -| Unregistered malformed draft lockout | A hand-authored feature `plan.md` never passed through the helper, so a `CheckPlan` failure escalates to `INVALID_STATE` and the guard denies every product mutation, including the prescribed recovery | `repair-state` quarantines the draft out of `features/` and returns the workflow to `auto-plan`, refusing any directory with a lock, `pr.md`, delivery state, or tracked files | Loosening candidate selection so a genuinely invalid plan silently unblocks product edits | +| Ambient malformed-draft lockout | A hand-authored feature `plan.md` never passed through the helper, so candidate discovery treats its `CheckPlan` failure as global workflow authority and blocks unrelated repository work | Keep unselected drafts observational; route only an explicitly selected candidate through plan-gate validation. `repair-state` remains the bounded quarantine path and refuses any directory with a lock, `pr.md`, delivery state, or tracked files | Silently accepting the malformed selected plan, or weakening managed-path and destructive-operation controls | | Premature supervisory pointer advance | A durable supervisory pointer/state advances on request-success and revokes the correction actuator for a target whose postcondition (CI, merge) is not yet observed, so the stranded target can never be re-addressed | Separate the advance from correctability: keep a bounded in-place actuator for a non-terminal target (re-gate/re-publish the same open PR) and a bounded forward actuator once it is terminal (corrective child); resolve addressability network-free from a persisted terminal-state cache, never advance a supervisory pointer past an unobserved postcondition | Serializing legitimately-parallel work by refusing to advance, or persisting an identity/status that deadlocks the corrected retry | | Non-transactional multi-file promote | A managed artifact spans files that must land together (e.g. the compiled `tasks.json`, `test-matrix.json`, `evidence.md`, and the `plan.lock.json` that binds them), but independent non-atomic writes can leave a partial set on a crash or a failed post-write check | Promote the whole set through the transactional mutation boundary as one mutation: base-hash preconditions, supervisor-authority binding, atomic all-or-nothing write, post-write verification with automatic rollback, and a reversible receipt whose inverse bytes make the boundary closed under inversion — `undo` re-applies the inverse as a mutation (with redo as undo-of-the-undo), and a domain guard refuses reversal once a delivery gate would be stranded | Patching consistency after the fact with hash guards instead of making the promote atomic, persisting a rejected identity so a corrected retry deadlocks, or undoing an activation that strands live delivery state | | Provenance-blind runtime install | A write path stamps an artifact's declared identity (version/commit) from one origin — the running process's compile-time globals — while binding its integrity proof (checksum) to a different origin — the passed bytes; every checksum gate passes because the lock is internally consistent, but the binary self-reports a third value and the version gate fail-closes (clone-wide when the runtime is shared). Symptom: `update -binary ` run by an older helper writes newer bytes into the older version's slot, then every worktree's guard denies at once | Derive the installed artifact's identity from the artifact itself (execute its `version` self-report) and enforce it at the *write* boundary: refuse to install a `-binary` whose self-report disagrees with the running process, and re-exec a cross-version candidate so it installs itself — running becomes installed, so its embedded bundle, constants, slot path, and receipts are all authoritative by construction. Re-hash the just-written slot against its manifest and roll back on mismatch | Executing an untrusted candidate (bounded, operator-invoked only), or converting a recoverable slot mismatch into a hard clone-wide refuse that blocks legitimate upgrades; a per-read self-report exec would tax every guard event, so identity is enforced where it is written, not on the hydration hot path | diff --git a/boatstack/repair_state_test.go b/boatstack/repair_state_test.go index 8ea0489..0437afb 100644 --- a/boatstack/repair_state_test.go +++ b/boatstack/repair_state_test.go @@ -9,8 +9,8 @@ import ( // writeMalformedDraft models a legacy failure: an agent hand-authored a // feature directory with a prose plan.md but never let the helper register it, so -// there is no plan.lock.json and no delivery state. CheckPlan fails on it, which -// the guard escalates to INVALID_STATE and denies every product mutation. +// there is no plan.lock.json and no delivery state. CheckPlan fails on it, but +// the draft remains observational until that feature is explicitly selected. func writeMalformedDraft(t *testing.T, repo, feature string) string { t.Helper() directory := filepath.Join(repo, ".product-loop", "features", feature) @@ -47,17 +47,24 @@ func TestControlledPhaseTransitionAllowsRepairStateAcrossStages(t *testing.T) { } } -// TestRepairStateClosesTheInvalidStateLoop is the end-to-end contract: with a -// malformed unregistered draft on disk, a product mutation is denied and names -// repair-state, and repair-state itself is then allowed — the recovery the guard -// prescribes is genuinely reachable. -func TestRepairStateClosesTheInvalidStateLoop(t *testing.T) { +// TestRepairStateRemainsExplicitWithoutAmbientLockout is the end-to-end contract: with a +// malformed unregistered draft on disk, ordinary product work remains outside +// managed scope while explicit feature resolution reaches plan validation and +// the bounded repair command remains available. +func TestRepairStateRemainsExplicitWithoutAmbientLockout(t *testing.T) { repo := nextTestRepo(t) writeMalformedDraft(t, repo, "stuck-feature") findings := ClassifyCommand(repo, "python scripts/migrate.py") - if len(findings) == 0 || findings[0].WorkflowStage != "INVALID_STATE" || findings[0].NextOperation != "repair-state" { - t.Fatalf("product mutation was not denied with a repair-state prescription: %#v", findings) + if len(findings) != 0 { + t.Fatalf("unselected malformed draft controlled ordinary mutation: %#v", findings) + } + status, err := ResolveNext(repo, "stuck-feature") + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "DRAFT_PLAN" || status.NextOperation != "plan-gate" { + t.Fatalf("selected draft did not route through plan validation: %#v", status) } if denied := ClassifyCommand(repo, ".product-loop/boatstack repair-state --repo . --feature stuck-feature"); len(denied) != 0 { t.Fatalf("the prescribed recovery was itself denied: %#v", denied) diff --git a/boatstack/safety.go b/boatstack/safety.go index de1fa5c..45c63e2 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -67,7 +67,7 @@ func malformedHookInput(code string) error { // idioms — recovery-status | jq, git diff | wc -l, … | sort | uniq -c — compose // freely. Effect-CHANGING syntax (redirection > <, command substitution $()) is // still banned in isPureReadOnlyCommand, so no filter can be turned into a writer. -var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|psql\s+[^\n]*\s-c\s+["']?\s*select\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b)`) +var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b)`) // Constitutional/Optimization split. These destruction rules are CONSTITUTIONAL: // they define the real boundary (destroying a live resource) and are never traded @@ -153,7 +153,7 @@ var insightGitStagingPattern = regexp.MustCompile(`(?i)^\s*git\s+(?:add|diff|sta var insightInPlaceMutationPattern = regexp.MustCompile(`(?i)\bsed\s+-[^\s]*i(?:\.[^\s]+)?\b`) var mutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create|delete|remove|move|rename|update|insert|upload|install)`) var planningMutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create)`) -var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|query|fetch|open)(?:[_-]|$)`) +var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|fetch|open)(?:[_-]|$)`) // featuresCommandPathPattern extracts a .product-loop/features/… operand from a // shell command so the first-write latch can see raw shell writes (cp, tee, >) @@ -406,9 +406,9 @@ func fileWriterTool(nameLower, attemptedPath string) bool { } // featureScopedPath reports whether a repo-relative path lands anywhere under -// the managed planning tree. Broader than planningMarkdownPath on purpose: the -// first-write latch covers every depth and name, while planningMarkdownPath -// stays the exact allowlist for the bounded DRAFT_PLAN carve-out. +// the managed planning tree. It is broader than planningMarkdownPath on +// purpose: raw host mutation is denied at every depth, while the owned +// planning-write command validates the exact artifact allowlist itself. func featureScopedPath(path string) bool { return strings.HasPrefix(filepath.ToSlash(path), ".product-loop/features/") } @@ -434,93 +434,35 @@ func planningMarkdownPath(path string) bool { return len(parts) == 4 && featureSlugPattern.MatchString(parts[2]) && planningArtifacts[parts[3]] } -// preActivationFinding decides whether a product mutation is denied before a plan -// reaches its activation boundary, and — per the Coreachability invariant — names -// a recovery verb that actually CLEARS the cause it reports. A "cannot verify / -// cannot resolve" error is observation loss (a channel fault), not a plant defect: -// no mutation verb repairs it, so it is classified distinctly and routed to the -// read-only doctor to diagnose the channel — never to repair-state, which acts on a -// malformed draft and would refuse. A genuinely malformed draft routes to -// repair-state; every other stage carries ResolveNext's own (already Coreachable) -// next operation. +// preActivationFinding protects only Boatstack-owned planning state before plan +// activation. Repository attachment, saved plans, approvals, and ambiguous or +// invalid candidates are observations, not authority over ordinary repository +// tools. Product mutation is therefore outside this pre-activation boundary; +// activation rechecks the plan, approval or autonomy receipt, branch, worktree, +// and product baseline before it creates managed delivery state. +// +// The first raw write into the feature tree remains denied. Every planning +// artifact crosses the owned planning-write boundary, so an ambient hook cannot +// be bypassed by creating or editing controller state with a host writer. +// control-law: ambient-plans-never-activate-workflow-control +// control-law: first-planning-write-uses-the-owned-channel func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) { - active, err := ActiveManagedDeliveries(repo) - if err != nil { - // Same-Relation-Same-Law: the mutation boundary FAILS CLOSED on invalid - // delivery state — ignoring a delivery quiets status but never launders corrupt - // state into a mutation (TestActiveManagedDeliveriesStaysFailClosedOnInvalid). - // But the block must be Coreachable: distinguish a corrupt-delivery *plant* - // fault (a verb clears it — discard-delivery, named) from an *observation* - // (channel) fault (doctor to diagnose). Naming discard-delivery is what tells - // the operator how to actually unblock mutation, since ignoring will not. - if _, invalid, scanErr := scanManagedDeliveries(repo); scanErr == nil && len(invalid) > 0 { - return SafetyFinding{ - Category: "workflow-state-invalid", Source: "delivery-state", - Reason: "invalid managed delivery state blocks all mutation; ignoring it only quiets status — clear it with discard-delivery to continue", - NextOperation: "discard-delivery", BlockingFeature: invalid[0], AttemptedPath: attemptedPath, - }, true - } - return SafetyFinding{Category: "workflow-observation-fault", Reason: "managed delivery state cannot be verified; diagnose the channel with doctor", Source: "delivery-state", NextOperation: "doctor"}, true - } - if len(active) > 0 { - return SafetyFinding{}, false - } - candidates, err := featurePlanCandidates(repo) - if err != nil { - return SafetyFinding{Category: "workflow-observation-fault", Reason: "saved feature plans cannot be verified; diagnose the channel with doctor", Source: "planning-state", NextOperation: "doctor"}, true - } - if len(candidates) == 0 { - // First-write latch: even before any plan candidate exists, the managed - // planning tree is authored only through the owned channel. Without this, - // the very first raw host write of plan.md registers a malformed draft and - // the agent discovers planning-write only by failing into INVALID_STATE. - // The deny is path-scoped — ordinary product writes stay unlatched at zero - // candidates. Stage NOT_STARTED is what ResolveNext reports here, and - // controlledPhaseTransition admits planning-write at that stage, so the - // denial names a verb the guard accepts (Coreachability). - // control-law: first-planning-write-uses-the-owned-channel - if featureScopedPath(attemptedPath) { - finding := SafetyFinding{ - Category: "workflow-phase-bypass", Reason: "planning Markdown is created through the owned channel; a raw first write into .product-loop/features/ is denied", Source: "planning-state", - WorkflowStage: "NOT_STARTED", AttemptedPath: attemptedPath, NextOperation: "planning-write", - } - if parts := strings.Split(filepath.ToSlash(attemptedPath), "/"); len(parts) > 2 && featureSlugPattern.MatchString(parts[2]) { - finding.BlockingFeature = parts[2] - } - return finding, true - } + if !featureScopedPath(attemptedPath) { return SafetyFinding{}, false } - status, err := ResolveNext(repo, "") - if err != nil { - return SafetyFinding{Category: "workflow-observation-fault", Reason: "workflow state cannot be resolved; diagnose the channel with doctor", Source: "planning-state", NextOperation: "doctor"}, true - } - if status.ObservedStage != "DRAFT_PLAN" && status.ObservedStage != "APPROVED" && status.ObservedStage != "POLICY_READY" && status.ObservedStage != "AMBIGUOUS" && status.ObservedStage != "INVALID_STATE" { - return SafetyFinding{}, false - } - if len(candidates) == 1 && status.ObservedStage != "AMBIGUOUS" { - planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(candidates[0]), "plan.md") - check, checkErr := CheckPlan(planPath) - if checkErr != nil { - return SafetyFinding{ - Category: "workflow-phase-bypass", Reason: "saved plan state is invalid", Source: "planning-state", - BlockingFeature: candidates[0], WorkflowStage: "INVALID_STATE", AttemptedPath: attemptedPath, NextOperation: "repair-state", - }, true - } - if status.ObservedStage == "APPROVED" { - approvalPath := filepath.Join(filepath.Dir(planPath), "approval.md") - if _, approvalErr := CheckApprovalReceipt(approvalPath, check); approvalErr != nil { - return SafetyFinding{ - Category: "workflow-phase-bypass", Reason: "approval or product baseline is stale", Source: "planning-state", - BlockingFeature: candidates[0], WorkflowStage: "INVALID_STATE", AttemptedPath: attemptedPath, NextOperation: "plan-gate", - }, true + finding := SafetyFinding{ + Category: "workflow-phase-bypass", Reason: "Boatstack planning state may be changed only through the owned planning-write channel", Source: "planning-state", + WorkflowStage: "NOT_STARTED", AttemptedPath: attemptedPath, NextOperation: "planning-write", + } + if parts := strings.Split(filepath.ToSlash(attemptedPath), "/"); len(parts) > 2 && featureSlugPattern.MatchString(parts[2]) { + finding.BlockingFeature = parts[2] + if fileExists(filepath.Join(WorkspaceFor(repo).FeatureDir(parts[2]), "plan.md")) { + if status, err := ResolveNext(repo, parts[2]); err == nil { + finding.WorkflowStage = status.ObservedStage } } } - return SafetyFinding{ - Category: "workflow-phase-bypass", Reason: "product mutation is denied until the saved plan reaches its controlled activation boundary", Source: "planning-state", - BlockingFeature: status.Feature, WorkflowStage: status.ObservedStage, AttemptedPath: attemptedPath, NextOperation: status.NextOperation, - }, true + return finding, true } func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) { @@ -541,27 +483,25 @@ func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) return SafetyFinding{}, false } branch, _ := gitCommand(repo, "branch", "--show-current") - selected := "" - relation := "unrelated" + matching := []string{} for _, feature := range active { state, loadErr := LoadDeliveryState(repo, feature) if loadErr == nil && stateMatchesBranch(state, strings.TrimSpace(branch)) { - if selected != "" { - selected = strings.Join(active, ",") - relation = "ambiguous" - break - } - selected = feature - relation = "current_branch" + matching = append(matching, feature) } } - if selected == "" { - if len(active) == 1 { - selected = active[0] - } else { - selected = strings.Join(active, ",") - relation = "ambiguous" - } + // A delivery in another branch or worktree supplies no publication authority + // here. Direct publication remains guarded when the current branch actually + // owns one or more managed delivery slices. + // control-law: managed-publication-scope-follows-current-branch + if len(matching) == 0 { + return SafetyFinding{}, false + } + selected := matching[0] + relation := "current_branch" + if len(matching) > 1 { + selected = strings.Join(matching, ",") + relation = "ambiguous" } finding := SafetyFinding{ Category: "workflow-publication-bypass", Reason: reason, Source: source, @@ -882,7 +822,7 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if !isPureReadOnlyCommand(command) { // Feed any named .product-loop/features/ operand so the first-write latch // sees raw shell writes (cp/tee/redirect) the same way it sees a Write tool. - if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) { + if finding, blocked := preActivationFinding(repo, featuresPathInCommand(command)); blocked && !approvedPublisherPattern.MatchString(command) && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) { return []SafetyFinding{finding} } } @@ -946,9 +886,7 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { mutationCapable := mutationToolPattern.MatchString(nameLower) || (strings.HasPrefix(nameLower, "mcp__") && !externalReadOnlyToolPattern.MatchString(nameLower)) if mutationCapable { if finding, blocked := preActivationFinding(repo, attemptedPath); blocked { - if finding.WorkflowStage != "DRAFT_PLAN" || attemptedPath == "" || !planningMarkdownPath(attemptedPath) || !planningMutationToolPattern.MatchString(nameLower) { - findings = append(findings, finding) - } + findings = append(findings, finding) } } publicationText := strings.ToLower(combined) diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 63a2c14..056fcc3 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -771,7 +771,12 @@ func TestPlanningMarkdownPathRejectsIntakeStaging(t *testing.T) { } } -func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { +// Positive and relation conformance for +// control-law: ambient-plans-never-activate-workflow-control. +// A saved draft is visible to Boatstack, but it supplies no authority over +// ordinary repository tools. The hook retains its always-on destructive floor +// and the separate owned planning-state boundary. +func TestSavedPlanDoesNotActivateWorkflowControl(t *testing.T) { repo := nextTestRepo(t) writeValidSavedFeaturePlan(t, repo, "guarded-feature") statusBefore, err := gitCommand(repo, "status", "--short") @@ -779,18 +784,20 @@ func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { t.Fatal(err) } - assertBlocked := func(label string, findings []SafetyFinding) { + assertAllowed := func(label string, findings []SafetyFinding) { t.Helper() - if len(findings) == 0 || findings[0].Category != "workflow-phase-bypass" || findings[0].WorkflowStage != "DRAFT_PLAN" || findings[0].NextOperation != "plan-gate" { - t.Fatalf("%s escaped the draft plan interlock: %#v", label, findings) + if len(findings) != 0 { + t.Fatalf("%s was controlled by an ambient draft: %#v", label, findings) } } - assertBlocked("native edit", ClassifyTool(repo, "Write", map[string]any{"file_path": "src/app.ts", "content": "changed"})) - assertBlocked("patch", ClassifyTool(repo, "ApplyPatch", map[string]any{"path": "src/app.ts", "patch": "diff"})) - assertBlocked("shell redirection", ClassifyCommand(repo, "printf changed > src/app.ts")) - assertBlocked("package installation", ClassifyCommand(repo, "npm install example")) - assertBlocked("MCP mutation", ClassifyTool(repo, "mcp__files__update", map[string]any{"path": "src/app.ts"})) - assertBlocked("unknown MCP capability", ClassifyTool(repo, "mcp__files__act", map[string]any{"path": "src/app.ts"})) + assertAllowed("native edit", ClassifyTool(repo, "Write", map[string]any{"file_path": "src/app.ts", "content": "changed"})) + assertAllowed("patch", ClassifyTool(repo, "ApplyPatch", map[string]any{"path": "src/app.ts", "patch": "diff"})) + assertAllowed("shell redirection", ClassifyCommand(repo, "printf changed > src/app.ts")) + assertAllowed("package installation", ClassifyCommand(repo, "npm install example")) + assertAllowed("analysis CLI", ClassifyCommand(repo, "analysis-tool inspect --model system.json")) + assertAllowed("test runner", ClassifyCommand(repo, "go test ./...")) + assertAllowed("MCP mutation", ClassifyTool(repo, "mcp__files__update", map[string]any{"path": "src/app.ts"})) + assertAllowed("unknown MCP capability", ClassifyTool(repo, "mcp__files__act", map[string]any{"path": "src/app.ts"})) if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 { t.Fatalf("read-only inspection was denied: %#v", findings) @@ -801,8 +808,8 @@ func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { if findings := ClassifyCommand(repo, ".product-loop/boatstack check-plan --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { t.Fatalf("bounded plan inspection was denied: %#v", findings) } - if findings := ClassifyTool(repo, "Write", map[string]any{"file_path": ".product-loop/features/guarded-feature/plan.md", "content": "# revised plan"}); len(findings) != 0 { - t.Fatalf("bounded planning Markdown was denied: %#v", findings) + if findings := ClassifyTool(repo, "Write", map[string]any{"file_path": ".product-loop/features/guarded-feature/plan.md", "content": "# revised plan"}); len(findings) == 0 || findings[0].NextOperation != "planning-write" { + t.Fatalf("raw managed planning write escaped the owned channel: %#v", findings) } if findings := ClassifyCommand(repo, ".product-loop/boatstack record-approval --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { t.Fatalf("exact approval transition was denied: %#v", findings) @@ -816,7 +823,7 @@ func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { } } -func TestPreActivationInterlockPreservesUnmanagedAndActivatedBehavior(t *testing.T) { +func TestUnactivatedApprovalAndPolicyStateRemainObservations(t *testing.T) { unmanaged := nextTestRepo(t) if findings := ClassifyTool(unmanaged, "Write", map[string]any{"file_path": "src/app.ts"}); len(findings) != 0 { t.Fatalf("unmanaged product editing changed: %#v", findings) @@ -838,8 +845,8 @@ func TestPreActivationInterlockPreservesUnmanagedAndActivatedBehavior(t *testing t.Fatal(err) } findings := ClassifyTool(approved, "Edit", map[string]any{"path": "src/app.ts"}) - if len(findings) == 0 || findings[0].WorkflowStage != "APPROVED" || findings[0].NextOperation != "build" { - t.Fatalf("approved-but-not-activated product edit escaped: %#v", findings) + if len(findings) != 0 { + t.Fatalf("approved-but-not-activated plan controlled product tools: %#v", findings) } policy := nextTestRepo(t) @@ -854,24 +861,25 @@ func TestPreActivationInterlockPreservesUnmanagedAndActivatedBehavior(t *testing } writeValidSavedFeaturePlan(t, policy, "policy-feature") findings = ClassifyTool(policy, "Write", map[string]any{"path": "src/app.ts"}) - if len(findings) == 0 || findings[0].WorkflowStage != "POLICY_READY" || findings[0].NextOperation != "build" { - t.Fatalf("policy-ready product edit escaped: %#v", findings) + if len(findings) != 0 { + t.Fatalf("policy-ready but unactivated plan controlled product tools: %#v", findings) } } -func TestCursorPreToolUseDeniesNativeEditAfterAutoPlan(t *testing.T) { +func TestCursorPreToolUseAllowsNativeEditWithAmbientDraft(t *testing.T) { repo := nextTestRepo(t) writeValidSavedFeaturePlan(t, repo, "cursor-feature") input := []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`) - for attempt := 0; attempt < 2; attempt++ { // a conversation notification cannot change authority + for attempt := 0; attempt < 2; attempt++ { output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: input}) - if !denied || !strings.Contains(string(output), `"permission":"deny"`) || !strings.Contains(string(output), "plan-gate") { - t.Fatalf("Cursor native edit was not deterministically denied: %s", output) + if denied || !strings.Contains(string(output), `"permission":"allow"`) { + t.Fatalf("Cursor native edit was controlled by an ambient draft: %s", output) } } } -func TestPreActivationNativeEditIsDeniedAcrossHostContracts(t *testing.T) { +// Relation conformance: every supported host reaches the same ambient boundary. +func TestAmbientDraftDoesNotControlNativeEditAcrossHostContracts(t *testing.T) { repo := nextTestRepo(t) writeValidSavedFeaturePlan(t, repo, "host-conformance") tests := map[string][]byte{ @@ -883,8 +891,8 @@ func TestPreActivationNativeEditIsDeniedAcrossHostContracts(t *testing.T) { for host, input := range tests { t.Run(host, func(t *testing.T) { output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}) - if !denied || !strings.Contains(string(output), "plan-gate") { - t.Fatalf("%s native mutation escaped: %s", host, output) + if denied { + t.Fatalf("%s native tool was controlled by an ambient draft: %s", host, output) } }) } diff --git a/boatstack/safety_update_publisher_test.go b/boatstack/safety_update_publisher_test.go index 1ef9575..d050eb2 100644 --- a/boatstack/safety_update_publisher_test.go +++ b/boatstack/safety_update_publisher_test.go @@ -130,7 +130,7 @@ func TestApprovedPublishPRStaysAllowedDuringActiveDelivery(t *testing.T) { runGit(t, repo, "init", "-b", "main") if err := saveDeliveryState(repo, DeliveryState{ SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64), - ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "REVIEW"}}, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "REVIEW", HeadBranch: "main"}}, }); err != nil { t.Fatal(err) } diff --git a/boatstack/supervisory_control_test.go b/boatstack/supervisory_control_test.go index 210401e..00fa7d1 100644 --- a/boatstack/supervisory_control_test.go +++ b/boatstack/supervisory_control_test.go @@ -253,9 +253,9 @@ func TestSupervisoryControlNeverDeadlocks(t *testing.T) { } }) - // A malformed, unregistered, untracked draft is prescribed repair-state; that - // verb must accept and quarantine it. - t.Run("malformed draft is prescribed repair-state which clears it", func(t *testing.T) { + // A malformed draft remains visible to explicit Boatstack resolution without + // controlling unrelated repository work. + t.Run("malformed draft is explicitly repairable without ambient control", func(t *testing.T) { repo := nextTestRepo(t) dir := filepath.Join(repo, ".product-loop", "features", "broken") if err := os.MkdirAll(dir, 0o755); err != nil { @@ -264,12 +264,15 @@ func TestSupervisoryControlNeverDeadlocks(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "plan.md"), []byte("not a valid plan\n"), 0o644); err != nil { t.Fatal(err) } - finding, blocked := preActivationFinding(repo, filepath.Join(dir, "x.go")) - if !blocked || finding.NextOperation != "repair-state" { - t.Fatalf("malformed draft prescribed %q (blocked=%v), want repair-state", finding.NextOperation, blocked) + if finding, blocked := preActivationFinding(repo, "src/x.go"); blocked { + t.Fatalf("malformed ambient draft controlled product work: %+v", finding) } - if !controlledPhaseTransition("boatstack-helper repair-state --repo .", finding.WorkflowStage) { - t.Fatal("prescribed repair-state is not admitted for the malformed draft") + status, err := ResolveNext(repo, "broken") + if err != nil { + t.Fatal(err) + } + if status.NextOperation != "plan-gate" { + t.Fatalf("selected draft prescribed %q, want plan-gate validation", status.NextOperation) } result, err := RepairState(repo, "broken") if err != nil { diff --git a/release-notes/2026-08-10-explicit-workflow-scope.md b/release-notes/2026-08-10-explicit-workflow-scope.md new file mode 100644 index 0000000..84efe1c --- /dev/null +++ b/release-notes/2026-08-10-explicit-workflow-scope.md @@ -0,0 +1,3 @@ +### Saved plans no longer control ordinary repository work + +Boatstack now keeps its destructive-operation safety floor active without treating installation, saved drafts, approvals, or unrelated delivery state as authority over normal tools. Explicit feature selection resolves the intended draft, and managed workflow controls remain bound to an active delivery in its owning worktree and branch. From 0e50bcd2a505bef29febe1ad3aad4e341c025e59 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Mon, 10 Aug 2026 03:17:34 +0100 Subject: [PATCH 2/2] test: bind publication fixture to current branch --- boatstack/publication_ignored_repro_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/boatstack/publication_ignored_repro_test.go b/boatstack/publication_ignored_repro_test.go index 96d4c2a..77ed236 100644 --- a/boatstack/publication_ignored_repro_test.go +++ b/boatstack/publication_ignored_repro_test.go @@ -22,7 +22,7 @@ func TestPublicationBypassHonorsIgnoredDeliveries(t *testing.T) { if err != nil { t.Fatal(err) } - state.Slices[0].HeadBranch = "main" + state.Slices[0].HeadBranch = gitOutput(repo, "branch", "--show-current") if err := saveDeliveryState(repo, state); err != nil { t.Fatal(err) }