From b989d4fb0a70db4767c13d31ebbaca1eaf54311f Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 9 Aug 2026 19:11:37 +0100 Subject: [PATCH 1/4] fix: make worktree runtime selection deterministic --- README.md | 4 +- boatstack/SKILL.md | 24 +- .../cmd/boatstack-helper/command_trace.go | 2 +- .../coverage_conformance_test.go | 23 +- boatstack/cmd/boatstack-helper/main.go | 19 +- boatstack/denial.go | 10 +- .../denial_escalation_conformance_test.go | 2 +- boatstack/export.go | 37 ++- boatstack/export_test.go | 34 ++- boatstack/flow_control.go | 26 +- boatstack/flow_control_test.go | 2 +- boatstack/init.go | 4 +- boatstack/launcher.go | 174 +++++++++++ boatstack/launcher_test.go | 285 ++++++++++++++++++ boatstack/next_response_conformance_test.go | 12 +- boatstack/paths.go | 17 +- .../planning_first_write_conformance_test.go | 2 +- boatstack/planning_transport.go | 29 +- .../planning_transport_conformance_test.go | 23 +- ...post_publish_prescribe_conformance_test.go | 4 +- boatstack/references/artifacts.md | 5 +- .../irreversible-operation-boundary.md | 2 +- boatstack/references/portability.md | 3 +- boatstack/references/workflow.md | 12 +- boatstack/repair_state_test.go | 8 +- boatstack/safety.go | 14 +- boatstack/safety_corpus_test.go | 2 +- boatstack/safety_test.go | 16 +- boatstack/safety_update_publisher_test.go | 34 ++- boatstack/statemap.go | 7 +- boatstack/statemap_conformance_test.go | 5 +- docs/configuration.md | 2 +- docs/generated-files.md | 10 +- docs/getting-started.md | 8 +- docs/public-claims.json | 8 +- docs/troubleshooting.md | 24 +- docs/why-these-steps.md | 4 +- labs/diagram-json/README.md | 6 +- ...deterministic-worktree-runtime-launcher.md | 3 + 39 files changed, 752 insertions(+), 154 deletions(-) create mode 100644 boatstack/launcher.go create mode 100644 boatstack/launcher_test.go create mode 100644 release-notes/2026-08-09-deterministic-worktree-runtime-launcher.md diff --git a/README.md b/README.md index dfcf4acf..2cb1490c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Copy this into Cursor, Codex, Claude Code, or Gemini CLI while the repository is Install Boatstack in this repository from https://github.com/operatorstack/boatstack. Detect whether you are running in Cursor, Codex, Claude Code, or Gemini CLI; create or use a chore/install-boatstack branch; run the official installer for this operating system; default to core unless I request gstack or Spec Kit; keep all portable host adapters; run Boatstack doctor; show me the generated files and installation diff; and prepare the installation PR without merging it or starting product work. ``` -Install Boatstack in its own infrastructure PR. Merge that PR before you start a feature. Install once per Git clone. Linked worktrees reuse the verified runtime and restore their ignored local helper automatically. +Install Boatstack in its own infrastructure PR. Merge that PR before you start a feature. After that one repository adoption, fresh clones and linked worktrees inherit tracked launchers that activate the exact verified runtime automatically. ## Start with two moves @@ -165,7 +165,7 @@ 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 had the hook but not its ignored helper | Restores the verified local runtime before judging the command | Linked-worktree and tamper 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 | [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. diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 12867639..8b2c247c 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -30,9 +30,9 @@ 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/bin/boatstack-helper 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. 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. -To see every feature at once, run the read-only `.product-loop/bin/boatstack-helper 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/bin/boatstack-helper 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. +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 @@ -52,7 +52,7 @@ This enforcement is defense in depth, not a complete sandbox. Keep least-privile Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes are repository administration, not product intent. Never route them to `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. For an explicit branch and remote ref, use the project-local `workspace-sync` helper. It fetches the exact source, checkpoints branch and dirty-worktree state, aligns the branch in its owning worktree, and returns verified recovery refs. -For requests such as “ensure main is same as origin/main remove any current changes,” inspect only the named refs and worktree, then invoke `.product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main`. If the guard denies a raw hard reset or clean, report the denial and this single recovery action immediately. Do not inspect feature plans, scan the repository, search for the helper, or retry destructive Git. +For requests such as “ensure main is same as origin/main remove any current changes,” inspect only the named refs and worktree, then invoke `.product-loop/boatstack workspace-sync --repo . --branch main --source origin/main`. If the guard denies a raw hard reset or clean, report the denial and this single recovery action immediately. Do not inspect feature plans, scan the repository, search for the helper, or retry destructive Git. ## Bound the outcome @@ -84,7 +84,7 @@ Do not scan the entire repository by default. Record discovered paths and comman ## Respond to the developer -Follow the **User-facing response contract** in `references/workflow.md` for every operation. Begin every Boatstack response with the status banner (`boatstack-helper next-status --repo . --render`), then lead with the mapped plain-language outcome, show only decision-relevant content, end with one `### Next step`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed **Technical details**. Internal operations such as `check-plan`, `record-approval`, and `activate-plan` 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. +Follow the **User-facing response contract** in `references/workflow.md` for every operation. Begin every Boatstack response with the status banner (`.product-loop/boatstack next-status --repo . --render`), then lead with the mapped plain-language outcome, show only decision-relevant content, end with one `### Next step`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed **Technical details**. Internal operations such as `check-plan`, `record-approval`, and `activate-plan` 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. Use the global, state-scoped reply shortcuts for finite input: `a` approves the pending plan, `o` opens the currently previewed feature/ad-hoc/update PR, `u` updates the currently previewed existing PR, and `r` accepts every recommendation displayed in the current finite-question response. Trim surrounding whitespace and match the complete reply case-insensitively. Bracketed forms such as `[o]`, embedded letters, and shortcuts from another state are ordinary text. Continue accepting `approve`, `open PR`, `update PR`, and `open update PR` for compatibility, but do not advertise them in user-facing responses. @@ -100,7 +100,7 @@ Before starting `/auto-plan` for a new feature, check `next-status --repo . --js ## Run `auto-plan` -0. Require the plan file produced in the active host's Plan mode, passed explicitly. Validate it with `.product-loop/bin/boatstack-helper check-source-plan --repo . --plan `. Boatstack never scans directories for plans, so `--plan` is required and no unshipped saved plan becomes ambient context. If no plan path is supplied or the file is missing, empty, or unreadable, return `BLOCKED`; do not write or guess the missing source plan inside `auto-plan`. Because its hash is re-checked through `build`, point `--plan` at a durable in-repo path that stays present and unchanged; a path outside the repository is rejected. +0. Require the plan file produced in the active host's Plan mode, passed explicitly. Validate it with `.product-loop/boatstack check-source-plan --repo . --plan `. Boatstack never scans directories for plans, so `--plan` is required and no unshipped saved plan becomes ambient context. If no plan path is supplied or the file is missing, empty, or unreadable, return `BLOCKED`; do not write or guess the missing source plan inside `auto-plan`. Because its hash is re-checked through `build`, point `--plan` at a durable in-repo path that stays present and unchanged; a path outside the repository is rejected. 1. Treat the supplied plan as an initial proposal, not approved truth. Record its path as `source_plan_path` in the structured plan. 2. Write the bounded outcome definition before proposing architecture. 3. Separate facts, decisions, unknowns, and safely deferrable gaps. @@ -117,7 +117,7 @@ Before starting `/auto-plan` for a new feature, check `next-status --repo . --js 13. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative. 14. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`. 14. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names. -15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Author every feature artifact through the owned channel: pass the complete document to `.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact ` using the literal planning transport in `.product-loop/workflow.md` — a single-quoted heredoc in a POSIX shell or the UTF-8-scoped single-quoted here-string in PowerShell. This is the primary writer for `.product-loop/features/`, not a fallback, and it remains available after the planning latch denies raw writes. Send the complete envelope in one tool call. Never run the helper without input, split the envelope across calls, use an expansion-capable delimiter, target another repository or helper, or paste Markdown at a shell prompt. Put the authoritative JSON inside the marked Boatstack block and run `.product-loop/bin/boatstack-helper check-plan --plan /plan.md`; this command is read-only. The host's ordinary Markdown writer may be used only where the host explicitly permits it. Never use arbitrary shell redirection to evade a host write boundary. +15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Author every feature artifact through the owned channel: pass the complete document to `.product-loop/boatstack planning-write --repo . --feature --artifact ` using the literal planning transport in `.product-loop/workflow.md` — a single-quoted heredoc in a POSIX shell or the UTF-8-scoped single-quoted here-string in PowerShell. This is the primary writer for `.product-loop/features/`, not a fallback, and it remains available after the planning latch denies raw writes. Send the complete envelope in one tool call. Never run the helper without input, split the envelope across calls, use an expansion-capable delimiter, target another repository or helper, or paste Markdown at a shell prompt. Put the authoritative JSON inside the marked Boatstack block and run `.product-loop/boatstack check-plan --plan /plan.md`; this command is read-only. The host's ordinary Markdown writer may be used only where the host explicitly permits it. Never use arbitrary shell redirection to evade a host write boundary. 16. Keep implementation tasks separate from publication authority. Internal phases remain tasks inside one delivery slice. When the accepted outcome explicitly requires multiple PRs, declare ordered `delivery_slices`; assign every task exactly once and give each slice its own optional base/head branch contract. Plan approval approves this structure but never authorizes a push or PR. 17. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`. @@ -130,14 +130,14 @@ Treat repository-owned product context as canonical. Do not require it to be mig 1. Run the read-only Markdown preflight and retain its exact fingerprint: ```bash -.product-loop/bin/boatstack-helper check-plan \ +.product-loop/boatstack check-plan \ --plan .product-loop/features//plan.md ``` 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 `boatstack-helper record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`. +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. 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`. @@ -148,7 +148,7 @@ All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gs - Before the first product-code edit, activate the exact authorized Markdown plan. Include `--approval` only when `workflow.human_plan_approval` is true: ```bash -.product-loop/bin/boatstack-helper activate-plan \ +.product-loop/boatstack activate-plan \ --plan .product-loop/features//plan.md \ --out-dir .product-loop/features//compiled \ --output .product-loop/features//plan.lock.json @@ -176,7 +176,7 @@ Before any product edit or explicit `repair`, run `recovery-status` with the exa If Cursor reports `MainThreadShellExec not initialized`, the host failed before Boatstack's hook process started. Keep the hook fail-closed and make **Developer: Reload Window** the primary recovery, then retry the operation. Recommend the verified installer only when Boatstack itself reports a missing, drifted, unsafe, or checksum-invalid helper/runtime. -If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/bin/boatstack-helper diagnose-hook --host --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime. +If any host reports `HOST_PAYLOAD_MALFORMED`, Boatstack received an event it could not safely decode; no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop shell and tool retries, preserve current edits, and run `.product-loop/boatstack diagnose-hook --host --repo .` from an external terminal. For Cursor, start a new task after the probe. The diagnostic proves the installed guard with a canonical event but cannot inspect the live event supplied by the host. Do not recommend reinstall or hydration unless Boatstack separately reports a missing, drifted, unsafe, or checksum-invalid runtime. Same-intent repair resumes at the helper-reported stage and reuses the existing gates. Pass `--mechanism` for every repair classification. Implementation, verification, and review repairs each have an independent three-attempt budget. Requirement amendments and readiness recovery consume none. An identical failure-class, evidence, and mechanism retry is denied. A requirement amendment or ambiguous expected behavior blocks product edits and returns to a concise Plan Gate delta. Never edit `changes.md`, ignored delivery state, or receipts directly; those are emitted by controlled transitions. Conversation history is never workflow authority. @@ -255,7 +255,7 @@ This is a two-slice ZCA projection: the reviewer brief minimizes review effort, Read [failure-moves.md](references/failure-moves.md) before proposing a loop change. -For a retro over past sessions, run the read-only `.product-loop/bin/boatstack-helper retro derive --input [--input ...]`. It detects operator instructions that recur across sessions and classifies each as a missing observation, verb, setpoint, or guard, with a suggested typed promotion. It reads only the transcript files the user names, works fully offline, and writes nothing. A recurring instruction is evidence of a missing typed control — promote it by hand through the normal reviewed delivery flow; never turn it into a saved prompt, and never apply a proposal automatically. +For a retro over past sessions, run the read-only `.product-loop/boatstack retro derive --input [--input ...]`. It detects operator instructions that recur across sessions and classifies each as a missing observation, verb, setpoint, or guard, with a suggested typed promotion. It reads only the transcript files the user names, works fully offline, and writes nothing. A recurring instruction is evidence of a missing typed control — promote it by hand through the normal reviewed delivery flow; never turn it into a saved prompt, and never apply a proposal automatically. 1. Classify the observed failure below the surface symptom. 2. State a mechanism and the exact failure population the move targets. @@ -271,7 +271,7 @@ More steps, more context, stronger wording, more tests, or more retries are not Read [portability.md](references/portability.md), then use: ```bash -.product-loop/bin/boatstack-helper export --repo /path/to/repo --config /path/to/project.json --write +.product-loop/boatstack export --repo /path/to/repo --config /path/to/project.json --write ``` Run with `--check` in CI to detect drift. The exporter writes generated files only and refuses to overwrite user-owned files. Review the generated diff in a branch and ship it through a PR. diff --git a/boatstack/cmd/boatstack-helper/command_trace.go b/boatstack/cmd/boatstack-helper/command_trace.go index e09b38b5..23fe8ec3 100644 --- a/boatstack/cmd/boatstack-helper/command_trace.go +++ b/boatstack/cmd/boatstack-helper/command_trace.go @@ -25,7 +25,7 @@ var commandTracePolicies = map[string]commandTracePolicy{ "prepare-update-pr": {Category: "update-publication"}, "publish-update-pr": {Category: "update-publication"}, "release-classify": {Category: "release"}, "next-patch": {Category: "release"}, "export": {Category: "installation"}, "migrate-config": {Category: "installation"}, - "hydrate-runtime": {Category: "installation"}, "doctor": {Category: "readiness"}, + "hydrate-runtime": {Category: "installation"}, "activate-worktree-runtime": {Category: "installation"}, "doctor": {Category: "readiness"}, "check-source-plan": {Category: "planning"}, "check-plan": {Category: "planning"}, "planning-write": {Category: "planning"}, "record-approval": {Category: "planning"}, "record-autonomy": {Category: "planning"}, "activate-plan": {Category: "delivery"}, diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index 1c7849db..76ecf794 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -32,17 +32,18 @@ import ( // dispatch verbs that are not transitions of the delivery state machine. var nonDeliveryVerbs = map[string]bool{ // Update / release / distribution lifecycle (not the per-feature delivery machine). - "init": true, - "update": true, - "check-update": true, - "prepare-update-pr": true, - "publish-update-pr": true, - "release-classify": true, - "next-patch": true, - "export": true, - "migrate-config": true, - "hydrate-runtime": true, - "version": true, + "init": true, + "update": true, + "check-update": true, + "prepare-update-pr": true, + "publish-update-pr": true, + "release-classify": true, + "next-patch": true, + "export": true, + "migrate-config": true, + "hydrate-runtime": true, + "activate-worktree-runtime": true, + "version": true, // Planning phase, before a plan is activated into a delivery. "check-source-plan": true, "check-plan": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 88ad2da3..b0ca929e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -1298,6 +1298,21 @@ func hydrateRuntimeCommand(arguments []string) int { return 0 } +// activateWorktreeRuntimeCommand installs or repairs only this worktree's +// ignored helper from the already verified exact shared runtime. Unlike the +// safety-hook bootstrap it reads no hook payload and makes no policy decision. +func activateWorktreeRuntimeCommand(arguments []string) int { + flags := flag.NewFlagSet("activate-worktree-runtime", flag.ContinueOnError) + repo := flags.String("repo", ".", "worktree whose local runtime should be activated") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if err := boatstack.HydrateWorktree(*repo); err != nil { + return fail(fmt.Errorf("worktree runtime activation failed: %w", err)) + } + return 0 +} + func checkSafetyCommand(arguments []string) int { flags := flag.NewFlagSet("check-safety", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose operational diff should be checked") @@ -1595,7 +1610,7 @@ func workspaceSyncCommand(arguments []string) int { func run() (result int) { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil { @@ -1706,6 +1721,8 @@ func run() (result int) { return bootstrapSafetyHookCommand(os.Args[2:]) case "hydrate-runtime": return hydrateRuntimeCommand(os.Args[2:]) + case "activate-worktree-runtime": + return activateWorktreeRuntimeCommand(os.Args[2:]) case "check-safety": return checkSafetyCommand(os.Args[2:]) case "workspace-cut": diff --git a/boatstack/denial.go b/boatstack/denial.go index de3f275a..b86184a1 100644 --- a/boatstack/denial.go +++ b/boatstack/denial.go @@ -94,7 +94,7 @@ func (d Denial) escalationLine() string { if !d.Escalated { return "" } - return fmt.Sprintf("This denial repeated %d times. Run: boatstack-helper doctor --repo .", d.RepeatCount) + return fmt.Sprintf("This denial repeated %d times. Run: .product-loop/boatstack doctor --repo .", d.RepeatCount) } // --- ANSI palette (truecolor; matches the approved mockup) ------------------- @@ -162,7 +162,7 @@ func (d Denial) optionLines(limit int) []string { } hidden := len(d.Options) - len(shown) if d.OptionsTruncated || hidden > 0 { - lines = append(lines, " (more legal moves: run boatstack-helper next-status)") + lines = append(lines, " (more legal moves: run .product-loop/boatstack next-status)") } return lines } @@ -467,9 +467,9 @@ func denialFor(host string, finding SafetyFinding) Denial { d.Detail = "Boatstack could not inspect the " + name + " hook event (HOST_PAYLOAD_MALFORMED:" + finding.Reason + "). No unsafe operation was detected; execution is denied because the intended command or tool call is unavailable. Retry once with an explicit non-empty command. If this repeats, stop shell and tool retries and preserve current edits." if strings.EqualFold(host, "cursor") { - d.Detail += " Start a new Cursor task and run `.product-loop/bin/boatstack-helper diagnose-hook --host cursor --repo .` from an external terminal. Do not reinstall Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime." + d.Detail += " Start a new Cursor task and run `.product-loop/boatstack diagnose-hook --host cursor --repo .` from an external terminal. Do not reinstall Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime." } else { - d.Detail += " Run `.product-loop/bin/boatstack-helper diagnose-hook --host " + strings.ToLower(host) + " --repo .` from an external terminal before changing the installation." + d.Detail += " Run `.product-loop/boatstack diagnose-hook --host " + strings.ToLower(host) + " --repo .` from an external terminal before changing the installation." } return d @@ -518,7 +518,7 @@ func denialFor(host string, finding SafetyFinding) Denial { if finding.BlockingFeature != "" { slug = finding.BlockingFeature } - d.Detail += fmt.Sprintf(" Planning Markdown is authored through the owned channel: one complete literal `.product-loop/bin/boatstack-helper planning-write --repo . --feature %s --artifact ` envelope from `.product-loop/workflow.md` — never a raw host write into `.product-loop/features/` or a manual shell paste.", slug) + d.Detail += fmt.Sprintf(" Planning Markdown is authored through the owned channel: one complete literal `.product-loop/boatstack planning-write --repo . --feature %s --artifact ` envelope from `.product-loop/workflow.md` — never a raw host write into `.product-loop/features/` or a manual shell paste.", slug) } d.Reassurance = reassureUntouched return d diff --git a/boatstack/denial_escalation_conformance_test.go b/boatstack/denial_escalation_conformance_test.go index 72af46f9..6a528d54 100644 --- a/boatstack/denial_escalation_conformance_test.go +++ b/boatstack/denial_escalation_conformance_test.go @@ -46,7 +46,7 @@ func TestThirdIdenticalDenialEscalates(t *testing.T) { if !strings.Contains(string(output), fmt.Sprintf("repeated %d times", denialEscalationThreshold)) { t.Fatalf("escalation must carry the repeat count:\n%s", output) } - if !strings.Contains(string(output), "boatstack-helper doctor") { + if !strings.Contains(string(output), ".product-loop/boatstack doctor") { t.Fatalf("escalation must prescribe the fresh diagnostic:\n%s", output) } } diff --git a/boatstack/export.go b/boatstack/export.go index 340b25fb..4156ba62 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "sort" "strings" ) @@ -22,7 +23,7 @@ var allowedAdapters = map[string]bool{ const executionModeDX = "\n\n**Execution Mode Notice:** Boatstack requires active tool use (such as shell execution) to run its commands and inspect the workspace. If you are currently operating in a Plan Mode, Read-Only Mode, or a state with restricted tool access, you MUST immediately inform the user that Boatstack requires full execution capabilities and ask them to approve exiting that mode or grant execution permissions before you can proceed. Do not attempt to mock or bypass the operation." -const repositoryAdministrationBoundary = `Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local .product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main. The helper checkpoints recoverable state before alignment. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command.` +const repositoryAdministrationBoundary = `Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local .product-loop/boatstack workspace-sync --repo . --branch main --source origin/main. The helper checkpoints recoverable state before alignment. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command.` var ( readCanonical = ReadCanonical @@ -255,7 +256,7 @@ func normalizedAdapters(adapters []string) []string { func commandBody(operation, extra string) string { preflight := "" if operation == "auto-plan" { - preflight = `Before reading repository context or drafting artifacts, identify the path of the plan produced in the active host/system conversation — the user supplies it as the invocation argument, ` + "`/auto-plan `" + ` — and run the project-local helper with ` + "`check-source-plan --repo . --plan `" + `. Use its ` + "`SOURCE_PLAN`" + ` result. Boatstack does not scan directories for plans: ` + "`--plan`" + ` is required, so no unshipped saved plan becomes ambient context. If no plan path is available, stop and ask the user for the plan to build; do not create or guess a substitute inside auto-plan. The plan file must remain present and unchanged through build, so point ` + "`--plan`" + ` at a durable in-repo path, not an ephemeral scratch file; a path outside the repository is rejected because it cannot stay committed and hash-current through build. Author each known planning document through ` + "`boatstack-helper planning-write`" + ` using one complete literal envelope from ` + "`.product-loop/workflow.md`" + ` — the single-quoted POSIX heredoc or the UTF-8-scoped single-quoted PowerShell here-string. Never run a bare helper, split the envelope across calls, paste Markdown at a shell prompt, or bypass the host boundary with arbitrary redirection.` + preflight = `Before reading repository context or drafting artifacts, identify the path of the plan produced in the active host/system conversation — the user supplies it as the invocation argument, ` + "`/auto-plan `" + ` — and run the tracked .product-loop/boatstack launcher with ` + "`check-source-plan --repo . --plan `" + `. Use its ` + "`SOURCE_PLAN`" + ` result. Boatstack does not scan directories for plans: ` + "`--plan`" + ` is required, so no unshipped saved plan becomes ambient context. If no plan path is available, stop and ask the user for the plan to build; do not create or guess a substitute inside auto-plan. The plan file must remain present and unchanged through build, so point ` + "`--plan`" + ` at a durable in-repo path, not an ephemeral scratch file; a path outside the repository is rejected because it cannot stay committed and hash-current through build. Author each known planning document through ` + "`.product-loop/boatstack planning-write`" + ` using one complete literal envelope from ` + "`.product-loop/workflow.md`" + ` — the single-quoted POSIX heredoc or the UTF-8-scoped single-quoted PowerShell here-string. Never run a bare helper, split the envelope across calls, paste Markdown at a shell prompt, or bypass the host boundary with arbitrary redirection.` } return fmt.Sprintf(`# %s @@ -315,6 +316,8 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } files[".product-loop/project.json"] = projectJSON files[".product-loop/.gitignore"] = []byte("bin/\nworktrees/\n") + files[".product-loop/boatstack"] = launcherShellScript() + files[".product-loop/boatstack.ps1"] = launcherPowerShellScript() files[".product-loop/hooks/guard.sh"] = guardShellScript() files[".product-loop/hooks/guard.ps1"] = guardPowerShellScript() for _, host := range []string{"cursor", "claude", "codex", "gemini"} { @@ -364,10 +367,10 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations := map[string]string{ - "boatstack-next": "Run the project-local helper next-status --repo . --format response and present its output as the response. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. The helper renders the canonical response contract deterministically — the outcome line and the single ### Next step block with the exact runnable command when one is prescribable; never override, re-derive, or add a second next action. The helper also types the step's actor: when the rendered step is marked \"This step is mine to do\", the step is the agent's, and the one next action is the delegation reply g. Only after the exact reply g, execute the prescribed step, re-render next-status --repo . --format response, and continue through further agent-owned steps until the next step belongs to the operator (an approval, a publish or cleanup reply, a feature choice, a product fact) or no action is required. Stop immediately when a step does not change the prescribed next step — repetition without progress is a stall; report the block and hand the turn to the operator. Never end a response by describing work the agent still has to do. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result.", - "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", - "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 project-local helper 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 project-local helper 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.", + "boatstack-next": "Run the tracked .product-loop/boatstack launcher next-status --repo . --format response and present its output as the response. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. The helper renders the canonical response contract deterministically — the outcome line and the single ### Next step block with the exact runnable command when one is prescribable; never override, re-derive, or add a second next action. The helper also types the step's actor: when the rendered step is marked \"This step is mine to do\", the step is the agent's, and the one next action is the delegation reply g. Only after the exact reply g, execute the prescribed step, re-render next-status --repo . --format response, and continue through further agent-owned steps until the next step belongs to the operator (an approval, a publish or cleanup reply, a feature choice, a product fact) or no action is required. Stop immediately when a step does not change the prescribed next step — repetition without progress is a stall; report the block and hand the turn to the operator. Never end a response by describing work the agent still has to do. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result.", + "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If NOT_STARTED, respond Start a Boatstack feature and ask the user for the plan produced in the host conversation, then execute auto-plan with its path via --plan (Boatstack does not scan directories for plans) without Git preflight, pausing at its normal decision or approval boundary; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the tracked .product-loop/boatstack launcher run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", + "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.", "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.", @@ -380,9 +383,9 @@ 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 project-local helper 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-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 project-local helper 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 project-local helper 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.", + "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-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"] += " 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." @@ -725,23 +728,35 @@ func writeExport(repo string, files map[string][]byte, repairPaths map[string]bo } } for _, relative := range sortedKeys(files) { - if err := writeFile(filepath.Join(repo, filepath.FromSlash(relative)), files[relative], 0o644); err != nil { + if err := writeFile(filepath.Join(repo, filepath.FromSlash(relative)), files[relative], generatedFileMode(relative)); err != nil { return err } } return nil } +func generatedFileMode(relative string) os.FileMode { + if relative == ".product-loop/boatstack" { + return 0o755 + } + return 0o644 +} + func CheckExport(repo string, files map[string][]byte) error { problems := []string{} for _, relative := range sortedKeys(files) { - current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) + path := filepath.Join(repo, filepath.FromSlash(relative)) + current, err := os.ReadFile(path) if os.IsNotExist(err) { problems = append(problems, "missing "+relative) } else if err != nil { problems = append(problems, fmt.Sprintf("unreadable %s: %v", relative, err)) } else if string(current) != string(files[relative]) { problems = append(problems, "drift "+relative) + } else if relative == ".product-loop/boatstack" && runtime.GOOS != "windows" { + if info, statErr := os.Stat(path); statErr != nil || info.Mode().Perm()&0o111 == 0 { + problems = append(problems, "non-executable "+relative) + } } } if len(problems) > 0 { diff --git a/boatstack/export_test.go b/boatstack/export_test.go index cdf1a00b..735bf18a 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -4,6 +4,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -113,6 +114,8 @@ func TestExportAndDriftCheck(t *testing.T) { ".agents/skills/boatstack-run/SKILL.md", ".agents/skills/auto-plan/SKILL.md", ".product-loop/.gitignore", + ".product-loop/boatstack", + ".product-loop/boatstack.ps1", ".product-loop/templates/plan.md", ".product-loop/templates/approval.md", ".product-loop/hooks/guard.sh", @@ -123,6 +126,30 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatalf("expected generated file %s", path) } } + launcherPath := filepath.Join(repo, ".product-loop", "boatstack") + launcherInfo, err := os.Stat(launcherPath) + if err != nil || (runtime.GOOS != "windows" && launcherInfo.Mode().Perm()&0o111 == 0) { + t.Fatalf("POSIX launcher does not have its generated mode: %v", err) + } + if runtime.GOOS != "windows" { + if err := os.Chmod(launcherPath, 0o644); err != nil { + t.Fatal(err) + } + if err := CheckExport(repo, bundle.Files); err == nil || !strings.Contains(err.Error(), "non-executable .product-loop/boatstack") { + t.Fatalf("export check accepted a non-executable launcher: %v", err) + } + if err := os.Chmod(launcherPath, 0o755); err != nil { + t.Fatal(err) + } + } + for path, content := range bundle.Files { + if path == ".product-loop/boatstack" || path == ".product-loop/boatstack.ps1" { + continue + } + if strings.Contains(string(content), ".product-loop/bin/boatstack-helper") { + t.Fatalf("generated surface %s invokes the internal runtime directly", path) + } + } claudeSkillPaths := map[string]bool{} for path := range bundle.Files { if strings.HasPrefix(path, ".claude/skills/") && strings.HasSuffix(path, "/SKILL.md") { @@ -433,8 +460,9 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatal("ship adapter permits unrelated scope expansion") } lock := string(bundle.Files[".product-loop/generated.lock.json"]) - if !strings.Contains(lock, `"source_commit"`) || !strings.Contains(lock, `"integrations"`) { - t.Fatal("generated lock must record runtime provenance and integrations") + if !strings.Contains(lock, `"source_commit"`) || !strings.Contains(lock, `"integrations"`) || + !strings.Contains(lock, `".product-loop/boatstack"`) || !strings.Contains(lock, `".product-loop/boatstack.ps1"`) { + t.Fatal("generated lock must record runtime provenance, integrations, and tracked launchers") } workflow := string(bundle.Files[".product-loop/workflow.md"]) for _, expected := range []string{ @@ -559,7 +587,7 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { "Branch synchronization, status, switching, worktree maintenance", "must never route to auto-plan or repair", "ensure main is same is origin/main remove any current changes", - ".product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main", + ".product-loop/boatstack workspace-sync --repo . --branch main --source origin/main", "do not inspect plans, scan the repository, search for the helper", } { if !strings.Contains(surface, expected) { diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 18f5c538..fc7f0a91 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -319,7 +319,7 @@ func (p PrescribedCommand) CommandLine() string { program := p.Program if program == "" { if literalPlanningInput { - program = projectLocalHelperCommand() + program = projectLocalLauncherCommand() } else { program = "boatstack-helper" } @@ -464,7 +464,7 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri Verb: "check-source-plan", Args: repoArgs, RequiresHumanInput: []string{"--plan"}, 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`.", projectLocalHelperCommand(), generatedWorkflowReference())) + }, 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": return finish(&PrescribedCommand{ Verb: "check-plan", @@ -508,7 +508,7 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri } else { slug = "" } - return finish(cmd, fmt.Sprintf("After repair, re-author the planning Markdown through the owned channel: one complete literal `%s planning-write --repo . --feature %s --artifact ` envelope from `%s`.", projectLocalHelperCommand(), slug, generatedWorkflowReference())) + return finish(cmd, fmt.Sprintf("After repair, re-author the planning Markdown through the owned channel: one complete literal `%s planning-write --repo . --feature %s --artifact ` envelope from `%s`.", projectLocalLauncherCommand(), slug, generatedWorkflowReference())) } return nil, "" default: @@ -661,6 +661,24 @@ func NextControl(repo, feature string) (FlowNext, error) { return nextControlFromStatus(repo, status) } +func bindFlowCommandPrograms(repo string, next *FlowNext) { + bind := func(command *PrescribedCommand) { + if command == nil || command.Program != "" || command.Verb == "init" { + return + } + workspace := WorkspaceFor(repo) + if workspace.Mode == SupervisionDetached { + command.Program = workspace.HelperPath() + return + } + command.Program = projectLocalLauncherCommand() + } + bind(next.Prescribed) + for index := range next.Alternatives { + bind(&next.Alternatives[index]) + } +} + // nextControlFromStatus is NextControl on an already-resolved status, so a // caller that renders both the friendly phrase and the prescription (the // response contract) observes state exactly once — one resolution, no drift. @@ -686,6 +704,7 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { out.FollowUp = followUp } out.Alternatives = alternativesFor(repo, status, out) + bindFlowCommandPrograms(repo, &out) out.Actor = classifyNextActor(status, out) return out, nil } @@ -729,6 +748,7 @@ func nextControlFromStatus(repo string, status NextStatus) (FlowNext, error) { } } out.Alternatives = alternativesFor(repo, status, out) + bindFlowCommandPrograms(repo, &out) out.Actor = classifyNextActor(status, out) return out, nil } diff --git a/boatstack/flow_control_test.go b/boatstack/flow_control_test.go index ac2b9baa..fa3cac14 100644 --- a/boatstack/flow_control_test.go +++ b/boatstack/flow_control_test.go @@ -99,7 +99,7 @@ func TestNextControlPrescribesWithoutResolvingBeforeActivation(t *testing.T) { t.Fatalf("DRAFT_PLAN must prescribe check-plan: %+v", next.Prescribed) } out := FormatFlowNext(next) - if !strings.Contains(out, "pre-activation (delivery oracle not engaged)") || !strings.Contains(out, "Run: boatstack-helper check-plan") { + if !strings.Contains(out, "pre-activation (delivery oracle not engaged)") || !strings.Contains(out, "Run: .product-loop/boatstack check-plan") { t.Fatalf("pre-activation rendering must label the state and carry the Run line: %q", out) } } diff --git a/boatstack/init.go b/boatstack/init.go index a852de4c..da5f7c76 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -721,8 +721,8 @@ func RunInit(options InitOptions) (returnErr error) { fmt.Fprintln(options.Output, " git commit -m \"chore: install Boatstack\"") fmt.Fprintln(options.Output, " git push -u origin chore/install-boatstack") } - fmt.Fprintln(options.Output, "The verified runtime is shared by worktrees in this Git clone; each worktree hydrates its ignored .product-loop/bin/ files automatically on first use.") - fmt.Fprintln(options.Output, "A separate fresh clone still requires one verified installer run.") + fmt.Fprintln(options.Output, "The verified runtime is shared by worktrees in this Git clone; each worktree inherits tracked launchers that activate its ignored .product-loop/bin/ files on first use.") + fmt.Fprintln(options.Output, "A fresh clone inherits the tracked launcher; its first invocation hydrates the exact pinned runtime through the verified installer.") if options.Update { fmt.Fprintln(options.Output, "\nAfter the update PR is merged, reload Cursor, Codex, or Claude.") } else { diff --git a/boatstack/launcher.go b/boatstack/launcher.go new file mode 100644 index 00000000..94eb2433 --- /dev/null +++ b/boatstack/launcher.go @@ -0,0 +1,174 @@ +package boatstack + +import "fmt" + +// launcherShellScript is the tracked command boundary for POSIX worktrees. It +// resolves only the release identity baked into this generated file, verifies +// that shared runtime, asks it to activate the ignored worktree-local runtime, +// and then dispatches through the local runtime. It never searches sibling +// worktrees or selects a newer release. +func launcherShellScript() []byte { + recovery := runtimeHydrateCommandBash(Version) + return []byte(fmt.Sprintf(`#!/usr/bin/env bash +# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. +set -u + +bs_fail() { + printf 'Boatstack runtime activation failed: %%s\n' "$1" >&2 + printf 'Recovery: %%s\n' '%s' >&2 + exit 2 +} + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P || true)" +[[ -n "$SCRIPT_DIR" ]] || bs_fail "cannot resolve the launcher directory" +ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)" +[[ -n "$ROOT" ]] || bs_fail "cannot resolve the repository" +COMMON="$(git -C "$SCRIPT_DIR" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)" +[[ -n "$COMMON" ]] || bs_fail "cannot resolve the Git common directory" + +case "$(uname -s)" in + Darwin) OS_NAME="darwin"; EXTENSION="" ;; + Linux) OS_NAME="linux"; EXTENSION="" ;; + MINGW*|MSYS*|CYGWIN*) OS_NAME="windows"; EXTENSION=".exe" ;; + *) bs_fail "unsupported operating system" ;; +esac +case "$(uname -m)" in + x86_64|amd64) ARCH="amd64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) bs_fail "unsupported architecture" ;; +esac + +VERSION='%s' +SOURCE_COMMIT='%s' +PLATFORM="${OS_NAME}-${ARCH}" +SLOT="$COMMON/boatstack/runtimes/$VERSION/$SOURCE_COMMIT/$PLATFORM" +HELPER="$SLOT/boatstack-helper$EXTENSION" +MANIFEST="$SLOT/runtime.lock.json" + +bs_slot_ready() { + [[ -x "$HELPER" && ! -L "$HELPER" && -f "$MANIFEST" && ! -L "$MANIFEST" ]] +} + +if ! bs_slot_ready && [[ "${BOATSTACK_AUTO_HYDRATE:-1}" != "0" ]]; then + mkdir -p "$COMMON/boatstack" 2>/dev/null || true + HYDRATE_LOCK="$COMMON/boatstack/hydrate-$VERSION.lock" + if mkdir "$HYDRATE_LOCK" 2>/dev/null; then + if ! bs_slot_ready; then + ( + cd "$ROOT" || exit 1 + export BOATSTACK_MODE=hydrate BOATSTACK_VERSION="$VERSION" BOATSTACK_REPO="$ROOT" + HYDRATE_COMMAND="${BOATSTACK_HYDRATE_COMMAND:-}" + [[ -n "$HYDRATE_COMMAND" ]] || HYDRATE_COMMAND='%s' + if command -v timeout >/dev/null 2>&1; then + timeout 8 /bin/bash -c "$HYDRATE_COMMAND" + else + /bin/bash -c "$HYDRATE_COMMAND" + fi + ) >&2 || true + fi + rmdir "$HYDRATE_LOCK" 2>/dev/null || true + else + for _ in $(seq 1 12); do + [[ -d "$HYDRATE_LOCK" ]] || break + sleep 1 + done + fi +fi + +bs_slot_ready || bs_fail "the exact pinned shared runtime is missing or unsafe" +manifest_value() { + sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$MANIFEST" | head -n 1 +} +[[ "$(manifest_value boatstack_version)" == "$VERSION" ]] || bs_fail "shared runtime version does not match the launcher pin" +[[ "$(manifest_value source_commit)" == "$SOURCE_COMMIT" ]] || bs_fail "shared runtime source does not match the launcher pin" +[[ "$(manifest_value platform)" == "$PLATFORM" ]] || bs_fail "shared runtime platform does not match this host" +EXPECTED="$(manifest_value binary_sha256)" +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL="$(sha256sum "$HELPER" | awk '{print $1}')" +elif command -v shasum >/dev/null 2>&1; then + ACTUAL="$(shasum -a 256 "$HELPER" | awk '{print $1}')" +else + bs_fail "no SHA-256 verifier is available" +fi +[[ "$EXPECTED" =~ ^[0-9a-f]{64}$ && "$ACTUAL" == "$EXPECTED" ]] || bs_fail "shared runtime checksum is invalid" + +"$HELPER" activate-worktree-runtime --repo "$ROOT" || bs_fail "the pinned runtime could not activate this worktree" +LOCAL="$ROOT/.product-loop/bin/boatstack-helper$EXTENSION" +[[ -x "$LOCAL" && ! -L "$LOCAL" ]] || bs_fail "worktree runtime activation did not produce a safe helper" +exec "$LOCAL" "$@" +`, recovery, Version, SourceCommit, recovery)) +} + +// launcherPowerShellScript is the PowerShell form of launcherShellScript and +// enforces the same exact identity, activation, and recovery contract. +func launcherPowerShellScript() []byte { + recovery := runtimeHydrateCommandPowerShell(Version) + return []byte(fmt.Sprintf(`# Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json. +$ErrorActionPreference = "Stop" +function Fail-Activation([string]$Message) { + [Console]::Error.WriteLine("Boatstack runtime activation failed: $Message") + [Console]::Error.WriteLine('Recovery: %s') + exit 2 +} + +if (-not $PSScriptRoot) { Fail-Activation "cannot resolve the launcher directory" } +try { $root = (& git -C $PSScriptRoot rev-parse --show-toplevel 2>$null) } catch { Fail-Activation "cannot resolve the repository" } +if (-not $root) { Fail-Activation "cannot resolve the repository" } +try { $common = (& git -C $PSScriptRoot rev-parse --path-format=absolute --git-common-dir 2>$null) } catch { Fail-Activation "cannot resolve the Git common directory" } +if (-not $common) { Fail-Activation "cannot resolve the Git common directory" } +$arch = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()) { + "x64" { "amd64" } + "arm64" { "arm64" } + default { Fail-Activation "unsupported architecture" } +} +$version = '%s' +$sourceCommit = '%s' +$platform = "windows-$arch" +$slot = Join-Path $common "boatstack/runtimes/$version/$sourceCommit/$platform" +$helper = Join-Path $slot "boatstack-helper.exe" +$manifestPath = Join-Path $slot "runtime.lock.json" +function Slot-Ready { + (Test-Path -LiteralPath $helper -PathType Leaf) -and + (Test-Path -LiteralPath $manifestPath -PathType Leaf) -and + (-not ((Get-Item -LiteralPath $helper -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) -and + (-not ((Get-Item -LiteralPath $manifestPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) +} + +if ((-not (Slot-Ready)) -and ($env:BOATSTACK_AUTO_HYDRATE -ne "0")) { + $boatstackRoot = Join-Path $common "boatstack" + New-Item -ItemType Directory -Force -Path $boatstackRoot | Out-Null + $lockPath = Join-Path $boatstackRoot "hydrate-$version.lock" + $ownsLock = $false + try { New-Item -ItemType Directory -ErrorAction Stop -Path $lockPath | Out-Null; $ownsLock = $true } catch {} + if ($ownsLock) { + try { + if (-not (Slot-Ready)) { + $env:BOATSTACK_MODE = "hydrate" + $env:BOATSTACK_VERSION = $version + $env:BOATSTACK_REPO = $root + $hydrateCommand = $env:BOATSTACK_HYDRATE_COMMAND + if (-not $hydrateCommand) { $hydrateCommand = '%s' } + & powershell -NoProfile -Command $hydrateCommand 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } + } + } catch {} finally { Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue } + } else { + for ($i = 0; $i -lt 12 -and (Test-Path -LiteralPath $lockPath); $i++) { Start-Sleep -Seconds 1 } + } +} + +if (-not (Slot-Ready)) { Fail-Activation "the exact pinned shared runtime is missing or unsafe" } +try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch { Fail-Activation "shared runtime manifest is malformed" } +if ($manifest.boatstack_version -ne $version) { Fail-Activation "shared runtime version does not match the launcher pin" } +if ($manifest.source_commit -ne $sourceCommit) { Fail-Activation "shared runtime source does not match the launcher pin" } +if ($manifest.platform -ne $platform) { Fail-Activation "shared runtime platform does not match this host" } +$actual = (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant() +if (($manifest.binary_sha256 -notmatch '^[0-9a-f]{64}$') -or ($actual -ne $manifest.binary_sha256)) { Fail-Activation "shared runtime checksum is invalid" } + +& $helper activate-worktree-runtime --repo $root +if ($LASTEXITCODE -ne 0) { Fail-Activation "the pinned runtime could not activate this worktree" } +$local = Join-Path $root ".product-loop/bin/boatstack-helper.exe" +if (-not (Test-Path -LiteralPath $local -PathType Leaf)) { Fail-Activation "worktree runtime activation did not produce a safe helper" } +& $local @args +exit $LASTEXITCODE +`, recovery, Version, SourceCommit, recovery)) +} diff --git a/boatstack/launcher_test.go b/boatstack/launcher_test.go new file mode 100644 index 00000000..376c3a5e --- /dev/null +++ b/boatstack/launcher_test.go @@ -0,0 +1,285 @@ +package boatstack + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +func buildLauncherTestHelper(t *testing.T) string { + t.Helper() + binary := filepath.Join(t.TempDir(), helperName()) + command := exec.Command("go", "build", "-o", binary, "./cmd/boatstack-helper") + command.Dir = "." + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build helper: %v\n%s", err, output) + } + return binary +} + +func launcherTestRepository(t *testing.T) (string, string) { + t.Helper() + repo := planningRepo(t) + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module launcher-fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, BinaryPath: buildLauncherTestHelper(t), IntegrationChoice: "core", Yes: true}); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "initialize Boatstack") + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, repo, "worktree", "add", "--detach", linked, "HEAD") + return repo, linked +} + +func runLauncher(t *testing.T, repo string, arguments ...string) (string, error) { + t.Helper() + command := launcherCommand(repo, arguments...) + command.Dir = repo + value, err := command.CombinedOutput() + return string(value), err +} + +func launcherCommand(repo string, arguments ...string) *exec.Cmd { + if runtime.GOOS == "windows" { + values := []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", filepath.Join(repo, ".product-loop", "boatstack.ps1")} + return exec.Command("powershell", append(values, arguments...)...) + } + return exec.Command(filepath.Join(repo, ".product-loop", "boatstack"), arguments...) +} + +// control-law: tracked-launcher-selects-only-the-pinned-runtime +func TestTrackedLauncherActivatesFreshLinkedWorktreeWithoutHookTrust(t *testing.T) { + primary, linked := launcherTestRepository(t) + local := filepath.Join(linked, ".product-loop", "bin") + if _, err := os.Stat(local); !os.IsNotExist(err) { + t.Fatalf("fresh linked worktree unexpectedly inherited local runtime: %v", err) + } + + // A stale sibling-local helper is a counterexample only if the launcher scans + // sibling worktrees. The exact shared-runtime path must make it irrelevant. + primaryHelper := filepath.Join(primary, ".product-loop", "bin", helperName()) + if err := os.WriteFile(primaryHelper, []byte("#!/bin/sh\necho STALE-SIBLING\n"), 0o755); err != nil { + t.Fatal(err) + } + output, err := runLauncher(t, linked, "version") + if err != nil { + t.Fatalf("fresh-worktree version failed: %v\n%s", err, output) + } + if strings.Contains(output, "STALE-SIBLING") || !strings.Contains(output, Version) { + t.Fatalf("launcher selected the wrong runtime: %q", output) + } + for _, arguments := range [][]string{ + {"doctor", "--repo", "."}, + {"run-preflight", "--repo", ".", "--health-only", "--json"}, + } { + if output, err := runLauncher(t, linked, arguments...); err != nil { + t.Fatalf("launcher %v failed: %v\n%s", arguments, err, output) + } + } + if err := verifyLocalRuntime(linked); err != nil { + t.Fatalf("launcher did not leave an exact local runtime: %v", err) + } + + // Remove the exact shared slot and prove concurrent first use serializes one + // pinned hydration, then independently activates the worktree-local runtime. + binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + backup := t.TempDir() + backupBinary := filepath.Join(backup, helperName()) + backupManifest := filepath.Join(backup, "runtime.lock.json") + for source, target := range map[string]string{binary: backupBinary, manifest: backupManifest} { + value, readErr := os.ReadFile(source) + if readErr != nil { + t.Fatal(readErr) + } + mode := os.FileMode(0o644) + if source == binary { + mode = 0o755 + } + if writeErr := os.WriteFile(target, value, mode); writeErr != nil { + t.Fatal(writeErr) + } + } + if err := os.RemoveAll(filepath.Dir(binary)); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(local); err != nil { + t.Fatal(err) + } + hydrate := "mkdir -p " + quotedLiteral(t, filepath.Dir(binary)) + + " && cp " + quotedLiteral(t, backupBinary) + " " + quotedLiteral(t, binary) + + " && cp " + quotedLiteral(t, backupManifest) + " " + quotedLiteral(t, manifest) + if runtime.GOOS == "windows" { + hydrate = "$null = New-Item -ItemType Directory -Force -Path " + quotedLiteral(t, filepath.Dir(binary)) + + "; Copy-Item -Force " + quotedLiteral(t, backupBinary) + " " + quotedLiteral(t, binary) + + "; Copy-Item -Force " + quotedLiteral(t, backupManifest) + " " + quotedLiteral(t, manifest) + } + const workers = 4 + errors := make(chan string, workers) + var group sync.WaitGroup + for index := 0; index < workers; index++ { + group.Add(1) + go func() { + defer group.Done() + command := launcherCommand(linked, "version") + command.Dir = linked + command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND="+hydrate) + value, runErr := command.CombinedOutput() + if runErr != nil || !strings.Contains(string(value), Version) { + errors <- runErrString(runErr, value) + } + }() + } + group.Wait() + close(errors) + for failure := range errors { + t.Fatalf("concurrent pinned hydration failed: %s", failure) + } +} + +func runErrString(err error, output []byte) string { + if err == nil { + return string(output) + } + return err.Error() + ": " + string(output) +} + +// control-law: tracked-launcher-selects-only-the-pinned-runtime +func TestTrackedLauncherRejectsTamperedSharedRuntimeBeforeDispatch(t *testing.T) { + _, linked := launcherTestRepository(t) + binary, manifest, err := sharedRuntimePaths(linked, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + originalBinary, err := os.ReadFile(binary) + if err != nil { + t.Fatal(err) + } + originalManifest, err := os.ReadFile(manifest) + if err != nil { + t.Fatal(err) + } + malformedError := "shared runtime version does not match the launcher pin" + if runtime.GOOS == "windows" { + malformedError = "shared runtime manifest is malformed" + } + tests := []struct { + name string + mutate func() + error string + }{ + { + name: "binary checksum", + mutate: func() { + if err := os.WriteFile(binary, []byte("tampered"), 0o755); err != nil { + t.Fatal(err) + } + }, + error: "shared runtime checksum is invalid", + }, + { + name: "malformed manifest", + mutate: func() { + if err := os.WriteFile(manifest, []byte("{not-json"), 0o644); err != nil { + t.Fatal(err) + } + }, + error: malformedError, + }, + { + name: "version pin mismatch", + mutate: func() { + value := strings.Replace(string(originalManifest), `"boatstack_version": "`+Version+`"`, `"boatstack_version": "foreign"`, 1) + if value == string(originalManifest) { + value = strings.Replace(string(originalManifest), `"boatstack_version":"`+Version+`"`, `"boatstack_version":"foreign"`, 1) + } + if value == string(originalManifest) { + value = strings.Replace(string(originalManifest), Version, "foreign", 1) + } + if err := os.WriteFile(manifest, []byte(value), 0o644); err != nil { + t.Fatal(err) + } + }, + error: "shared runtime version does not match the launcher pin", + }, + { + name: "source pin mismatch", + mutate: func() { + value := strings.Replace(string(originalManifest), SourceCommit, "foreign-source", 1) + if err := os.WriteFile(manifest, []byte(value), 0o644); err != nil { + t.Fatal(err) + } + }, + error: "shared runtime source does not match the launcher pin", + }, + } + if runtime.GOOS != "windows" { + tests = append(tests, struct { + name string + mutate func() + error string + }{ + name: "symlinked binary", + mutate: func() { + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(t.TempDir(), "foreign"), binary); err != nil { + t.Fatal(err) + } + }, + error: "the exact pinned shared runtime is missing or unsafe", + }) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _ = os.Remove(binary) + if err := os.WriteFile(binary, originalBinary, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifest, originalManifest, 0o644); err != nil { + t.Fatal(err) + } + test.mutate() + command := launcherCommand(linked, "version") + command.Dir = linked + command.Env = append(os.Environ(), "BOATSTACK_AUTO_HYDRATE=0") + value, runErr := command.CombinedOutput() + output := string(value) + if runErr == nil || !strings.Contains(output, "Boatstack runtime activation failed: "+test.error) { + t.Fatalf("invalid runtime did not fail closed: %v\n%s", runErr, output) + } + if !strings.Contains(output, "Recovery:") || !strings.Contains(output, Version) { + t.Fatalf("activation failure omitted exact pinned recovery: %s", output) + } + if _, statErr := os.Stat(filepath.Join(linked, ".product-loop", "bin", helperName())); !os.IsNotExist(statErr) { + t.Fatalf("failed activation partially installed a local runtime: %v", statErr) + } + }) + } +} + +// control-law: tracked-launcher-selects-only-the-pinned-runtime +func TestLaunchersShareExactIdentityActivationAndRecoveryContract(t *testing.T) { + for name, value := range map[string]string{ + "POSIX": string(launcherShellScript()), + "PowerShell": string(launcherPowerShellScript()), + } { + for _, required := range []string{Version, SourceCommit, "activate-worktree-runtime", "binary_sha256", "Boatstack runtime activation failed", "BOATSTACK_MODE"} { + if !strings.Contains(value, required) { + t.Fatalf("%s launcher omits %q", name, required) + } + } + if strings.Contains(strings.ToLower(value), "latest") || strings.Contains(value, ".product-loop/worktrees") { + t.Fatalf("%s launcher contains a non-deterministic selection path", name) + } + } +} diff --git a/boatstack/next_response_conformance_test.go b/boatstack/next_response_conformance_test.go index dbd58eee..d9c1cfc8 100644 --- a/boatstack/next_response_conformance_test.go +++ b/boatstack/next_response_conformance_test.go @@ -23,7 +23,7 @@ import ( // skill prose to POINTING at the renderer rather than restating a state table. // machineTokens must never appear as status prose in a rendered response. -// Lines that carry the runnable command (`boatstack-helper …`) are the one +// Lines that carry the runnable command (`.product-loop/boatstack …`) are the one // legitimate exception — the verb IS the next step there. var machineTokens = regexp.MustCompile(`DRAFT_PLAN|APPROVED|POLICY_READY|NOT_INITIALIZED|INVALID_STATE|AMBIGUOUS|NOT_STARTED|TEST_PASSED|REVIEW_PASSED|PR_PREVIEW|FEATURE_COMPLETE|repair-state|discard-delivery|plan-gate|ship-gate|review-gate|auto-plan`) @@ -47,7 +47,7 @@ func TestResponseContractPerStage(t *testing.T) { repo := nextTestRepo(t) status, output := renderedResponse(t, repo) assertResponseShape(t, status, output) - if !strings.Contains(output, "Run: boatstack-helper check-source-plan") { + if !strings.Contains(output, "Run: .product-loop/boatstack check-source-plan") { t.Fatalf("NOT_STARTED must carry the prescribed command: %q", output) } }) @@ -57,7 +57,7 @@ func TestResponseContractPerStage(t *testing.T) { writeSavedFeaturePlan(t, repo, "demo") status, output := renderedResponse(t, repo) assertResponseShape(t, status, output) - if !strings.Contains(output, "Run: boatstack-helper check-plan") { + if !strings.Contains(output, "Run: .product-loop/boatstack check-plan") { t.Fatalf("DRAFT_PLAN must carry the prescribed command: %q", output) } if !strings.Contains(output, "Then: ") { @@ -73,7 +73,7 @@ func TestResponseContractPerStage(t *testing.T) { } status, output := renderedResponse(t, repo) assertResponseShape(t, status, output) - if !strings.Contains(output, "Run: boatstack-helper activate-plan") { + if !strings.Contains(output, "Run: .product-loop/boatstack activate-plan") { t.Fatalf("APPROVED must carry the prescribed command: %q", output) } }) @@ -89,7 +89,7 @@ func TestResponseContractPerStage(t *testing.T) { t.Fatal(err) } assertResponseShape(t, status, output) - if !strings.Contains(output, "Run: boatstack-helper record-delivery-gate") { + if !strings.Contains(output, "Run: .product-loop/boatstack record-delivery-gate") { t.Fatalf("BUILD must carry the oracle-prescribed command: %q", output) } }) @@ -143,7 +143,7 @@ func TestResponseHidesMachineTokens(t *testing.T) { t.Run(name, func(t *testing.T) { _, output := renderedResponse(t, fixture(t)) for _, line := range strings.Split(output, "\n") { - if strings.Contains(line, "boatstack-helper") { + if strings.Contains(line, ".product-loop/boatstack") { continue // the runnable command line is the legitimate exception } if match := machineTokens.FindString(line); match != "" { diff --git a/boatstack/paths.go b/boatstack/paths.go index 6c913ec5..fc08c2c2 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -230,11 +230,22 @@ func (w WorkspaceContext) HelperPath() string { return filepath.Join(w.GeneratedRoot(), "bin", helperName()) } -// projectLocalHelperCommand is the portable repository-relative spelling used +// LauncherPath is the tracked exact-runtime command boundary in embedded mode. +// Detached supervision has no repository-owned launcher and continues to use +// HelperPath from its external controller root. +func (w WorkspaceContext) LauncherPath(powerShell bool) string { + name := "boatstack" + if powerShell { + name += ".ps1" + } + return filepath.Join(w.GeneratedRoot(), name) +} + +// projectLocalLauncherCommand is the portable repository-relative spelling used // in rendered embedded-mode prescriptions. Path ownership remains centralized // here even though the command is displayed before a concrete workspace exists. -func projectLocalHelperCommand() string { - return filepath.ToSlash(filepath.Join(productLoopDirName, "bin", helperName())) +func projectLocalLauncherCommand() string { + return filepath.ToSlash(filepath.Join(productLoopDirName, "boatstack")) } func generatedWorkflowReference() string { diff --git a/boatstack/planning_first_write_conformance_test.go b/boatstack/planning_first_write_conformance_test.go index 84d5cb9b..1ee8676d 100644 --- a/boatstack/planning_first_write_conformance_test.go +++ b/boatstack/planning_first_write_conformance_test.go @@ -30,7 +30,7 @@ func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) { t.Cleanup(func() { planningInstallationHealth = previousHealth }) for _, command := range []string{ - ".product-loop/bin/boatstack-helper planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", + ".product-loop/boatstack planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "boatstack-helper check-source-plan --repo . --plan docs/plan.md", } { if findings := ClassifyCommand(repo, command); len(findings) > 0 { diff --git a/boatstack/planning_transport.go b/boatstack/planning_transport.go index f2faf6e3..1f3582a6 100644 --- a/boatstack/planning_transport.go +++ b/boatstack/planning_transport.go @@ -4,7 +4,6 @@ import ( "path" "path/filepath" "regexp" - "runtime" "strings" "unicode" "unicode/utf8" @@ -25,7 +24,7 @@ const powerShellPlanningEncodingLine = `$OutputEncoding = [System.Text.UTF8Encod var posixPlanningHeader = regexp.MustCompile(`^(.*\S)[ \t]+<<'([A-Za-z_][A-Za-z0-9_]{0,63})'[ \t]*$`) var powerShellPlanningClose = regexp.MustCompile(`^'@[ \t]+\|[ \t]+&[ \t]+(.+)$`) -var planningWriteMention = regexp.MustCompile(`(?i)\bboatstack-helper(?:\.exe)?['"]?[ \t]+planning-write(?:[ \t]|$)`) +var planningWriteMention = regexp.MustCompile(`(?i)\bboatstack(?:\.ps1)?['"]?[ \t]+planning-write(?:[ \t]|$)`) type planningTransportInspection struct { Matched bool @@ -129,12 +128,19 @@ func literalCommandWords(value string) (words []string, complete bool) { } func portableExecutableBase(value string) string { - return strings.TrimSuffix(strings.ToLower(path.Base(strings.ReplaceAll(value, "\\", "/"))), ".exe") + base := strings.ToLower(path.Base(strings.ReplaceAll(value, "\\", "/"))) + base = strings.TrimSuffix(base, ".exe") + return strings.TrimSuffix(base, ".ps1") +} + +func planningExecutable(value string) bool { + base := portableExecutableBase(value) + return base == "boatstack" || base == "boatstack-helper" } func planningWriteAttempt(value string) bool { words, _ := literalCommandWords(value) - return len(words) >= 2 && portableExecutableBase(words[0]) == "boatstack-helper" && words[1] == "planning-write" + return len(words) >= 2 && planningExecutable(words[0]) && words[1] == "planning-write" } func planningVerbInvocationAttempt(value string) bool { @@ -158,7 +164,7 @@ func powerShellPlanningAttempt(command string) bool { func planningWriteHeader(value string) (planningWriteInvocation, bool) { words, complete := literalCommandWords(value) - if !complete || len(words) < 2 || portableExecutableBase(words[0]) != "boatstack-helper" || words[1] != "planning-write" { + if !complete || len(words) < 2 || !planningExecutable(words[0]) || words[1] != "planning-write" { return planningWriteInvocation{}, false } values := map[string]string{} @@ -230,17 +236,18 @@ func planningTransportBinding(repo string, transport planningTransportInspection executable = canonical } base := strings.ToLower(filepath.Base(executable)) - canonicalBase := strings.ToLower(helperName()) - if base != canonicalBase && !(runtime.GOOS == "windows" && base == "boatstack-helper") { - return "helper-path-mismatch" - } workspace, workspaceErr := ResolveWorkspaceContext(root) if workspaceErr != nil { return "workspace-binding-unverified" } expected := workspace.HelperPath() - if runtime.GOOS == "windows" && base == "boatstack-helper" { - expected = strings.TrimSuffix(expected, ".exe") + if workspace.Mode == SupervisionEmbedded { + if base != "boatstack" && base != "boatstack.ps1" { + return "helper-path-mismatch" + } + expected = workspace.LauncherPath(base == "boatstack.ps1") + } else if portableExecutableBase(base) != "boatstack-helper" { + return "helper-path-mismatch" } if canonical, canonicalErr := filepath.EvalSymlinks(expected); canonicalErr == nil { expected = canonical diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go index 063d622c..982f7bcc 100644 --- a/boatstack/planning_transport_conformance_test.go +++ b/boatstack/planning_transport_conformance_test.go @@ -102,7 +102,10 @@ func installPlanningTransportFixture(t *testing.T, repo string) string { }); err != nil { t.Fatalf("install healthy planning transport fixture: %v", err) } - return WorkspaceFor(repo).HelperPath() + if runtime.GOOS == "windows" { + return filepath.Join(repo, ".product-loop", "boatstack.ps1") + } + return filepath.Join(repo, ".product-loop", "boatstack") } func executePlanningEnvelope(t *testing.T, repo, command string) { @@ -231,13 +234,13 @@ func TestPlanningTransportTreatsDocumentTextAsInertAcrossHosts(t *testing.T) { "terraform destroy | Remove-Item -Recurse -Force $HOME", "secret-marker-that-must-never-be-rendered", }, "\n") + "\n" - posix := posixPlanningEnvelope(t, ".product-loop/bin/boatstack-helper", repo, "threat-model", "questions.md", body) + posix := posixPlanningEnvelope(t, ".product-loop/boatstack", repo, "threat-model", "questions.md", body) commands := []struct { command, expected string hostConformanceRun bool }{ {posix, body, true}, - {powerShellPlanningEnvelope(t, `.product-loop\bin\boatstack-helper.exe`, repo, "threat-model", "questions.md", body), body, runtime.GOOS == "windows"}, + {powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "threat-model", "questions.md", body), body, runtime.GOOS == "windows"}, {strings.ReplaceAll(posix, "\n", "\r\n"), strings.ReplaceAll(body, "\n", "\r\n"), true}, } for _, test := range commands { @@ -260,9 +263,9 @@ func TestPlanningTransportTreatsDocumentTextAsInertAcrossHosts(t *testing.T) { func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) { repo := safetyTestRepo(t) otherRepo := t.TempDir() - header := planningHeader(t, ".product-loop/bin/boatstack-helper", repo, "transport-failures", "plan.md") - valid := posixPlanningEnvelope(t, ".product-loop/bin/boatstack-helper", repo, "transport-failures", "plan.md", "# Plan\n") - powerShellValid := powerShellPlanningEnvelope(t, `.product-loop\bin\boatstack-helper.exe`, repo, "transport-failures", "plan.md", "# Plan\n") + header := planningHeader(t, ".product-loop/boatstack", repo, "transport-failures", "plan.md") + valid := posixPlanningEnvelope(t, ".product-loop/boatstack", repo, "transport-failures", "plan.md", "# Plan\n") + powerShellValid := powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "transport-failures", "plan.md", "# Plan\n") cases := map[string]string{ "bare command": header, "leading command": "touch sentinel\n" + valid, @@ -279,13 +282,13 @@ func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) "missing feature value": strings.Replace(valid, "--feature transport-failures", "--feature --artifact", 1), "command substitution header": strings.Replace(valid, "--repo "+quotedLiteral(t, repo), "--repo $(touch sentinel)", 1), "repository mismatch": strings.Replace(valid, quotedLiteral(t, repo), quotedLiteral(t, otherRepo), 1), - "helper path mismatch": strings.Replace(valid, ".product-loop/bin/boatstack-helper", "/tmp/boatstack-helper", 1), - "helper alias": strings.Replace(valid, ".product-loop/bin/boatstack-helper", ".product-loop/bin/helper-alias", 1), + "helper path mismatch": strings.Replace(valid, ".product-loop/boatstack", "/tmp/boatstack-helper", 1), + "helper alias": strings.Replace(valid, ".product-loop/boatstack", ".product-loop/bin/helper-alias", 1), "invalid UTF-8": header + " <<'BOATSTACK_PLAN_EOF'\n" + string([]byte{0xff}) + "\nBOATSTACK_PLAN_EOF\n", "NUL content": header + " <<'BOATSTACK_PLAN_EOF'\nplan\x00body\nBOATSTACK_PLAN_EOF\n", "PowerShell no UTF-8 scope": "@'\n# Plan\n'@ | & " + header, "PowerShell truncated": "& {\n" + powerShellPlanningEncodingLine + "\n@'\n# Plan\n", - "PowerShell delimiter collision": powerShellPlanningEnvelope(t, `.product-loop\bin\boatstack-helper.exe`, repo, "transport-failures", "plan.md", "# Plan\n'@\ntouch sentinel\n"), + "PowerShell delimiter collision": powerShellPlanningEnvelope(t, `.product-loop\boatstack.ps1`, repo, "transport-failures", "plan.md", "# Plan\n'@\ntouch sentinel\n"), "PowerShell trailing command": strings.TrimSuffix(powerShellValid, "\n") + "; touch sentinel\n", } for name, command := range cases { @@ -329,7 +332,7 @@ func TestPlanningTransportDoesNotClaimUnrelatedPowerShellHereStrings(t *testing. } func TestPlanningTransportPreservesStageAdmissions(t *testing.T) { - command := posixPlanningEnvelope(t, "/path with spaces/boatstack-helper", "/repo with spaces", "stage-matrix", "questions.md", "# Questions\n") + command := posixPlanningEnvelope(t, "/path with spaces/boatstack", "/repo with spaces", "stage-matrix", "questions.md", "# Questions\n") inspection := inspectPlanningWriteTransport(command) if !inspection.Matched || inspection.InvalidReason != "" { t.Fatalf("valid path variant did not parse: %#v", inspection) diff --git a/boatstack/post_publish_prescribe_conformance_test.go b/boatstack/post_publish_prescribe_conformance_test.go index c84357e0..d357229e 100644 --- a/boatstack/post_publish_prescribe_conformance_test.go +++ b/boatstack/post_publish_prescribe_conformance_test.go @@ -56,14 +56,16 @@ func TestMergedTerminalPrescribesPostPublishSteps(t *testing.T) { payload: phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending), wantActor: NextActorAgent, wantVerb: "flow", + wantProgram: ".product-loop/boatstack", wantTransition: string(MarkerPublishedWatch), - wantInCommand: "boatstack-helper flow watch", + wantInCommand: ".product-loop/boatstack flow watch", }, { name: "checks_failing_prescribes_ci_correction", payload: phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail), wantActor: NextActorAgent, wantVerb: "record-change", + wantProgram: ".product-loop/boatstack", wantTransition: "delivery.record_change", wantInCommand: "--source-stage ci", wantOwed: []string{"--message", "--classification", "--mechanism"}, diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index cb42c8ff..44f7456d 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -131,6 +131,7 @@ clone, `external` outside the repository (Detached Supervision). | source-config | committed-generated | checkout | init, migrate-config, update | | generated-references | committed-generated | checkout | init, update, export | | guard-hooks | committed-generated | checkout | init, update, export | +| runtime-launchers | committed-generated | checkout | init, update, export | | generated-lock | committed-generated | checkout | init, update, export | | planning-artifacts | committed-planning | checkout | planning-write | | approval-receipt | committed-planning | checkout | record-approval | @@ -143,7 +144,7 @@ clone, `external` outside the repository (Detached Supervision). | pr-briefs | committed-planning | checkout | pr-context | | verified-boundaries | committed-planning | checkout | record-delivery-gate | | insight-artifacts | committed-insight | checkout | insight | -| worktree-helper | checkout-runtime | checkout | init, update, hydrate-runtime | +| worktree-helper | checkout-runtime | checkout | init, update, hydrate-runtime, activate-worktree-runtime | | managed-worktrees | checkout-runtime | checkout | workspace-cut, workspace-cleanup, workspace-reap | | delivery-state | runtime-worktree | per-worktree | delivery transitions | | operation-ledger | runtime-worktree | per-worktree | run-preflight, publishers | @@ -173,7 +174,7 @@ controller reads and writes must use `WorkspaceContext.GeneratedRoot`, ## Detached feature reattachment -Run `boatstack-helper attach --repo . --force` to reattach an older embedded +Run `.product-loop/boatstack attach --repo . --force` to reattach an older embedded open-feature package. Boatstack verifies the plan and approval or autonomy fingerprints, copies the package atomically, and verifies the copied hash. The machine result is `IMPORTED`, `UNCHANGED`, `CONFLICTING`, or `REJECTED`. diff --git a/boatstack/references/irreversible-operation-boundary.md b/boatstack/references/irreversible-operation-boundary.md index ec644bdc..1aa1b193 100644 --- a/boatstack/references/irreversible-operation-boundary.md +++ b/boatstack/references/irreversible-operation-boundary.md @@ -36,7 +36,7 @@ Project hooks are deterministic interception, not a complete security sandbox. H Managed-run preflight reports this distinction directly. `HOOK_GUARDED` means the deterministic hook blocks recognized unsafe effects, but ambient cloud authority is not proven absent. `CREDENTIAL_ENFORCED` requires `workflow.external_authority.mode: "credential-enforced"`, an operator-provisioned trust store outside the managed principal's writable boundary, and a short-lived Ed25519-signed receipt from service IAM, a credential broker, or an isolated host. The receipt binds the repository, worktree, host session, principal, issuer, enforcement mechanism, and expiry, and must attest `repository-only` authority with no cloud control-plane capability. Missing, stale, mismatched, overprivileged, self-authored, or invalidly signed receipts block the run before delivery mutation. -The external attestor obtains the expected repository and worktree fingerprints from `boatstack-helper authority-context --repo .`. The managed host supplies the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the session binding in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. These coordinates are bindings, not credentials, and must contain no secret material. +The external attestor obtains the expected repository and worktree fingerprints from `.product-loop/boatstack authority-context --repo .`. The managed host supplies the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the session binding in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. These coordinates are bindings, not credentials, and must contain no secret material. The receipt is strict JSON with `schema_version: 1`, the two context fingerprints, `host_session`, `principal_fingerprint`, `authority_class: "repository-only"`, `cloud_control_plane_authority: false`, `enforced_by` (`service-iam`, `credential-broker`, or `isolated-host`), `issuer`, RFC 3339 `issued_at` and `expires_at`, and a base64 Ed25519 `signature`. Its maximum lifetime is 15 minutes. The signing payload is the compact JSON returned by `AuthorityReceiptSigningBytes` with `signature` set to the empty string; unknown or duplicate fields are rejected. diff --git a/boatstack/references/portability.md b/boatstack/references/portability.md index 3d9ce2ef..1c448b7a 100644 --- a/boatstack/references/portability.md +++ b/boatstack/references/portability.md @@ -9,7 +9,8 @@ The source of truth is `.product-loop/`: - `artifacts.md`: document contract; - `failure-moves.md`: failure taxonomy and experimental rules; - `templates/`: artifact templates; -- `bin/boatstack-helper`: ignored, platform-native deterministic helper installed locally; +- `boatstack` and `boatstack.ps1`: tracked, pinned command launchers available in every worktree; +- `bin/boatstack-helper`: ignored, platform-native deterministic runtime activated internally by the launchers; - `generated.lock.json`: generator version, config hash, and generated file list. Host-specific files are compiled adapters: diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 9156adc1..887c050d 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -61,7 +61,7 @@ the structure below, with a host-compatible rendering for **Technical details**. Begin every Boatstack response with the status banner, so the reader can tell Boatstack's output apart from ordinary prose and see where their work stands at a glance. Emit the exact -output of `boatstack-helper next-status --repo . --render` verbatim (a fenced code block or as +output of `.product-loop/boatstack next-status --repo . --render` verbatim (a fenced code block or as plain lines), above the `## ` heading. The banner is presentation only: it does not replace the single `### Next step`, does not add a second action, and never introduces machine codes or internal stage names (the renderer already hides them). The `--json` @@ -184,7 +184,7 @@ For bug-shaped intent (a crash, stack trace, or failing signal), the read-only ` Before repository inspection, run: ```bash -.product-loop/bin/boatstack-helper check-source-plan --repo . --plan +.product-loop/boatstack check-source-plan --repo . --plan ``` Boatstack never scans directories for plans, so `--plan` is required and no unshipped saved plan becomes ambient context. If no plan path is supplied, or the file is missing, empty, or unreadable, `auto-plan` is `BLOCKED` and must request the plan to build. It must not manufacture the missing input. Because the file's hash is recorded and re-checked through `BUILD`, `--plan` must point at a durable in-repo path that stays present and unchanged; a path outside the repository is rejected. This source plan is an initial proposal rather than human approval. @@ -269,12 +269,12 @@ If gstack is installed, its review skills can execute these lenses. If Spec Kit ### Literal planning transport -Feature artifacts are authored through the owned channel `.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact `. The complete Markdown document and command must cross the host hook in one literal envelope. The hook binds the command to the current repository's project-local helper, validates the command and closing delimiter, treats the body as data, and denies truncation or trailing commands before the shell runs. +Feature artifacts are authored through the owned channel `.product-loop/boatstack planning-write --repo . --feature --artifact `. The complete Markdown document and command must cross the host hook in one literal envelope. The hook binds the command to the current repository's project-local helper, validates the command and closing delimiter, treats the body as data, and denies truncation or trailing commands before the shell runs. In Bash, zsh, and Git Bash, use a single-quoted heredoc. The closing token must not occur as a line in the Markdown; choose another simple token when it does. In Git Bash on Windows, append `.exe` to the project-local helper path. ```bash -.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' +.product-loop/boatstack planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' BOATSTACK_PLAN_EOF ``` @@ -286,7 +286,7 @@ In Windows PowerShell, keep UTF-8 local to a child scope and use a single-quoted $OutputEncoding = [System.Text.UTF8Encoding]::new($false) @' -'@ | & '.product-loop\bin\boatstack-helper.exe' planning-write --repo . --feature --artifact +'@ | & '.product-loop\boatstack.ps1' planning-write --repo . --feature --artifact } ``` @@ -306,7 +306,7 @@ When `workflow.pr_visual_evidence` is `suggest` or `require`, every managed plan ### `PLAN -> PLAN_GATE` -Run `boatstack-helper check-plan --plan /plan.md` and present the full draft, plan fingerprint, and product baseline returned by the check. A non-empty baseline includes its exact diff, changed paths, and SHA-256 so edits that existed when managed planning began remain visible and preserved. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only. +Run `.product-loop/boatstack check-plan --plan /plan.md` and present the full draft, plan fingerprint, and product baseline returned by the check. A non-empty baseline includes its exact diff, changed paths, and SHA-256 so edits that existed when managed planning began remain visible and preserved. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only. ### `PLAN_GATE -> PLAN_APPROVED` diff --git a/boatstack/repair_state_test.go b/boatstack/repair_state_test.go index 1f707ccf..e64cd47d 100644 --- a/boatstack/repair_state_test.go +++ b/boatstack/repair_state_test.go @@ -29,17 +29,17 @@ func writeMalformedDraft(t *testing.T, repo, feature string) string { } func TestControlledPhaseTransitionAllowsRepairStateAcrossStages(t *testing.T) { - command := ".product-loop/bin/boatstack-helper repair-state --repo . --feature stuck" + command := ".product-loop/boatstack repair-state --repo . --feature stuck" for _, stage := range []string{"", "INVALID_STATE", "DRAFT_PLAN", "APPROVED"} { if !controlledPhaseTransition(command, stage) { t.Fatalf("repair-state was denied at stage %q; recovery must be reachable", stage) } } // The escape hatch must not widen the surface for real transitions or metachars. - if controlledPhaseTransition(".product-loop/bin/boatstack-helper activate-plan", "INVALID_STATE") { + if controlledPhaseTransition(".product-loop/boatstack activate-plan", "INVALID_STATE") { t.Fatal("activate-plan escaped the INVALID_STATE interlock") } - if controlledPhaseTransition(".product-loop/bin/boatstack-helper repair-state; rm -rf .", "INVALID_STATE") { + if controlledPhaseTransition(".product-loop/boatstack repair-state; rm -rf .", "INVALID_STATE") { t.Fatal("chained destruction was allowed to ride on repair-state") } if controlledPhaseTransition("python scripts/migrate.py", "INVALID_STATE") { @@ -59,7 +59,7 @@ func TestRepairStateClosesTheInvalidStateLoop(t *testing.T) { 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 denied := ClassifyCommand(repo, ".product-loop/bin/boatstack-helper repair-state --repo . --feature stuck-feature"); len(denied) != 0 { + 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 5cfab1aa..02e1f05b 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-helper(?:[_.-][a-z0-9._-]+)?\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-helper(?:[_.-][a-z0-9._-]+)?\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)|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)`) // Constitutional/Optimization split. These destruction rules are CONSTITUTIONAL: // they define the real boundary (destroying a live resource) and are never traded @@ -121,7 +121,7 @@ var operationalPathPattern = regexp.MustCompile(`(?i)(?:^|/)(?:scripts?|migratio // without treating names such as check-update or API method labels as queries. var mutationStatementPattern = regexp.MustCompile(`(?is)\b(?:delete\s+from\s+(?:[a-z_][a-z0-9_$.-]*|"[^"]+")|update\s+(?:[a-z_][a-z0-9_$.-]*|"[^"]+")\s+set\b)[^;]*`) var directPublicationPattern = regexp.MustCompile(`(?i)(?:\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|edit|ready|merge)\b|\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)|\bhub\s+pull-request\b|\bcurl\b[^\n;&|]*(?:api\.github\.com|/pulls\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH)))`) -var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper\s+publish-pr\b[^\n;&|]*$`) +var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:&\s+)?['"]?\.product-loop[/\\]boatstack(?:\.ps1)?['"]?\s+publish-pr\b[^\n;&|]*$`) // approvedUpdatePublisherPattern recognizes the sanctioned Boatstack version-update // publisher. That command must be passed the update preview path, which lives under @@ -133,7 +133,7 @@ var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack // chained after it, and it tolerates the platform-suffixed helper binary (for example // boatstack-helper_darwin_arm64) that a running update may invoke after the installed // helper is swapped or removed. -var approvedUpdatePublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+publish-update-pr\b[^\n;&|]*$`) +var approvedUpdatePublisherPattern = regexp.MustCompile(`(?i)^\s*(?:&\s+)?['"]?\.product-loop[/\\]boatstack(?:\.ps1)?['"]?\s+publish-update-pr\b[^\n;&|]*$`) // deliveryStatePathPattern matches Boatstack's managed runtime/control state so // the guard denies direct model mutation of it. It covers the embedded homes @@ -242,7 +242,7 @@ func controlledPhaseTransition(command, stage string) bool { return false } executable := portableExecutableBase(fields[0]) - if executable != "boatstack-helper" { + if executable != "boatstack" && executable != "boatstack-helper" { return false } if readOnlyHelperVerbs[fields[1]] { @@ -275,7 +275,11 @@ func controlledWorkspaceSync(repo, command string) bool { if err != nil { return false } - expected := filepath.Join(repo, ".product-loop", "bin", helperName()) + launcherName := "boatstack" + if strings.EqualFold(filepath.Ext(executable), ".ps1") { + launcherName = "boatstack.ps1" + } + expected := filepath.Join(repo, ".product-loop", launcherName) expected, err = filepath.Abs(expected) if err != nil || filepath.Clean(executable) != filepath.Clean(expected) { return false diff --git a/boatstack/safety_corpus_test.go b/boatstack/safety_corpus_test.go index 615fb147..7944573d 100644 --- a/boatstack/safety_corpus_test.go +++ b/boatstack/safety_corpus_test.go @@ -91,7 +91,7 @@ func TestGuardCorpusDualReward(t *testing.T) { }, "routine", false}, // The owned planning channel and ordinary product writes stay open at zero // candidates — the first-write latch is path-scoped, never a blanket deny. - {"planning-write-first-artifact", "", ".product-loop/bin/boatstack-helper planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "routine", false}, + {"planning-write-first-artifact", "", ".product-loop/boatstack planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "routine", false}, {"check-source-plan", "", `boatstack-helper check-source-plan --repo . --plan docs/plan.md`, "routine", false}, {"write-product-source", "Write", map[string]any{ "file_path": filepath.Join(repo, "src", "app.ts"), diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 4e5c9768..63a2c14c 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -234,21 +234,21 @@ func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) { repo := safetyTestRepo(t) writeValidSavedFeaturePlan(t, repo, "pending-feature") - helper := filepath.Join(repo, ".product-loop", "bin", helperName()) - if err := os.MkdirAll(filepath.Dir(helper), 0o755); err != nil { + launcher := filepath.Join(repo, ".product-loop", "boatstack") + if err := os.MkdirAll(filepath.Dir(launcher), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + if err := os.WriteFile(launcher, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } - command := ".product-loop/bin/" + helperName() + " workspace-sync --repo . --branch main --source origin/main" + command := ".product-loop/boatstack workspace-sync --repo . --branch main --source origin/main" if findings := ClassifyCommand(repo, command); len(findings) != 0 { t.Fatalf("exact project-local workspace sync was denied: %#v", findings) } for _, command := range []string{ "boatstack-helper workspace-sync --repo . --branch main --source origin/main", "/tmp/boatstack-helper workspace-sync --repo . --branch main --source origin/main", - ".product-loop/bin/" + helperName() + " workspace-sync --repo /tmp --branch main --source origin/main", + ".product-loop/boatstack workspace-sync --repo /tmp --branch main --source origin/main", } { findings := ClassifyCommand(repo, command) if len(findings) == 0 || findings[0].Category != "workspace-sync-bypass" { @@ -370,7 +370,7 @@ func TestSafeDiagnosticsAndFixForwardCommandsRemainAllowed(t *testing.T) { `git diff -- scripts/apply_schema.py | head -20`, `python scripts/apply_schema.py --dry-run`, `psql -c "SELECT current_database()"`, - `.product-loop/bin/boatstack-helper check-update --repo . --force`, + `.product-loop/boatstack check-update --repo . --force`, `psql -c "UPDATE accounts SET active = false WHERE id = 7"`, } for _, command := range commands { @@ -798,13 +798,13 @@ func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { if findings := ClassifyTool(repo, "mcp__files__read", map[string]any{"path": "src/app.ts"}); len(findings) != 0 { t.Fatalf("explicitly read-only MCP inspection was denied: %#v", findings) } - if findings := ClassifyCommand(repo, ".product-loop/bin/boatstack-helper check-plan --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { + 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 := ClassifyCommand(repo, ".product-loop/bin/boatstack-helper record-approval --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { + 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) } statusAfter, err := gitCommand(repo, "status", "--short") diff --git a/boatstack/safety_update_publisher_test.go b/boatstack/safety_update_publisher_test.go index 8e746cbb..1ef95755 100644 --- a/boatstack/safety_update_publisher_test.go +++ b/boatstack/safety_update_publisher_test.go @@ -24,10 +24,8 @@ func TestUpdatePublisherIsExemptFromStateTamper(t *testing.T) { preview := updatePreviewArg(repo) fingerprint := strings.Repeat("a", 64) commands := map[string]string{ - "installed helper": ".product-loop/bin/boatstack-helper publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint, - "absolute path": "/usr/local/bin/boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint, - "suffixed binary": "/tmp/boatstack-helper_darwin_arm64 publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint, - "bare name": "boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint, + "POSIX launcher": ".product-loop/boatstack publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint, + "PowerShell launcher": "& '.product-loop\\boatstack.ps1' publish-update-pr --repo . --preview " + preview + " --preview-fingerprint " + fingerprint, } for name, command := range commands { t.Run(name, func(t *testing.T) { @@ -38,6 +36,24 @@ func TestUpdatePublisherIsExemptFromStateTamper(t *testing.T) { } } +func TestUpdatePublisherRejectsForeignRuntimeEntrypoints(t *testing.T) { + repo := safetyTestRepo(t) + preview := updatePreviewArg(repo) + fingerprint := strings.Repeat("a", 64) + for name, command := range map[string]string{ + "internal helper": ".product-loop/bin/boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint, + "foreign helper": "/tmp/boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint, + "launcher alias": "/tmp/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint, + } { + t.Run(name, func(t *testing.T) { + findings := ClassifyCommand(repo, command) + if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" { + t.Fatalf("foreign runtime entrypoint was admitted: %#v", findings) + } + }) + } +} + // The exemption must hold in the state that actually blocked real updates: a repo with // an active managed delivery. The tamper branch runs before the delivery-aware checks, // so the publish must pass through regardless of delivery state. @@ -50,7 +66,7 @@ func TestUpdatePublisherIsExemptWithActiveDelivery(t *testing.T) { }); err != nil { t.Fatal(err) } - command := ".product-loop/bin/boatstack-helper publish-update-pr --repo . --preview " + updatePreviewArg(repo) + " --preview-fingerprint " + strings.Repeat("a", 64) + command := ".product-loop/boatstack publish-update-pr --repo . --preview " + updatePreviewArg(repo) + " --preview-fingerprint " + strings.Repeat("a", 64) if findings := ClassifyCommand(repo, command); len(findings) != 0 { t.Fatalf("the update publisher was denied while a delivery was active: %#v", findings) } @@ -93,9 +109,9 @@ func TestUpdatePublisherExemptionRejectsChaining(t *testing.T) { preview := updatePreviewArg(repo) fingerprint := strings.Repeat("a", 64) for name, command := range map[string]string{ - "semicolon": "boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + "; rm -rf important", - "and": "boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " && rm -rf important", - "pipe": "boatstack-helper publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " | tee steal", + "semicolon": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + "; rm -rf important", + "and": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " && rm -rf important", + "pipe": ".product-loop/boatstack publish-update-pr --preview " + preview + " --preview-fingerprint " + fingerprint + " | tee steal", } { t.Run(name, func(t *testing.T) { findings := ClassifyCommand(repo, command) @@ -121,7 +137,7 @@ func TestApprovedPublishPRStaysAllowedDuringActiveDelivery(t *testing.T) { if findings := ClassifyCommand(repo, "gh pr create --title phase-one"); len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" { t.Fatalf("sanity: a direct PR creation should be denied while a delivery is active: %#v", findings) } - allowed := ".product-loop/bin/boatstack-helper publish-pr --preview .product-loop/features/phased-feature/pr.md --preview-fingerprint " + strings.Repeat("a", 64) + " --action create" + allowed := ".product-loop/boatstack publish-pr --preview .product-loop/features/phased-feature/pr.md --preview-fingerprint " + strings.Repeat("a", 64) + " --action create" if findings := ClassifyCommand(repo, allowed); len(findings) != 0 { t.Fatalf("the sanctioned publish-pr helper was denied: %#v", findings) } diff --git a/boatstack/statemap.go b/boatstack/statemap.go index c7e3daed..3ca1c86b 100644 --- a/boatstack/statemap.go +++ b/boatstack/statemap.go @@ -97,6 +97,11 @@ func StateRegistry() []StateEntry { OwnerVerbs: []string{"init", "update", "export"}, Sample: generatedSample("hooks", "guard.sh"), }, + { + Name: "runtime-launchers", Class: ClassCommittedGenerated, Partition: "checkout", + OwnerVerbs: []string{"init", "update", "export"}, + Sample: generatedSample("boatstack"), + }, { Name: "generated-lock", Class: ClassCommittedGenerated, Partition: "checkout", OwnerVerbs: []string{"init", "update", "export"}, @@ -165,7 +170,7 @@ func StateRegistry() []StateEntry { }, { Name: "worktree-helper", Class: ClassCheckoutRuntime, Partition: "checkout", Gitignored: true, - OwnerVerbs: []string{"init", "update", "hydrate-runtime"}, + OwnerVerbs: []string{"init", "update", "hydrate-runtime", "activate-worktree-runtime"}, Sample: generatedSample("bin", "install.lock.json"), }, { diff --git a/boatstack/statemap_conformance_test.go b/boatstack/statemap_conformance_test.go index e37dabf9..88713318 100644 --- a/boatstack/statemap_conformance_test.go +++ b/boatstack/statemap_conformance_test.go @@ -154,8 +154,9 @@ func TestGuardClassifiersMatchDeclaredOwnership(t *testing.T) { func TestProductLoopLiteralsStayInDeclaredFiles(t *testing.T) { allowed := map[string]string{ "activation.go": "controller-syntax", "delivery.go": "controller-syntax", "export.go": "controller-bundle", - "hooks.go": "embedded-installation", - "init.go": "embedded-installation", "installation_repair.go": "embedded-installation", "mutation_undo.go": "controller-syntax", + "hooks.go": "embedded-installation", + "launcher.go": "embedded-installation", + "init.go": "embedded-installation", "installation_repair.go": "embedded-installation", "mutation_undo.go": "controller-syntax", "paths.go": "canonical-owner", "planning.go": "product-diff-syntax", "pr.go": "product-diff-syntax", "recovery.go": "product-diff-syntax", "runtime_cache.go": "embedded-installation", "safety.go": "policy-syntax", "update.go": "embedded-installation", "update_publication.go": "embedded-installation", diff --git a/docs/configuration.md b/docs/configuration.md index c4769f4c..3f893048 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,7 +127,7 @@ Changelog enforcement is mechanical. Boundary analysis is model-mediated plannin } ``` -Strict mode requires an external service-IAM, credential-broker, or isolated-host attestor. The JSON trust store maps issuer IDs to base64 Ed25519 public keys and must be operator-owned outside the repository; Boatstack rejects a file or parent directory owned or writable by the managed principal. Obtain the expected binding with `boatstack-helper authority-context --repo .`; the attestor signs a receipt for that repository, worktree, host session, principal, and a maximum 15-minute lifetime. Set the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the attested session in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. Boatstack never holds the signing key. Missing or invalid evidence blocks `run-preflight` and remains `HOOK_GUARDED`; only a valid external receipt reports `CREDENTIAL_ENFORCED`. +Strict mode requires an external service-IAM, credential-broker, or isolated-host attestor. The JSON trust store maps issuer IDs to base64 Ed25519 public keys and must be operator-owned outside the repository; Boatstack rejects a file or parent directory owned or writable by the managed principal. Obtain the expected binding with `.product-loop/boatstack authority-context --repo .`; the attestor signs a receipt for that repository, worktree, host session, principal, and a maximum 15-minute lifetime. Set the absolute receipt path in `BOATSTACK_AUTHORITY_RECEIPT`, the attested session in `BOATSTACK_HOST_SESSION`, and the attested principal fingerprint in `BOATSTACK_PRINCIPAL_FINGERPRINT`. Boatstack never holds the signing key. Missing or invalid evidence blocks `run-preflight` and remains `HOOK_GUARDED`; only a valid external receipt reports `CREDENTIAL_ENFORCED`. ```json { diff --git a/docs/generated-files.md b/docs/generated-files.md index b83e3163..690e60eb 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -10,7 +10,9 @@ Boatstack creates installation state once and feature evidence repeatedly. Keepi | Path | What it is | What you do | |---|---|---| | `.boatstack-project.json` | Project-owned repository facts and commands | Review and edit | -| `.product-loop/` references, templates, hooks, and generated lock | Shared Boatstack runtime | Commit; regenerate rather than hand-edit | +| `.product-loop/` references, templates, hooks, launchers, and generated lock | Shared Boatstack contract | Commit; regenerate rather than hand-edit | +| `.product-loop/boatstack` | POSIX exact-runtime command launcher | Commit with executable mode; invoke for Boatstack commands | +| `.product-loop/boatstack.ps1` | PowerShell exact-runtime command launcher | Commit; invoke with `& .product-loop/boatstack.ps1` | | `.cursor/`, `.agents/`, and `.claude/` Boatstack adapters | Cursor commands, the Codex router, and Claude's visible workflow skills plus hidden natural-language router | Commit | | `.github/PULL_REQUEST_TEMPLATE/boatstack.md` | Fallback PR structure | Commit | | `.cursor/hooks.json`, `.claude/settings.json`, `.codex/hooks.json` | Boatstack fragments merged with existing host settings | Review and commit | @@ -49,9 +51,9 @@ PR schema v3 includes structural visual-evidence policy, status, count, and fing ## Worktrees, fresh clones, and updates -One verified runtime is cached under the clone's Git common directory and keyed by Boatstack version, source commit, operating system, and architecture. Linked worktrees share that cache. Their first guarded command atomically restores the ignored local helper and install lock, then evaluates the original command. Hydration uses no network and produces no tracked diff. +One verified runtime is cached under the clone's Git common directory and keyed by Boatstack version, source commit, operating system, and architecture. Linked worktrees share that cache and inherit tracked POSIX and PowerShell launchers. A launcher resolves only its baked version and source commit, verifies the manifest and checksum, atomically restores the ignored local helper and install lock, then dispatches the requested command. It does not inspect sibling worktrees or select `latest`. A missing shared slot hydrates through the exact tag-pinned, checksum-verified installer. -Independent clones do not share a Git common directory. Committed adapters survive a clone, but the ignored helper and repository-family cache do not; run the installer once in the new clone. +Independent clones do not share a Git common directory. The committed launchers survive a clone, while the ignored helper and repository-family cache do not. The first launcher invocation hydrates the exact pinned runtime through the verified installer. For an update, run `/boatstack-update` from a current default branch with no product or user-owned edits. Boatstack creates `chore/update-boatstack-v`, verifies the target helper before inspecting the installed runtime, preserves integrations, and stores a fingerprinted non-empty update-PR preview under Git-common Boatstack state before asking for `o`. Exact owned migrations are automatic. Explicit `--repair` backs up recoverable owned drift under Git-common `boatstack/repair-backups/` and keeps the repaired files in the same update PR. @@ -62,7 +64,7 @@ An update refuses feature branches, stale default branches, product edits, user- If generated state looks wrong, run: ```bash -.product-loop/bin/boatstack-helper doctor --repo . +.product-loop/boatstack doctor --repo . ``` Do not delete adapters merely to make a feature diff smaller. If the original installation was never committed, stop and create its infrastructure PR first. diff --git a/docs/getting-started.md b/docs/getting-started.md index 4197a7ae..3874b78c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,7 +3,7 @@ **For:** a product builder or engineer using Cursor, Codex, or Claude Code. **Outcome:** install Boatstack in one infrastructure PR, then take one ordinary request through approval, build, evidence, review, and PR preparation. -Boatstack is repository-local. Install it once per Git clone and commit the shared workflow before starting product work. Linked Git worktrees reuse the clone's verified runtime automatically. +Boatstack is repository-local. Adopt it once in the repository and commit the shared workflow before starting product work. Fresh clones and linked worktrees inherit tracked launchers that activate the pinned runtime automatically. ## Return after an interruption @@ -39,18 +39,18 @@ irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 | Choose `core` unless you already want gstack, GitHub Spec Kit, or both. Confirm the real repository test command when asked. The installer previews paths, verifies the helper, installs portable host adapters, and runs: ```bash -.product-loop/bin/boatstack-helper doctor --repo . +.product-loop/boatstack doctor --repo . ``` Review and commit the paths printed by the installer. Merge this infrastructure PR before creating a feature branch. Later feature PRs then contain the product change and its evidence rather than one-time setup noise. ### Git worktrees -The installer keeps a versioned, verified runtime under Git's common directory. A linked worktree still starts without the ignored `.product-loop/bin/` directory, but its first guarded Cursor, Codex, or Claude call restores that local runtime automatically before evaluating the original command. This performs no download and changes no tracked files. +The installer keeps a versioned, verified runtime under Git's common directory. Every linked worktree inherits `.product-loop/boatstack` and `.product-loop/boatstack.ps1` from Git. Its first launcher invocation verifies the exact pinned shared runtime, restores the ignored `.product-loop/bin/` runtime, and dispatches the command without requiring hook trust. This performs no download and changes no tracked files when the shared runtime is present. Host activation is separate from runtime installation. Codex requires the exact linked-worktree project path and pre/post tool hook definitions to be reviewed and trusted through `/hooks`; start a new task after trusting them. Claude Code requires Bash and exposes `PreToolUse`, `PostToolUse`, and failure observation through `/hooks`. Cursor requires a window reload and enabled before/after native, shell, and MCP hooks. -Different Boatstack versions use separate cached runtimes, so an older worktree is not silently run with a newer helper. A separate clone has a different Git common directory and still needs one installer run. +Different Boatstack versions use separate cached runtimes, so an older worktree is not silently run with a newer helper. The launcher never searches sibling worktrees or selects `latest`. A separate clone has a different Git common directory; its first launcher invocation hydrates the exact pinned release through the verified installer when needed. ## 2. Start with the idea diff --git a/docs/public-claims.json b/docs/public-claims.json index 8c836c6c..ba74680c 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -104,13 +104,13 @@ }, { "id": "git-worktree-activation", - "public_claim": "Boatstack installs one verified runtime per Git clone and automatically restores ignored local runtime state for linked worktrees before evaluating their first guarded command.", + "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.", "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": "Versioned Git-common-directory runtime cache, generated provenance selection, atomic local hydration, and fail-closed mismatch handling.", + "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/runtime_cache.go", "../boatstack/hooks.go"], - "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], + "implementation": ["../boatstack/launcher.go", "../boatstack/runtime_cache.go"], + "verification": ["../boatstack/launcher_test.go", "../boatstack/runtime_cache_test.go", "../boatstack/hooks_hydrate_test.go"], "last_verified_version": "v0.7.120" }, { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 61183379..d2b9946b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6,7 +6,7 @@ Start with: ```bash -.product-loop/bin/boatstack-helper doctor --repo . +.product-loop/boatstack doctor --repo . ``` ## A command is denied as destructive @@ -15,9 +15,11 @@ Boatstack has no in-session bypass. Preserve the current external state and diag If a safe diagnostic was denied, keep the denial output and report the smallest reproducible command. Do not rename or wrap it to evade the check. -## The safety helper or hook is missing +## The runtime or hook is missing -The hook fails closed. In a linked worktree, the first guarded call should restore the ignored local helper from the verified repository-family cache. If Boatstack reports that the shared runtime is missing, run the official installer once from any checkout belonging to that Git clone, run `doctor`, and reload the coding host. Do not copy an executable without its verified runtime manifest. +Run `.product-loop/boatstack doctor --repo .` from the linked worktree. The tracked launcher verifies the committed pin and exact shared runtime before it restores the ignored local helper. This activation does not depend on hook trust. If the exact shared runtime is absent, the launcher runs the tag-pinned, checksum-verified installer and reports one pinned recovery command if activation still fails. Do not copy an executable from another worktree or select a newer cached runtime. + +A missing `.product-loop/bin/` directory is activation state, not evidence that a newer Boatstack release is required. `doctor` proves the generated host contract, not host activation. In Codex, trust the exact linked-worktree path, open `/hooks`, review and trust the current Boatstack hook hash, and start a new task. In Claude Code, reload and use `/hooks` to confirm the `PreToolUse` hook; Bash is required. In Cursor, reload the window and confirm both pre-execution hooks are enabled. Cursor hooks remain defense in depth because host-side output handling can change independently of Boatstack. @@ -32,7 +34,7 @@ This is a Cursor host initialization failure: Boatstack's hook process did not s Boatstack received a hook event without a decodable command or tool call. It fails closed, but no unsafe operation was detected. Retry once with an explicit non-empty command. If the same code repeats, stop agent shell and tool retries, preserve edits, and run this from a normal terminal outside the blocked agent path: ```bash -.product-loop/bin/boatstack-helper diagnose-hook --host cursor --repo . +.product-loop/boatstack diagnose-hook --host cursor --repo . ``` Replace `cursor` with `claude` or `codex` for those hosts. A passing probe proves the installed wrapper, shared runtime, decoder, and canonical allow response; it cannot reveal the live payload emitted by the coding host. For Cursor, start a new task after a passing probe. Do not reinstall or hydrate Boatstack unless it separately reports a missing, drifted, unsafe, or checksum-invalid runtime. @@ -55,7 +57,7 @@ Cursor reads project commands from `.cursor/commands/*.md`: ```bash ls .cursor/commands -.product-loop/bin/boatstack-helper doctor --repo . +.product-loop/boatstack doctor --repo . ``` Rerun the installer and reload Cursor when files are missing. Commit the restored adapter in a dedicated infrastructure PR. @@ -66,7 +68,7 @@ Claude Code reads Boatstack's user-facing workflow skills from `.claude/skills/< ```bash ls .claude/skills -.product-loop/bin/boatstack-helper doctor --repo . +.product-loop/boatstack doctor --repo . ``` If Boatstack created `.claude/skills/` while Claude Code was already running, reload Claude Code once. Rerun the installer when `doctor` reports a missing generated skill, and never replace a user-owned skill with the same name without reviewing the collision. @@ -80,12 +82,12 @@ Finish the host's Plan-mode exploration and save it as a durable file, then reru Planning is Markdown-only. Send the complete command and document in one shell-tool call. For Bash, zsh, or Git Bash: ```bash -.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' +.product-loop/boatstack planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' BOATSTACK_PLAN_EOF ``` -In Git Bash on Windows, use `.product-loop/bin/boatstack-helper.exe` in the same envelope. +In Git Bash on Windows, use `.product-loop/boatstack` in the same envelope. For Windows PowerShell: @@ -94,7 +96,7 @@ For Windows PowerShell: $OutputEncoding = [System.Text.UTF8Encoding]::new($false) @' -'@ | & '.product-loop\bin\boatstack-helper.exe' planning-write --repo . --feature --artifact +'@ | & '.product-loop\boatstack.ps1' planning-write --repo . --feature --artifact } ``` @@ -132,7 +134,7 @@ Boatstack detects common package-manager tests, `scripts/check.sh`, Go, Rust, Ma ## A fresh clone has no helper -This is expected: the repository-family cache lives inside that clone's Git common directory and `.product-loop/bin/` is ignored. Run the installer once from the repository root. Future linked worktrees of that clone hydrate automatically without another download. +This is expected: the repository-family cache lives inside that clone's Git common directory and `.product-loop/bin/` is ignored. Run `.product-loop/boatstack doctor --repo .`. The tracked launcher hydrates the exact pinned runtime through the tag-pinned, checksum-verified installer. Future linked worktrees reuse that clone-wide runtime without another download. ## `/boatstack-update` is postponed @@ -160,7 +162,7 @@ If a previous update succeeded but its generated diff was later discarded, rerun ## A tool call repeats or publication appears stuck -Run `.product-loop/bin/boatstack-helper operation-status --repo . --json`. `EXECUTING` means the exact call already has a live lease, so wait instead of launching it again. `RECONCILE_REQUIRED` means Boatstack did not observe completion; verify the reported Git, GitHub, file, browser, or MCP postcondition before retrying. A successful operation whose response was lost is recovered from that observation. Do not reset the task, repeat a denied push, or open another PR. +Run `.product-loop/boatstack operation-status --repo . --json`. `EXECUTING` means the exact call already has a live lease, so wait instead of launching it again. `RECONCILE_REQUIRED` means Boatstack did not observe completion; verify the reported Git, GitHub, file, browser, or MCP postcondition before retrying. A successful operation whose response was lost is recovered from that observation. Do not reset the task, repeat a denied push, or open another PR. Operation receipts are shared by linked worktrees and retry budgets survive new chats and host restarts. If more than one unfinished operation matches, rerun status with the reported operation ID rather than choosing the newest. The receipts contain fingerprints and secret-free observations; no command payload or credential should be added to them. diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md index af3c6c03..13d48045 100644 --- a/docs/why-these-steps.md +++ b/docs/why-these-steps.md @@ -101,11 +101,11 @@ The paired product evaluation will use the same feature, lower-cost model, budge **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. On the first guarded call in a linked worktree, that runtime checks the worktree's generated provenance, atomically restores its ignored local helper and lock, and then evaluates the original event. It performs no download and does not share trust across unrelated clones. +**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; repository-family activation behavior verified in automated tests. Fresh independent clones still require one verified installer run. +**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 diff --git a/labs/diagram-json/README.md b/labs/diagram-json/README.md index c1f3feb3..743e8579 100644 --- a/labs/diagram-json/README.md +++ b/labs/diagram-json/README.md @@ -50,7 +50,7 @@ The result is a draft, not code: The agent first validates `plan.md` without creating files: ```bash -.product-loop/bin/boatstack-helper check-plan --plan plan.md +.product-loop/boatstack check-plan --plan plan.md ``` It then presents the draft, its fingerprint, and the three contract decisions @@ -72,7 +72,7 @@ When the developer uses the host's normal Build transition, `/build` activates the exact approved plan before its first product-code edit: ```bash -.product-loop/bin/boatstack-helper activate-plan \ +.product-loop/boatstack activate-plan \ --plan plan.md \ --approval approval.md \ --out-dir compiled \ @@ -92,7 +92,7 @@ approved state into deterministic machine artifacts. Editing `source-plan.md`, implementation. ```bash -.product-loop/bin/boatstack-helper activate-plan \ +.product-loop/boatstack activate-plan \ --plan plan.md \ --approval approval.md \ --out-dir compiled \ diff --git a/release-notes/2026-08-09-deterministic-worktree-runtime-launcher.md b/release-notes/2026-08-09-deterministic-worktree-runtime-launcher.md new file mode 100644 index 00000000..a01308f5 --- /dev/null +++ b/release-notes/2026-08-09-deterministic-worktree-runtime-launcher.md @@ -0,0 +1,3 @@ +### Make Boatstack commands reliable in every worktree + +Every generated repository now tracks pinned POSIX and PowerShell launchers. A fresh linked worktree can activate its exact Boatstack runtime before hook trust, without selecting a helper from another worktree or treating missing local bytes as an available update. From ebf3b2a5ce3b90b2feaa4837d60736c696613057 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 9 Aug 2026 19:22:53 +0100 Subject: [PATCH 2/4] fix cross-platform launcher CI conformance --- boatstack/launcher.go | 6 +++++- boatstack/planning_test.go | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/boatstack/launcher.go b/boatstack/launcher.go index 94eb2433..778b15f5 100644 --- a/boatstack/launcher.go +++ b/boatstack/launcher.go @@ -168,7 +168,11 @@ if (($manifest.binary_sha256 -notmatch '^[0-9a-f]{64}$') -or ($actual -ne $manif if ($LASTEXITCODE -ne 0) { Fail-Activation "the pinned runtime could not activate this worktree" } $local = Join-Path $root ".product-loop/bin/boatstack-helper.exe" if (-not (Test-Path -LiteralPath $local -PathType Leaf)) { Fail-Activation "worktree runtime activation did not produce a safe helper" } -& $local @args +if ($MyInvocation.ExpectingInput) { + $input | & $local @args +} else { + & $local @args +} exit $LASTEXITCODE `, recovery, Version, SourceCommit, recovery)) } diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index e286d52e..a2079abc 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -20,6 +20,8 @@ func planningRepo(t *testing.T) string { if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) } + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") return repo } From beed8c949e0334969615a4c691bd469cc7430dba Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 9 Aug 2026 19:32:33 +0100 Subject: [PATCH 3/4] preserve generated bytes across worktree checkouts --- boatstack/export.go | 24 ++++++++++++++++++++++++ boatstack/export_test.go | 6 ++++++ boatstack/init.go | 4 ++-- boatstack/launcher_test.go | 3 +++ boatstack/planning.go | 6 +++++- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/boatstack/export.go b/boatstack/export.go index 4156ba62..82c3eb53 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -13,6 +13,13 @@ import ( var adapterNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +// embeddedConfigBytes removes only Git's platform line-ending transport from +// the editable embedded config. Detached supervision keeps its exact-byte +// digest and never calls this helper. +func embeddedConfigBytes(value []byte) []byte { + return []byte(strings.ReplaceAll(string(value), "\r\n", "\n")) +} + var allowedAdapters = map[string]bool{ "cursor": true, "claude": true, @@ -596,6 +603,23 @@ List explicit gaps with impact and revisit trigger, or state that no material ga return ExportBundle{}, err } + lineEndingAttributes := []byte("# " + Marker + "\n* text eol=lf\n") + for prefix, attributesPath := range map[string]string{ + ".agents/skills/": ".agents/skills/.gitattributes", + ".claude/skills/": ".claude/skills/.gitattributes", + ".cursor/": ".cursor/.gitattributes", + ".gemini/skills/": ".gemini/skills/.gitattributes", + ".github/PULL_REQUEST_TEMPLATE/": ".github/PULL_REQUEST_TEMPLATE/.gitattributes", + ".product-loop/": ".product-loop/.gitattributes", + } { + for path := range files { + if strings.HasPrefix(path, prefix) { + files[attributesPath] = lineEndingAttributes + break + } + } + } + hashes := map[string]string{} for path, value := range files { hashes[path] = SHA256Bytes(value) diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 735bf18a..53f38234 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -113,6 +113,12 @@ func TestExportAndDriftCheck(t *testing.T) { ".agents/skills/boatstack/SKILL.md", ".agents/skills/boatstack-run/SKILL.md", ".agents/skills/auto-plan/SKILL.md", + ".agents/skills/.gitattributes", + ".claude/skills/.gitattributes", + ".cursor/.gitattributes", + ".gemini/skills/.gitattributes", + ".github/PULL_REQUEST_TEMPLATE/.gitattributes", + ".product-loop/.gitattributes", ".product-loop/.gitignore", ".product-loop/boatstack", ".product-loop/boatstack.ps1", diff --git a/boatstack/init.go b/boatstack/init.go index da5f7c76..8720af0d 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -471,7 +471,7 @@ func RunInit(options InitOptions) (returnErr error) { return recoverErr } } - bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") + bundle, err := BuildExportBundle(configPath, config, embeddedConfigBytes(rawConfig), "boatstack") if err != nil { return err } @@ -888,7 +888,7 @@ func RunUpdate(options InitOptions) error { } func verifyInstalledUpdatePostcondition(repo, configPath string, config ProjectConfig, rawConfig []byte, expectedIntegrations map[string]IntegrationState) error { - bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") + bundle, err := BuildExportBundle(configPath, config, embeddedConfigBytes(rawConfig), "boatstack") if err != nil { return err } diff --git a/boatstack/launcher_test.go b/boatstack/launcher_test.go index 376c3a5e..f3b5d498 100644 --- a/boatstack/launcher_test.go +++ b/boatstack/launcher_test.go @@ -24,6 +24,9 @@ func buildLauncherTestHelper(t *testing.T) string { func launcherTestRepository(t *testing.T) (string, string) { t.Helper() repo := planningRepo(t) + // Linked worktrees must preserve generated bytes even when a Windows-style + // checkout policy would otherwise rewrite every LF to CRLF. + runGit(t, repo, "config", "core.autocrlf", "true") if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module launcher-fixture\n"), 0o644); err != nil { t.Fatal(err) } diff --git a/boatstack/planning.go b/boatstack/planning.go index 42f7ea9b..90ebe67f 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -382,7 +382,11 @@ func CheckInstallationHealth(repoPath string) error { if err != nil { return fmt.Errorf("invalid or missing .boatstack-project.json: %w", err) } - bundle, err := BuildExportBundle(configPath, config, raw, "boatstack") + configBytes := raw + if ctx.Mode == SupervisionEmbedded { + configBytes = embeddedConfigBytes(raw) + } + bundle, err := BuildExportBundle(configPath, config, configBytes, "boatstack") if err != nil { return err } From 39ea5d8c2c8991560014bfd04eb9af98eeb5dde4 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 9 Aug 2026 19:35:51 +0100 Subject: [PATCH 4/4] canonicalize generated markdown line endings --- boatstack/export_test.go | 8 ++++++++ boatstack/runtime.go | 2 ++ 2 files changed, 10 insertions(+) diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 53f38234..d770e989 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -524,6 +524,14 @@ func TestExportAndDriftCheck(t *testing.T) { } } +func TestGeneratedMarkdownIsIndependentOfBuilderLineEndings(t *testing.T) { + want := GeneratedMarkdown("# Contract\n\nStable bytes.\n") + got := GeneratedMarkdown("# Contract\r\n\r\nStable bytes.\r\n") + if string(got) != string(want) { + t.Fatalf("generated Markdown retained builder line endings:\nwant %q\n got %q", want, got) + } +} + func TestExportPreservesOptionalChangelogPolicy(t *testing.T) { config := testConfig() config.Workflow.MaintainChangelog = true diff --git a/boatstack/runtime.go b/boatstack/runtime.go index c4c6286e..c5bd2e17 100644 --- a/boatstack/runtime.go +++ b/boatstack/runtime.go @@ -234,6 +234,8 @@ func GeneratedJSON(value any) ([]byte, error) { } func GeneratedMarkdown(body string) []byte { + body = strings.ReplaceAll(body, "\r\n", "\n") + body = strings.ReplaceAll(body, "\r", "\n") return []byte(fmt.Sprintf("\n\n%s\n", Marker, strings.TrimSpace(body))) }