diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index 59db4b6a..d10bbc1d 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -153,6 +153,59 @@ def test_attach_leaves_repository_pristine_and_state_external(self) -> None: status = self.helper_json("detached-status", "--repo", ".") self.assertTrue(status["attached"] and status["verified"]) + def test_bootstrap_oracle_is_credential_free_and_executes_exact_output(self) -> None: + attached = self.helper_json("attach", "--repo", ".", "--mode", "detached") + source_plan = self.repo / "request.md" + source_plan.write_text("# Source plan\n") + document = "# Synthetic artifact\n\n`rm -rf /` is inert documentation.\n" + + prescription = json.loads( + self.run_helper( + "flow", + "bootstrap", + "--repo", + ".", + "--feature", + "detached-bootstrap", + "--source-plan", + "request.md", + "--artifact", + "source-plan.md", + "--shell", + "posix", + "--json", + stdin=document, + ).stdout + ) + self.assertEqual(prescription["verification_status"], "VERIFIED") + self.assertEqual(prescription["supervision_mode"], "detached") + self.assertTrue(Path(prescription["helper_path"]).is_absolute()) + self.assertNotIn(".product-loop/boatstack planning-write", prescription["planning_envelope"]) + + events = { + "cursor": {"hook_event_name": "beforeShellExecution", "command": prescription["planning_envelope"]}, + "claude": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": prescription["planning_envelope"]}}, + "codex": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": prescription["planning_envelope"]}}, + "gemini": {"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": {"command": prescription["planning_envelope"]}}, + } + for host, event in events.items(): + admitted = self.run_helper( + "ambient-safety-hook", "--host", host, "--repo", ".", stdin=json.dumps(event) + ) + self.assertNotIn("deny", admitted.stdout.lower(), host) + + executed = subprocess.run( + ["bash", "-c", prescription["planning_envelope"]], + cwd=self.repo, + env=self._env(), + text=True, + capture_output=True, + ) + self.assertEqual(executed.returncode, 0, executed.stdout + executed.stderr) + artifact = Path(attached["control_root"]) / ".product-loop" / "features" / "detached-bootstrap" / "source-plan.md" + self.assertEqual(artifact.read_text(), document) + self.assert_repo_uncontaminated() + def test_activate_installs_guard_preserving_user_hooks(self) -> None: self.run_helper("attach", "--repo", ".", "--mode", "detached") diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 1ada1dea..b9b335be 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -127,15 +127,56 @@ def anchors(document: Path) -> set[str]: self.assertIn(" None: - paths = [ - REPO / "docs" / "account-recovery-walkthrough.md", - RUNTIME / "testdata" / "reviewer-pr-body.md", - ] - for path in paths: - value = path.read_text() - for private in ("Tax" + "Weave", "/Users/", "bigboateng", "cursor_password_reset_button_addition"): + private_values = ( + "Tax" + "Weave", + "/" + "Users/apple/Documents/GitHub/" + "tax" + "weave", + "big" + "boateng", + "cursor_password_" + "reset_button_addition", + ) + text_suffixes = {".go", ".json", ".md", ".ps1", ".py", ".sh", ".yaml", ".yml"} + for path in REPO.rglob("*"): + if not path.is_file() or ".git" in path.parts or path.suffix not in text_suffixes: + continue + value = path.read_text(errors="replace") + for private in private_values: self.assertNotIn(private, value, path) + def test_executable_documentation_examples_use_registered_cli(self) -> None: + documents = [ + REPO / "README.md", + REPO / "boatstack" / "SKILL.md", + *sorted((REPO / "docs").glob("*.md")), + *sorted((REPO / "boatstack" / "references").glob("*.md")), + ] + command_pattern = re.compile( + r"(?:\.product-loop/boatstack(?:\.ps1)?|boatstack-helper)\s+" + r"(?P[a-z][a-z0-9-]*)(?:\s+(?P[a-z][a-z0-9-]*))?" + ) + nested = {"flow", "insight", "retro"} + checked: dict[tuple[str, ...], str] = {} + for document in documents: + value = document.read_text().replace("\\\n", " ") + for match in command_pattern.finditer(value): + verb = match.group("verb") + subcommand = match.group("subcommand") if verb in nested else None + command = (verb, subcommand) if subcommand else (verb,) + line_end = value.find("\n", match.end()) + if line_end < 0: + line_end = len(value) + segment = value[match.start():line_end] + flags = set(re.findall(r"--([a-z][a-z0-9-]*)", segment)) + if command not in checked: + help_result = self.run_helper(*command, "--help", expected=2) + checked[command] = help_result.stdout + help_result.stderr + help_text = checked[command] + self.assertIn(f"Usage of {' '.join(command)}:", help_text, document) + for flag in flags: + self.assertRegex(help_text, rf"(?m)^ -{re.escape(flag)}(?:\s|$)", document) + + public_guidance = "\n".join(document.read_text() for document in documents) + self.assertNotIn(".product-loop/boatstack planning-write", public_guidance) + self.assertNotIn(".product-loop\\boatstack.ps1' planning-write", public_guidance) + def test_export_and_drift_contract(self) -> None: with tempfile.TemporaryDirectory() as temp: target = Path(temp) diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 64bac9a5..d90ad3c2 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -119,7 +119,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/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. +15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. For every artifact, send the selected feature, durable in-repo source plan, artifact name, target shell, and complete Markdown to the read-only `flow bootstrap` oracle through the current Boatstack operation entrypoint. Execute only its returned `planning_envelope`, unchanged. The oracle is the sole authority for embedded or detached helper identity, source-plan freshness, and literal shell rendering. Re-resolve it after `workspace-cut` from `destination_repository`. Never construct a `planning-write` command, split either literal 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 the current Boatstack entrypoint's read-only `check-plan --plan /plan.md`. 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`. diff --git a/boatstack/bootstrap.go b/boatstack/bootstrap.go new file mode 100644 index 00000000..9e38fa12 --- /dev/null +++ b/boatstack/bootstrap.go @@ -0,0 +1,232 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const bootstrapPrescriptionSchemaVersion = 1 + +type BootstrapShell string + +const ( + BootstrapShellPOSIX BootstrapShell = "posix" + BootstrapShellPowerShell BootstrapShell = "powershell" +) + +type BootstrapOptions struct { + Repo string + Feature string + SourcePlan string + Artifact string + Shell BootstrapShell + Document []byte +} + +// BootstrapPrescription is the read-only, mode-aware answer for the first +// managed planning write. It binds creation intent, source-plan freshness, the +// selected worktree, and the exact helper into one literal shell envelope. +// control-law: bootstrap-command-authority-is-workspace-bound +type BootstrapPrescription struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + Disposition string `json:"disposition"` + SupervisionMode SupervisionMode `json:"supervision_mode"` + Repository string `json:"repository"` + RepositoryID string `json:"repository_id,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + ControllerRoot string `json:"controller_root"` + HelperPath string `json:"helper_path"` + Feature string `json:"feature"` + SourcePlan string `json:"source_plan"` + SourcePlanSHA256 string `json:"source_plan_sha256"` + Artifact string `json:"artifact"` + ArtifactPath string `json:"artifact_path"` + DocumentSHA256 string `json:"document_sha256"` + Shell BootstrapShell `json:"shell"` + Argv []string `json:"argv"` + PlanningEnvelope string `json:"planning_envelope"` +} + +func normalizedPlanningDocument(document []byte) ([]byte, error) { + document = normalizePlanningTransportBytes(document) + if reason := validPlanningBody(string(document)); reason != "" { + return nil, fmt.Errorf("planning document is invalid: %s", reason) + } + value := append([]byte(nil), document...) + if len(value) == 0 || value[len(value)-1] != '\n' { + value = append(value, '\n') + } + return value, nil +} + +func bootstrapFeatureDisposition(repo string, workspace WorkspaceContext, feature string) (string, error) { + directory := workspace.FeatureDir(feature) + info, err := os.Lstat(directory) + if os.IsNotExist(err) { + return "CREATE_CANDIDATE", nil + } + if err != nil { + return "", err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) + } + statePath, stateErr := deliveryStatePath(repo, feature) + if stateErr != nil { + return "", stateErr + } + for _, path := range []string{ + statePath, + filepath.Join(directory, "plan.lock.json"), + filepath.Join(directory, "pr.md"), + filepath.Join(directory, "approval.md"), + filepath.Join(directory, "autonomy.md"), + } { + if fileExists(path) { + return "", fmt.Errorf("feature %s already carries managed authority; use flow next --feature %s", feature, feature) + } + } + entries, err := os.ReadDir(directory) + if err != nil { + return "", err + } + for _, entry := range entries { + if entry.IsDir() || !planningArtifacts[entry.Name()] { + return "", fmt.Errorf("feature %s has conflicting planning state; run recovery-status before bootstrapping", feature) + } + } + if fileExists(filepath.Join(directory, "plan.md")) { + if _, err := CheckPlan(filepath.Join(directory, "plan.md")); err != nil { + return "", fmt.Errorf("feature %s has an invalid saved plan; run recovery-status before bootstrapping: %w", feature, err) + } + } + return "RESUME_CANDIDATE", nil +} + +func bootstrapProgram(workspace WorkspaceContext, shell BootstrapShell) string { + if workspace.Mode == SupervisionDetached { + return workspace.HelperPath() + } + return workspace.LauncherPath(shell == BootstrapShellPowerShell) +} + +func planningArgv(program, repo, feature, artifact, sourcePlan, sourceSHA string) []string { + return []string{ + program, "planning-write", + "--repo", repo, + "--feature", feature, + "--artifact", artifact, + "--source-plan", sourcePlan, + "--source-plan-sha256", sourceSHA, + } +} + +func posixPlanningEnvelopeFor(argv []string, document []byte) string { + words := make([]string, len(argv)) + for index, word := range argv { + words[index] = posixPlanningWord(word) + } + delimiter := "BOATSTACK_PLAN_" + strings.ToUpper(SHA256Bytes(document)[:16]) + return strings.Join(words, " ") + " <<'" + delimiter + "'\n" + string(document) + delimiter + "\n" +} + +func powerShellPlanningWord(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func powerShellPlanningEnvelopeFor(argv []string, document []byte) (string, error) { + for _, line := range strings.Split(strings.ReplaceAll(string(document), "\r\n", "\n"), "\n") { + if strings.HasPrefix(line, "'@") { + return "", fmt.Errorf("PowerShell cannot represent a document line beginning with '@; use --shell posix with Git Bash") + } + } + words := make([]string, len(argv)) + for index, word := range argv { + if strings.Contains(word, "'") { + return "", fmt.Errorf("PowerShell cannot safely bind an argument containing a single quote; use --shell posix with Git Bash") + } + words[index] = powerShellPlanningWord(word) + } + return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + string(document) + "'@ | & " + strings.Join(words, " ") + "\n" + powerShellPlanningExitLine + "\n}\n", nil +} + +// ResolvePlanningBootstrap is pure with respect to repository and controller +// state: it validates current evidence and returns bytes to execute, but writes +// nothing. The later planning-write rechecks the source-plan digest before its +// atomic first write. +// control-law: bootstrap-command-authority-is-workspace-bound +func ResolvePlanningBootstrap(options BootstrapOptions) (BootstrapPrescription, error) { + if !featureSlugPattern.MatchString(options.Feature) { + return BootstrapPrescription{}, fmt.Errorf("feature must be a lowercase kebab-case slug") + } + if !planningArtifacts[options.Artifact] { + return BootstrapPrescription{}, fmt.Errorf("unsupported planning artifact %q; use one of: %s", options.Artifact, strings.Join(planningArtifactNames(), ", ")) + } + if options.Shell != BootstrapShellPOSIX && options.Shell != BootstrapShellPowerShell { + return BootstrapPrescription{}, fmt.Errorf("shell must be posix or powershell") + } + document, err := normalizedPlanningDocument(options.Document) + if err != nil { + return BootstrapPrescription{}, err + } + repo, err := ResolveRepository(options.Repo) + if err != nil { + return BootstrapPrescription{}, err + } + if err := CheckInstallationHealth(repo); err != nil { + return BootstrapPrescription{}, fmt.Errorf("bootstrap requires a healthy Boatstack installation: %w", DoctorRepairHint(err)) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + return BootstrapPrescription{}, err + } + sourcePlan, err := DiscoverSourcePlan(repo, options.SourcePlan) + if err != nil { + return BootstrapPrescription{}, err + } + sourceAbsolute := filepath.Join(repo, filepath.FromSlash(sourcePlan)) + if err := rejectSymlinkComponents(repo, sourceAbsolute); err != nil { + return BootstrapPrescription{}, fmt.Errorf("source plan must be a regular in-repository file without symlink indirection: %w", err) + } + if pathWithin(workspace.FeatureDir(options.Feature), sourceAbsolute) { + return BootstrapPrescription{}, fmt.Errorf("source plan must remain outside the generated feature package") + } + sourceSHA, err := SHA256File(sourceAbsolute) + if err != nil { + return BootstrapPrescription{}, err + } + disposition, err := bootstrapFeatureDisposition(repo, workspace, options.Feature) + if err != nil { + return BootstrapPrescription{}, err + } + program := bootstrapProgram(workspace, options.Shell) + argv := planningArgv(program, repo, options.Feature, options.Artifact, sourcePlan, sourceSHA) + if options.Shell == BootstrapShellPOSIX { + // Git Bash accepts Windows drive paths in slash form. Keep the typed argv + // identical to the bytes rendered for that shell. + argv[0] = filepath.ToSlash(argv[0]) + argv[3] = filepath.ToSlash(argv[3]) + } + var envelope string + if options.Shell == BootstrapShellPowerShell { + envelope, err = powerShellPlanningEnvelopeFor(argv, document) + } else { + envelope = posixPlanningEnvelopeFor(argv, document) + } + if err != nil { + return BootstrapPrescription{}, err + } + return BootstrapPrescription{ + SchemaVersion: bootstrapPrescriptionSchemaVersion, VerificationStatus: "VERIFIED", + Disposition: disposition, SupervisionMode: workspace.Mode, + Repository: repo, RepositoryID: workspace.RepoID, WorktreeID: workspace.WorktreeID, + ControllerRoot: workspace.ExportRoot(), HelperPath: program, + Feature: options.Feature, SourcePlan: sourcePlan, SourcePlanSHA256: sourceSHA, + Artifact: options.Artifact, ArtifactPath: filepath.Join(workspace.FeatureDir(options.Feature), options.Artifact), + DocumentSHA256: SHA256Bytes(document), Shell: options.Shell, Argv: argv, + PlanningEnvelope: envelope, + }, nil +} diff --git a/boatstack/bootstrap_conformance_test.go b/boatstack/bootstrap_conformance_test.go new file mode 100644 index 00000000..f88bfe82 --- /dev/null +++ b/boatstack/bootstrap_conformance_test.go @@ -0,0 +1,404 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func bootstrapTestShell() BootstrapShell { + if runtime.GOOS == "windows" { + return BootstrapShellPowerShell + } + return BootstrapShellPOSIX +} + +func writeBootstrapSourcePlan(t *testing.T, repo string) string { + t.Helper() + path := filepath.Join(repo, "docs", "source plan.md") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("# Source plan\n\nBuild the bounded feature.\n"), 0o644); err != nil { + t.Fatal(err) + } + return filepath.ToSlash(filepath.Join("docs", "source plan.md")) +} + +func executePlanningEnvelopeOutput(repo, command string) ([]byte, error) { + if runtime.GOOS == "windows" { + powershell, err := exec.LookPath("powershell") + if err != nil { + return nil, err + } + process := exec.Command(powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command) + process.Dir = repo + return process.CombinedOutput() + } + process := exec.Command("bash", "-c", command) + process.Dir = repo + return process.CombinedOutput() +} + +func executePlanningEnvelopeWith(repo, executable string, shell BootstrapShell, command string) ([]byte, error) { + var process *exec.Cmd + if shell == BootstrapShellPowerShell { + process = exec.Command(executable, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command) + } else { + process = exec.Command(executable, "-c", command) + } + process.Dir = repo + return process.CombinedOutput() +} + +func bootstrapInputEnvelope(t *testing.T, workspace WorkspaceContext, repo, feature, sourcePlan, artifact string, shell BootstrapShell, body []byte) string { + t.Helper() + program := bootstrapProgram(workspace, shell) + argv := []string{ + program, "flow", "bootstrap", "--repo", repo, "--feature", feature, + "--source-plan", sourcePlan, "--artifact", artifact, "--shell", string(shell), + } + if shell == BootstrapShellPowerShell { + envelope, err := powerShellPlanningEnvelopeFor(argv, body) + if err != nil { + t.Fatal(err) + } + return envelope + } + return posixPlanningEnvelopeFor(argv, body) +} + +// Positive, relation, and bypass conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. The exact stdin envelope +// admitted by every host runs the real bootstrap CLI; its exact stdout envelope +// is admitted again, runs in a real shell, and creates the intended artifact. +func TestBootstrapOracleRunsInputHookRendererOutputHookShellHelper(t *testing.T) { + repo := safetyTestRepo(t) + installPlanningTransportFixture(t, repo) + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + sourcePlan := writeBootstrapSourcePlan(t, repo) + body := []byte("# Synthetic plan\n\nCommands such as `rm -rf /` and `git reset --hard` are inert text.\n") + shell := bootstrapTestShell() + bootstrapCommand := bootstrapInputEnvelope(t, workspace, repo, "bootstrap-oracle", sourcePlan, "source-plan.md", shell, body) + + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, bootstrapCommand)}); denied { + t.Fatalf("%s denied the bootstrap input envelope: %s", host, output) + } + } + rendered, err := executePlanningEnvelopeOutput(repo, bootstrapCommand) + if err != nil { + t.Fatalf("execute bootstrap renderer: %v: %s", err, rendered) + } + planningEnvelope := string(rendered) + transport := inspectPlanningWriteTransport(planningEnvelope) + if !transport.Matched || transport.InvalidReason != "" || !strings.Contains(transport.Header, "--source-plan-sha256") || planningTransportBinding(repo, transport) != "" || string(transport.Content) != string(body) { + t.Fatalf("bootstrap output lost source evidence or workspace launcher: %s", planningEnvelope) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, planningEnvelope)}); denied { + t.Fatalf("%s denied the rendered planning envelope: %s", host, output) + } + } + if output, err := executePlanningEnvelopeOutput(repo, planningEnvelope); err != nil { + t.Fatalf("execute rendered planning envelope: %v: %s", err, output) + } + written, err := os.ReadFile(filepath.Join(workspace.FeatureDir("bootstrap-oracle"), "source-plan.md")) + if err != nil || string(written) != string(body) { + t.Fatalf("bootstrap artifact mismatch: %v %q", err, written) + } +} + +func TestBootstrapNormalizesPowerShellTransportEncoding(t *testing.T) { + document := append([]byte{0xef, 0xbb, 0xbf}, []byte("# Plan\r\n\r\nExact bytes.\r\n")...) + normalized, err := normalizedPlanningDocument(document) + if err != nil { + t.Fatal(err) + } + if string(normalized) != "# Plan\n\nExact bytes.\n" { + t.Fatalf("PowerShell transport encoding reached the prescription: %q", normalized) + } + + envelope, err := powerShellPlanningEnvelopeFor([]string{ + `.product-loop\boatstack.ps1`, "planning-write", "--repo", ".", "--feature", "transport-encoding", "--artifact", "plan.md", + }, []byte("# Plan\r\n")) + if err != nil { + t.Fatal(err) + } + inspection := inspectPlanningWriteTransport(envelope) + if !inspection.Matched || inspection.InvalidReason != "" || string(inspection.Content) != "# Plan\n" { + t.Fatalf("PowerShell transport parser did not return canonical Markdown: %+v", inspection) + } +} + +// Positive and relation conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. Detached mode must render the +// external bound helper and keep every controller byte outside the product repo. +func TestDetachedBootstrapOracleUsesExternalHelperAcrossHosts(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/bootstrap-oracle.git") + t.Setenv(stateRootEnv, filepath.Join(t.TempDir(), "Application Support")) + invalidateWorkspaceCache() + sourcePlan := writeBootstrapSourcePlan(t, repo) + source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName())) + result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: source}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach detached fixture: %+v %v", result, err) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + body := []byte("# Detached bootstrap\n\nThe controller stays external.\n") + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: "detached-bootstrap", SourcePlan: sourcePlan, + Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: body, + }) + if err != nil { + t.Fatal(err) + } + if prescription.Disposition != "CREATE_CANDIDATE" || prescription.HelperPath != workspace.HelperPath() || !strings.Contains(prescription.HelperPath, "Application Support") { + t.Fatalf("detached prescription is not bound to the external workspace: %+v", prescription) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, prescription.PlanningEnvelope)}); denied { + t.Fatalf("%s denied detached bootstrap output: %s", host, output) + } + } + if output, err := executePlanningEnvelopeOutput(repo, prescription.PlanningEnvelope); err != nil { + t.Fatalf("execute detached bootstrap output: %v: %s", err, output) + } + if _, err := os.Stat(filepath.Join(workspace.FeatureDir("detached-bootstrap"), "source-plan.md")); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repo, productLoopDirName)); !os.IsNotExist(err) { + t.Fatal("detached bootstrap leaked controller state into the product repository") + } +} + +// Negative and failure-state conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. A raw first write and stale +// source evidence both fail without creating managed feature state. +func TestBootstrapFirstWriteRequiresFreshSourceEvidence(t *testing.T) { + repo := safetyTestRepo(t) + installPlanningTransportFixture(t, repo) + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + if _, err := WritePlanningArtifact(PlanningWriteOptions{ + Repo: repo, Feature: "raw-first-write", Artifact: "source-plan.md", Content: []byte("# Raw\n"), + }); err == nil || !strings.Contains(err.Error(), "flow bootstrap") { + t.Fatalf("raw first write did not require bootstrap evidence: %v", err) + } + if _, err := os.Stat(workspace.FeatureDir("raw-first-write")); !os.IsNotExist(err) { + t.Fatal("rejected raw first write created feature state") + } + + sourcePlan := writeBootstrapSourcePlan(t, repo) + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: "stale-source", SourcePlan: sourcePlan, + Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: []byte("# Candidate\n"), + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(sourcePlan)), []byte("# Changed\n"), 0o644); err != nil { + t.Fatal(err) + } + output, runErr := executePlanningEnvelopeOutput(repo, prescription.PlanningEnvelope) + if runErr == nil || !strings.Contains(string(output), "source plan changed after bootstrap") { + t.Fatalf("stale source evidence was not rejected: %v %s", runErr, output) + } + if _, err := os.Stat(workspace.FeatureDir("stale-source")); !os.IsNotExist(err) { + t.Fatal("stale prescription created feature state") + } +} + +// Negative, bypass, and failure-state conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. +func TestBootstrapOracleRejectsInvalidIdentityStateAndShell(t *testing.T) { + repo := safetyTestRepo(t) + installPlanningTransportFixture(t, repo) + sourcePlan := writeBootstrapSourcePlan(t, repo) + base := BootstrapOptions{ + Repo: repo, Feature: "safe-bootstrap", SourcePlan: sourcePlan, + Artifact: "source-plan.md", Shell: bootstrapTestShell(), Document: []byte("# Candidate\n"), + } + tests := []struct { + name string + mutate func(*BootstrapOptions) + }{ + {"invalid slug", func(o *BootstrapOptions) { o.Feature = "Wrong Slug" }}, + {"unknown artifact", func(o *BootstrapOptions) { o.Artifact = "state.json" }}, + {"unknown shell", func(o *BootstrapOptions) { o.Shell = "cmd" }}, + {"missing source", func(o *BootstrapOptions) { o.SourcePlan = "docs/missing.md" }}, + {"powershell collision", func(o *BootstrapOptions) { + o.Shell = BootstrapShellPowerShell + o.Document = []byte("# Body\n'@ collision\n") + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + options := base + test.mutate(&options) + if _, err := ResolvePlanningBootstrap(options); err == nil { + t.Fatal("invalid bootstrap input was accepted") + } + }) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(workspace.FeatureDir(base.Feature), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace.FeatureDir(base.Feature), "plan.lock.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ResolvePlanningBootstrap(base); err == nil || !strings.Contains(err.Error(), "managed authority") { + t.Fatalf("managed feature was allowed back into bootstrap: %v", err) + } +} + +func TestFlowBootstrapJSONMatchesCanonicalPrescription(t *testing.T) { + repo := safetyTestRepo(t) + installPlanningTransportFixture(t, repo) + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + sourcePlan := writeBootstrapSourcePlan(t, repo) + body := []byte("# JSON prescription\n") + command := exec.Command(workspace.HelperPath(), "flow", "bootstrap", "--repo", repo, "--feature", "json-bootstrap", "--source-plan", sourcePlan, "--artifact", "source-plan.md", "--shell", string(bootstrapTestShell()), "--json") + command.Dir = repo + command.Stdin = strings.NewReader(string(body)) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("flow bootstrap --json: %v: %s", err, output) + } + var prescription BootstrapPrescription + if err := json.Unmarshal(output, &prescription); err != nil { + t.Fatal(err) + } + if prescription.VerificationStatus != "VERIFIED" || prescription.Feature != "json-bootstrap" || prescription.SourcePlanSHA256 == "" || len(prescription.Argv) == 0 || prescription.PlanningEnvelope == "" { + t.Fatalf("incomplete bootstrap JSON: %+v", prescription) + } +} + +// Relation and bypass conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. Generated host instructions +// may name the oracle, but may never reconstruct a mutation helper path. +func TestGeneratedPlanningInstructionsUseBootstrapOracle(t *testing.T) { + config := testConfig() + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack") + if err != nil { + t.Fatal(err) + } + paths := []string{ + ".product-loop/workflow.md", + ".cursor/commands/auto-plan.md", + ".claude/skills/auto-plan/SKILL.md", + ".agents/skills/auto-plan/SKILL.md", + ".gemini/skills/auto-plan/SKILL.md", + } + for _, path := range paths { + content := string(bundle.Files[path]) + if !strings.Contains(content, "flow bootstrap") || !strings.Contains(content, "planning_envelope") { + t.Fatalf("%s does not consume the canonical bootstrap oracle", path) + } + for _, forbidden := range []string{ + ".product-loop/boatstack planning-write", + `.product-loop\boatstack.ps1' planning-write`, + ".product-loop/bin/boatstack-helper planning-write", + } { + if strings.Contains(content, forbidden) { + t.Fatalf("%s reconstructs a planning mutation command: %q", path, forbidden) + } + } + } +} + +func TestBootstrapRendererQuotesLiteralPathsForBothShells(t *testing.T) { + body := []byte("# Quoted paths\n") + argv := []string{ + filepath.Join(string(filepath.Separator), "Application Support", "Boat's helper", helperName()), + "planning-write", "--repo", filepath.Join(string(filepath.Separator), "work trees", "consumer's repo"), + "--feature", "quoted-bootstrap", "--artifact", "plan.md", + "--source-plan", "docs/operator's plan.md", "--source-plan-sha256", strings.Repeat("a", 64), + } + posix := inspectPlanningWriteTransport(posixPlanningEnvelopeFor(argv, body)) + if !posix.Matched || posix.InvalidReason != "" || posix.Executable != argv[0] || posix.Repository != argv[3] { + t.Fatalf("POSIX literal quoting drifted: %+v", posix) + } + powerShellArgv := append([]string(nil), argv...) + powerShellArgv[0] = filepath.Join(string(filepath.Separator), "Application Support", "Boat helper", helperName()) + powerShellArgv[3] = filepath.Join(string(filepath.Separator), "work trees", "consumer repo") + powerShellArgv[9] = "docs/operator plan.md" + powerShellEnvelope, err := powerShellPlanningEnvelopeFor(powerShellArgv, body) + if err != nil { + t.Fatal(err) + } + powerShell := inspectPlanningWriteTransport(powerShellEnvelope) + if !powerShell.Matched || powerShell.InvalidReason != "" || powerShell.Executable != powerShellArgv[0] || powerShell.Repository != powerShellArgv[3] { + t.Fatalf("PowerShell literal quoting drifted: %+v", powerShell) + } + if _, err := powerShellPlanningEnvelopeFor(argv, body); err == nil { + t.Fatal("PowerShell renderer accepted an ambiguously quoted path") + } +} + +// Positive platform conformance for control-law: +// bootstrap-command-authority-is-workspace-bound. Required CI executes the +// canonical output in Bash on Unix, zsh on macOS, and both PowerShell and Git +// Bash on Windows without a provider API or installed coding host. +func TestBootstrapEnvelopeExecutesInRequiredRealShells(t *testing.T) { + repo := safetyTestRepo(t) + installPlanningTransportFixture(t, repo) + sourcePlan := writeBootstrapSourcePlan(t, repo) + type shellCase struct { + name string + executable string + shell BootstrapShell + } + cases := []shellCase{{name: "bash", executable: "bash", shell: BootstrapShellPOSIX}} + if runtime.GOOS == "windows" { + cases = append(cases, shellCase{name: "powershell", executable: "powershell", shell: BootstrapShellPowerShell}) + } + if runtime.GOOS == "darwin" { + cases = append(cases, shellCase{name: "zsh", executable: "zsh", shell: BootstrapShellPOSIX}) + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + if _, err := exec.LookPath(test.executable); err != nil { + t.Fatalf("required shell %s is unavailable: %v", test.executable, err) + } + feature := "real-shell-" + test.name + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: feature, SourcePlan: sourcePlan, + Artifact: "source-plan.md", Shell: test.shell, Document: []byte("# Real shell\n"), + }) + if err != nil { + t.Fatal(err) + } + if output, err := executePlanningEnvelopeWith(repo, test.executable, test.shell, prescription.PlanningEnvelope); err != nil { + t.Fatalf("%s execution failed: %v: %s", test.name, err, output) + } + artifact, err := os.ReadFile(filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "source-plan.md")) + if err != nil || string(artifact) != "# Real shell\n" { + t.Fatalf("%s did not preserve exact artifact bytes: %v %q", test.name, err, artifact) + } + }) + } +} diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go index 43e5e3d5..bc9e78d1 100644 --- a/boatstack/cmd/boatstack-helper/flow.go +++ b/boatstack/cmd/boatstack-helper/flow.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io" "os" "time" @@ -15,10 +16,12 @@ import ( // gate, authority, or exit code. func flowCommand(arguments []string) int { if len(arguments) == 0 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") return 2 } switch arguments[0] { + case "bootstrap": + return flowBootstrapCommand(arguments[1:]) case "check": return flowCheckCommand(arguments[1:]) case "next": @@ -37,6 +40,46 @@ func flowCommand(arguments []string) int { } } +// flowBootstrapCommand renders the exact literal planning envelope for one +// workspace. It reads the proposed Markdown from stdin and changes no state. +// control-law: bootstrap-command-authority-is-workspace-bound +func flowBootstrapCommand(arguments []string) int { + flags := flag.NewFlagSet("flow bootstrap", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose planning workspace should be resolved") + feature := flags.String("feature", "", "new or saved planning feature slug") + sourcePlan := flags.String("source-plan", "", "required in-repo source plan") + artifact := flags.String("artifact", "", "planning artifact to write") + shell := flags.String("shell", "", "target shell: posix or powershell") + jsonOutput := flags.Bool("json", false, "print the structured bootstrap prescription") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *feature == "" || *sourcePlan == "" || *artifact == "" || *shell == "" { + return fail(fmt.Errorf("flow bootstrap requires --feature, --source-plan, --artifact, and --shell posix|powershell; Markdown is read from stdin")) + } + document, err := io.ReadAll(os.Stdin) + if err != nil { + return fail(err) + } + prescription, err := boatstack.ResolvePlanningBootstrap(boatstack.BootstrapOptions{ + Repo: *repo, Feature: *feature, SourcePlan: *sourcePlan, Artifact: *artifact, + Shell: boatstack.BootstrapShell(*shell), Document: document, + }) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(prescription) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(prescription.PlanningEnvelope) + } + return 0 +} + // flowCheckCommand runs the static conformance + liveness gate over the delivery // model and exits non-zero on drift. It reads no repository state. func flowCheckCommand(arguments []string) int { diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index b0ca929e..67d1b3f9 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -540,6 +540,8 @@ func planningWriteCommand(arguments []string) int { repo := flags.String("repo", ".", "repository containing the feature package") feature := flags.String("feature", "", "lowercase kebab-case feature slug") artifact := flags.String("artifact", "", "known Markdown planning artifact name") + sourcePlan := flags.String("source-plan", "", "in-repo source plan bound by flow bootstrap") + sourcePlanSHA256 := flags.String("source-plan-sha256", "", "source-plan digest bound by flow bootstrap") if err := flags.Parse(arguments); err != nil { return 2 } @@ -552,6 +554,7 @@ func planningWriteCommand(arguments []string) int { } path, err := boatstack.WritePlanningArtifact(boatstack.PlanningWriteOptions{ Repo: *repo, Feature: *feature, Artifact: *artifact, Content: content, + SourcePlan: *sourcePlan, SourcePlanSHA256: *sourcePlanSHA256, }) if err != nil { return fail(err) diff --git a/boatstack/denial.go b/boatstack/denial.go index b86184a1..3ad1a84c 100644 --- a/boatstack/denial.go +++ b/boatstack/denial.go @@ -476,7 +476,7 @@ func denialFor(host string, finding SafetyFinding) Denial { case "planning-transport-invalid": d.Severity = SeverityAdvisory d.Qualifier = "planning input incomplete" - d.Detail = "Boatstack did not run the planning write because its literal Markdown envelope was incomplete or ambiguous (PLANNING_TRANSPORT_INVALID:" + finding.Reason + "). Use the single-quoted heredoc or UTF-8-scoped single-quoted PowerShell here-string in `.product-loop/workflow.md` and send it in one call. Do not paste the Markdown at a shell prompt or retry a truncated command." + d.Detail = "Boatstack did not run the planning write because its literal Markdown envelope was incomplete, stale, or ambiguous (PLANNING_TRANSPORT_INVALID:" + finding.Reason + "). Resolve a fresh mode-aware `flow bootstrap` prescription and execute only its returned planning_envelope. Do not construct a helper path, paste Markdown at a shell prompt, or retry a truncated command." d.Reassurance = reassureUntouched 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/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.Detail += fmt.Sprintf(" Planning Markdown is authored through the owned channel: resolve `flow bootstrap --feature %s` with the current source plan, artifact, shell, and complete Markdown, then execute only its returned planning_envelope — 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_test.go b/boatstack/denial_test.go index f2c5517b..dffb78f0 100644 --- a/boatstack/denial_test.go +++ b/boatstack/denial_test.go @@ -37,7 +37,7 @@ func TestDenialRenderModesCarryTheSameInformation(t *testing.T) { // control-law: prescriptive-closure-every-stage-names-a-runnable-command — a // planning-state plan-gate denial names the owned authoring channel -// (planning-write), not just the cleanup verb, in every render mode. +// (flow bootstrap), not just the cleanup verb, in every render mode. func TestPlanningPhaseBypassDenialNamesOwnedChannel(t *testing.T) { finding := SafetyFinding{ Category: "workflow-phase-bypass", Source: "planning-state", @@ -50,8 +50,8 @@ func TestPlanningPhaseBypassDenialNamesOwnedChannel(t *testing.T) { if !strings.Contains(out, "repair-state") { t.Fatalf("%s denial dropped the recovery verb: %q", name, out) } - if !strings.Contains(out, "planning-write --repo . --feature sample-feature --artifact ") { - t.Fatalf("%s denial must name the owned planning-write channel: %q", name, out) + if !strings.Contains(out, "flow bootstrap --feature sample-feature") { + t.Fatalf("%s denial must name the owned bootstrap channel: %q", name, out) } } // A non-planning finding must not gain the planning guidance. diff --git a/boatstack/export.go b/boatstack/export.go index b2d5b2d1..fd67e6c4 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -263,7 +263,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 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.` + preflight = `Before reading repository context or drafting artifacts, identify the durable in-repo path of the plan produced in the active host/system conversation — the user supplies it as ` + "`/auto-plan `" + `. Boatstack does not scan directories for plans. If no path is available, stop and ask for it; do not create or guess a substitute. For every planning artifact, send its complete Markdown to the read-only ` + "`flow bootstrap --repo . --feature --source-plan --artifact --shell posix|powershell`" + ` oracle through the current Boatstack operation entrypoint. Execute only the returned ` + "`planning_envelope`" + `, unchanged. Never construct a helper path or planning-write command from documentation. Re-resolve after every workspace cut from its ` + "`destination_repository`" + `.` } return fmt.Sprintf(`# %s @@ -379,7 +379,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "insight-capture": "Treat the complete invocation argument as the exact untrusted source message. Require insights.enabled before continuing. Run the available Value Map skill as a read-only conversational projection and preserve its canonical lineage: user, current state, value gap, desired outcome, mechanism, smallest proof, evidence, unknowns, grade, and verdict. When insights.suggest_features is true, inspect only the minimal relevant product slice to suggest one primary feature topic and optional related topics; label suggestions PROPOSED and do not bind them to a delivery. When it is false, leave topics for explicit human classification. Serialize the full proposed capture, including the exact source bytes and SHA-256, then pipe those bytes to the tracked .product-loop/boatstack launcher insight check --repo . --json. Display the complete Value Map, suggested topics, unknowns, returned preview fingerprint, and a prominent warning that the exact source and Value Map will enter the repository and may become public through Git history. Respond Insight ready to save and make the one next action: Reply `s` to save this exact insight as a repository diff. Only an exact state-scoped s for the currently displayed fingerprint authorizes piping the unchanged draft to insight save with the same preview nonce and fingerprint. If any source byte, map field, topic, nonce, or fingerprint changes, check again and require a new s. Never save on the initial request, on r, or when Value Map is unavailable. After a successful save respond Insight saved as a repository diff and show its ID and repository path. Do not create a feature, plan, branch, commit, or PR; publication remains a separate explicit action.", "insight-frontier": "Run the tracked .product-loop/boatstack launcher insight frontier --repo . and present the independent captures needing classification, delivery, evidence, terminal observation, or human completion. This operation is strictly read-only: do not append events, change associations, bind deliveries, evaluate by mutation, disposition captures, or alter the authoritative delivery frontier. Respond Insight frontier ready and show one suggested pending action per capture without presenting any insight as Boatstack's single delivery next action.", "root-cause": "Perform failure-mode elimination on a bug, not a patch. This operation is strictly read-only: do not edit product code, create or update artifacts, advance a gate, or contact GitHub; the user supplies the symptom, stack trace, error log, or failing signal as the argument. Locate the failure below its surface symptom and classify it against the failure classes in @.product-loop/failure-moves.md; name the failure CLASS, not the one instance, and if no class fits, name the new class in that vocabulary. Investigate with read-only tools and produce a numbered root-cause chain in which every step is cited to file:line and which distinguishes the crashing frame (the victim) from the true origin (the cause); label authoritative repository facts DISCOVERED and any inference PROPOSED. State the blast radius: every other call site or path exposed to the same class. Propose the minimal STRUCTURAL elimination that makes the whole class unreachable and covers every exposed site, reusing an existing repository pattern or utility where one exists, rather than a local guard on the single line in the trace. Present this as a material product decision with the same tiered paths auto-plan uses under boundary_analysis: [1a] Symptom Patch or [1b] Programmatic Enforcement (a boundary that eliminates the class), and recommend one. Require a regression that reproduces the failure mode before the fix plus the project's own gates as the proof the class is gone, and name related latent hazards left out of scope as non-goals. Then format the result as a host Plan-mode source plan (symptom, root-cause chain, failure mode, blast radius, elimination, non-goals, verification, delivery base branch) and respond Root cause found, making the one next action: save this plan to a durable in-repo path and run auto-plan with it via --plan. Do not implement the fix; hand off to the plan gate.", - "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If a selected hand-authored draft cannot be verified, resolve it through repair-state, then re-author its planning Markdown through the complete literal planning-write envelope in .product-loop/workflow.md. The invalid draft never controls unrelated repository tools. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", + "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Resolve every planning write through flow bootstrap with the selected feature, source plan, artifact, and shell; execute only its returned planning_envelope and resolve again after workspace-cut. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If a selected hand-authored draft cannot be verified, resolve it through repair-state, then re-author it through a fresh flow bootstrap prescription. The invalid draft never controls unrelated repository tools. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. activate-plan promotes the compiled task graph, test matrix, evidence ledger, and the plan lock together through the transactional mutation boundary as one mutation, so all four land all-or-nothing with a reversible receipt and a failed or interrupted promote leaves the prior state unchanged rather than half-written. The boundary is closed under inversion: mutation-status lists the receipts and undo --mutation reverses a managed-artifact promotion (redo is undo of the undo receipt), with undo refusing to reverse an activation once a delivery gate would be stranded; this governs Boatstack-generated artifacts only, never source code. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "repair": "First run recovery-status --repo . with the user's exact free-form requested change, its observed source stage, bounded evidence when available, and --json. This resolver covers both active and current-branch published deliveries. On repair_active, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts; classify the request and invoke record-change before any product edit. On draft_corrective_child, invoke record-change on the published parent, preserve its lock, receipts, slices, and publication evidence, and automatically prepare the suggested one-slice child plan with parent_delivery, exact correction, inherited intent, observed failure, returned existing_diff_sha256 and existing_changed_paths, verification requirements, and the resolved PR destination. Lead with The PR needs a corrective delivery. I prepared it for your approval. Then pause at the normal fingerprinted plan approval boundary; never reuse the parent's approval. An open PR reuses its verified head branch and is updated after fresh gates and publication confirmation. A merged or closed PR uses a fresh branch and PR; when a fingerprinted correction diff already exists, leave the original worktree untouched and transfer that exact reviewed diff into the fresh child only after approval. PUBLISHED_UNKNOWN may be drafted but its destination remains blocking at publication. Stop on BLOCKED and ask one targeted feature question using the returned blockers. If no managed target exists, continue ordinary conversation. Never discard pre-existing correction edits, edit runtime state directly, or bypass test, review, and ship gates. Never ask the user to repeat a denied push or PR mutation. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", @@ -394,7 +394,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the tracked .product-loop/boatstack launcher workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } - operations["boatstack-run"] = "Resolve an explicit target and feature slug from --to plan|verified|pr, the user's wording, or the supplied plan; when the target is absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --feature --json and operation-status first and reconcile in-flight work. Carry that feature through every later status and planning call so unrelated saved drafts cannot redirect the run. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run workspace-cut when prescribed and continue from its destination_repository. Only then run record-autonomy with the selected target, so the receipt binds the final feature branch. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action." + operations["boatstack-run"] = "Resolve an explicit target and feature slug from --to plan|verified|pr, the user's wording, or the supplied plan; when the target is absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run operation-status first and reconcile in-flight work. For a new feature with a supplied source plan, resolve flow bootstrap before calling feature-scoped next-status; an absent candidate is creation intent, not stale delivery state. Execute its returned planning_envelope, then carry the feature through every later status and planning call so unrelated saved drafts cannot redirect the run. For an existing saved or active feature, run next-status --repo . --feature --json normally. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run workspace-cut when prescribed and continue from its destination_repository. Discard every earlier bootstrap prescription and resolve again there. Only then run record-autonomy with the selected target, so the receipt binds the final feature branch. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action." operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan, but first run run-preflight --repo . --health-only --json before auto-plan writes any feature artifact; planning and plan-gate do not require delivery preflight beyond this pure health check. Stop without writing when installation or generated state is unhealthy. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action." operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason." operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. When workspace-cut is prescribed, complete it and continue from destination_repository before recording approval or autonomy. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 268d012c..bd222aaf 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -312,7 +312,7 @@ func TestExportAndDriftCheck(t *testing.T) { } } } - if !strings.Contains(autoPlan, "Markdown-only") || !strings.Contains(autoPlan, "Never silently choose a default") || !strings.Contains(autoPlan, "planning-write") || !strings.Contains(autoPlan, "complete literal envelope") || !strings.Contains(autoPlan, "PROPOSED") { + if !strings.Contains(autoPlan, "Markdown-only") || !strings.Contains(autoPlan, "Never silently choose a default") || !strings.Contains(autoPlan, "flow bootstrap") || !strings.Contains(autoPlan, "planning_envelope") || !strings.Contains(autoPlan, "PROPOSED") { t.Fatal("auto-plan adapter does not enforce the Markdown and question boundaries") } // Conformance: no ambient plan context. The exported auto-plan adapter must @@ -473,8 +473,9 @@ func TestExportAndDriftCheck(t *testing.T) { workflow := string(bundle.Files[".product-loop/workflow.md"]) for _, expected := range []string{ "### Literal planning transport", - "<<'BOATSTACK_PLAN_EOF'", - "$OutputEncoding = [System.Text.UTF8Encoding]::new($false)", + "flow bootstrap", + "planning_envelope", + "Do not select, append, or rewrite an executable path yourself", "PLANNING_TRANSPORT_INVALID", "## User-facing response contract", "Exactly one primary action", diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 2e92beeb..9814ddb5 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -88,16 +88,16 @@ func CurrentFlowState(repo, feature string) (deliverycontrol.StateID, bool) { // self-describing in JSON and telemetry. // control-law: prescriptive-closure-every-stage-names-a-runnable-command const ( - MarkerPlanningInit = deliverycontrol.TransitionID("planning.init") - MarkerPlanningCheckSource = deliverycontrol.TransitionID("planning.check_source_plan") - MarkerPlanningCheckPlan = deliverycontrol.TransitionID("planning.check_plan") - MarkerPlanningActivate = deliverycontrol.TransitionID("planning.activate") - MarkerPlanningWorkspace = deliverycontrol.TransitionID("planning.workspace_cut") - MarkerPlanningWrite = deliverycontrol.TransitionID("planning.planning_write") - MarkerPlanningApproval = deliverycontrol.TransitionID("planning.record_approval") - MarkerRecoveryDoctor = deliverycontrol.TransitionID("recovery.doctor") - MarkerRecoveryDiscard = deliverycontrol.TransitionID("recovery.discard_delivery") - MarkerRecoveryRepair = deliverycontrol.TransitionID("recovery.repair_state") + MarkerPlanningInit = deliverycontrol.TransitionID("planning.init") + MarkerPlanningBootstrap = deliverycontrol.TransitionID("planning.bootstrap") + MarkerPlanningCheckPlan = deliverycontrol.TransitionID("planning.check_plan") + MarkerPlanningActivate = deliverycontrol.TransitionID("planning.activate") + MarkerPlanningWorkspace = deliverycontrol.TransitionID("planning.workspace_cut") + MarkerPlanningWrite = deliverycontrol.TransitionID("planning.planning_write") + MarkerPlanningApproval = deliverycontrol.TransitionID("planning.record_approval") + MarkerRecoveryDoctor = deliverycontrol.TransitionID("recovery.doctor") + MarkerRecoveryDiscard = deliverycontrol.TransitionID("recovery.discard_delivery") + MarkerRecoveryRepair = deliverycontrol.TransitionID("recovery.repair_state") // Post-publish markers (merged terminal only). The delivery machine // deliberately models nothing past PUBLISHED — merging is not a Boatstack // verb and FEATURE_COMPLETE is entered by observation — so the post-publish @@ -464,12 +464,14 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri Verb: "init", Args: repoArgs, Transition: MarkerPlanningInit, }, "") case "NOT_STARTED": - // The host plan path is knowable only to the host conversation; owe it. + // The feature, source plan, artifact, target shell, and Markdown are supplied + // by the explicit host invocation. The workspace-bound bootstrap oracle turns + // them into the only executable planning envelope. return finish(&PrescribedCommand{ - 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`.", projectLocalLauncherCommand(), generatedWorkflowReference())) + Verb: "flow", Args: append([]string{"bootstrap"}, repoArgs...), + RequiresHumanInput: []string{"--feature", "--source-plan", "--artifact", "--shell", planningMarkdownInput}, + Transition: MarkerPlanningBootstrap, + }, "Execute only the returned planning_envelope, then resolve flow bootstrap again for each artifact. After workspace-cut, discard every earlier prescription and resolve from destination_repository.") case "DRAFT_PLAN": if status.NextOperation == "workspace-cut" { return finish(buildWorkspaceCut(repoArgs, status.Feature), @@ -517,7 +519,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`.", projectLocalLauncherCommand(), slug, generatedWorkflowReference())) + return finish(cmd, fmt.Sprintf("After repair, resolve `flow bootstrap --feature %s` with the current source plan, artifact, shell, and complete Markdown; execute only its returned planning_envelope.", slug)) } return nil, "" default: diff --git a/boatstack/flow_planning_prescribe_conformance_test.go b/boatstack/flow_planning_prescribe_conformance_test.go index 1e8a5c18..57457d20 100644 --- a/boatstack/flow_planning_prescribe_conformance_test.go +++ b/boatstack/flow_planning_prescribe_conformance_test.go @@ -28,6 +28,19 @@ var planningExceptions = map[string]string{ "AMBIGUOUS": "choosing between candidate features/deliveries is a human act; candidates surface via Reason/BlockingAmbiguity", } +func containsAllStrings(values, required []string) bool { + set := map[string]bool{} + for _, value := range values { + set[value] = true + } + for _, value := range required { + if !set[value] { + return false + } + } + return true +} + // planningStages is one representative synthetic NextStatus per pre-activation // stage (INVALID_STATE once per NextOperation route ResolveNext or the safety // finding can carry). @@ -72,13 +85,13 @@ func TestNextControlPrescribesPreActivationStages(t *testing.T) { t.Fatal(err) } p := next.Prescribed - if p == nil || p.Verb != "check-source-plan" { - t.Fatalf("NOT_STARTED must prescribe check-source-plan: %+v", p) + if p == nil || p.Verb != "flow" || len(p.Args) == 0 || p.Args[0] != "bootstrap" { + t.Fatalf("NOT_STARTED must prescribe flow bootstrap: %+v", p) } - if p.AutoDerivable || len(p.RequiresHumanInput) != 1 || p.RequiresHumanInput[0] != "--plan" { - t.Fatalf("the host plan path is unknowable and must be owed, never fabricated: %+v", p) + if p.AutoDerivable || !containsAllStrings(p.RequiresHumanInput, []string{"--feature", "--source-plan", "--artifact", "--shell", planningMarkdownInput}) { + t.Fatalf("bootstrap creation evidence must be owed, never fabricated: %+v", p) } - if !strings.Contains(next.FollowUp, "planning-write") { + if !strings.Contains(next.FollowUp, "planning_envelope") { t.Fatalf("the planning follow-up must name the owned authoring channel: %q", next.FollowUp) } }) @@ -283,8 +296,8 @@ func TestPlanningPrescriptionFailureStates(t *testing.T) { if cmd == nil || cmd.Verb != "repair-state" { t.Fatalf("repair-state route must be prescribed: %+v", cmd) } - if !strings.Contains(followUp, "planning-write --repo . --feature demo") { - t.Fatalf("repair follow-up must name planning-write for the feature: %q", followUp) + if !strings.Contains(followUp, "flow bootstrap --feature demo") { + t.Fatalf("repair follow-up must name flow bootstrap for the feature: %q", followUp) } }) diff --git a/boatstack/next_response_conformance_test.go b/boatstack/next_response_conformance_test.go index d9c1cfc8..f310772e 100644 --- a/boatstack/next_response_conformance_test.go +++ b/boatstack/next_response_conformance_test.go @@ -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: .product-loop/boatstack check-source-plan") { + if !strings.Contains(output, "Run: .product-loop/boatstack flow bootstrap") { t.Fatalf("NOT_STARTED must carry the prescribed command: %q", output) } }) diff --git a/boatstack/paths.go b/boatstack/paths.go index 69def042..fdc12476 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -248,10 +248,6 @@ func projectLocalLauncherCommand() string { return filepath.ToSlash(filepath.Join(productLoopDirName, "boatstack")) } -func generatedWorkflowReference() string { - return filepath.ToSlash(filepath.Join(productLoopDirName, "workflow.md")) -} - // ExportRoot is the base beneath which generated bundle paths are materialized. // Bundle keys include .product-loop and host-adapter directories, so callers // must pass this root — never RepoRoot — to export write/check operations. diff --git a/boatstack/planning.go b/boatstack/planning.go index 90ebe67f..962390e2 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -39,10 +39,12 @@ func planningArtifactNames() []string { } type PlanningWriteOptions struct { - Repo string - Feature string - Artifact string - Content []byte + Repo string + Feature string + Artifact string + Content []byte + SourcePlan string + SourcePlanSHA256 string } type ApprovalRecordOptions struct { @@ -232,11 +234,12 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if !planningArtifacts[options.Artifact] { return "", fmt.Errorf("unsupported planning artifact %q; use one of: %s (note the .md suffix)", options.Artifact, strings.Join(planningArtifactNames(), ", ")) } - // Windows PowerShell 5.1 may prepend the UTF-8 byte-order mark when a - // here-string is piped to a native command even when $OutputEncoding uses a - // no-BOM encoder. Treat that transport signature as encoding metadata, not - // Markdown content, so every supported shell produces the same artifact. - content := bytes.TrimPrefix(options.Content, []byte{0xef, 0xbb, 0xbf}) + // Windows PowerShell 5.1 may prepend the UTF-8 byte-order mark and serialize + // line endings as CRLF when a here-string is piped to a native command even + // when $OutputEncoding uses a no-BOM encoder. Treat those transport signatures + // as encoding metadata, not Markdown content, so every supported shell + // produces the same artifact. + content := normalizePlanningTransportBytes(options.Content) if !utf8.Valid(content) { return "", fmt.Errorf("planning artifact must be valid UTF-8 Markdown") } @@ -258,6 +261,36 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if err != nil { return "", err } + featureDirectory := ctx.FeatureDir(options.Feature) + _, featureErr := os.Lstat(featureDirectory) + firstWrite := os.IsNotExist(featureErr) + if featureErr != nil && !firstWrite { + return "", featureErr + } + hasSourceEvidence := strings.TrimSpace(options.SourcePlan) != "" || strings.TrimSpace(options.SourcePlanSHA256) != "" + if firstWrite && !hasSourceEvidence { + return "", fmt.Errorf("a new feature requires a flow bootstrap prescription with current source-plan evidence") + } + if hasSourceEvidence { + if strings.TrimSpace(options.SourcePlan) == "" || strings.TrimSpace(options.SourcePlanSHA256) == "" { + return "", fmt.Errorf("source-plan path and SHA-256 must be supplied together") + } + sourcePlan, discoverErr := DiscoverSourcePlan(repo, options.SourcePlan) + if discoverErr != nil { + return "", discoverErr + } + sourceAbsolute := filepath.Join(repo, filepath.FromSlash(sourcePlan)) + if rejectErr := rejectSymlinkComponents(repo, sourceAbsolute); rejectErr != nil { + return "", fmt.Errorf("source plan must be a regular in-repository file without symlink indirection: %w", rejectErr) + } + currentSHA, hashErr := SHA256File(sourceAbsolute) + if hashErr != nil { + return "", hashErr + } + if currentSHA != strings.TrimSpace(options.SourcePlanSHA256) { + return "", fmt.Errorf("source plan changed after bootstrap; resolve a fresh flow bootstrap prescription") + } + } destination := filepath.Join(ctx.FeatureDir(options.Feature), options.Artifact) if err := rejectSymlinkComponents(ctx.ExportRoot(), destination); err != nil { return "", err @@ -272,6 +305,11 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { return filepath.ToSlash(relative), nil } +func normalizePlanningTransportBytes(content []byte) []byte { + content = bytes.TrimPrefix(content, []byte{0xef, 0xbb, 0xbf}) + return bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n")) +} + func RecordApproval(options ApprovalRecordOptions) error { if strings.TrimSpace(options.ApprovedBy) == "" { return fmt.Errorf("approval requires a named human") diff --git a/boatstack/planning_first_write_conformance_test.go b/boatstack/planning_first_write_conformance_test.go index 1ee8676d..9790f24d 100644 --- a/boatstack/planning_first_write_conformance_test.go +++ b/boatstack/planning_first_write_conformance_test.go @@ -28,9 +28,13 @@ func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) { previousHealth := planningInstallationHealth planningInstallationHealth = func(string) error { return nil } t.Cleanup(func() { planningInstallationHealth = previousHealth }) + sourceSHA, err := SHA256File(filepath.Join(repo, "README.md")) + if err != nil { + t.Fatal(err) + } for _, command := range []string{ - ".product-loop/boatstack 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 --source-plan README.md --source-plan-sha256 " + sourceSHA + " <<'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 { @@ -38,10 +42,10 @@ func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) { } } - written, err := WritePlanningArtifact(PlanningWriteOptions{ + written, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{ Repo: repo, Feature: "checkout", Artifact: "source-plan.md", Content: []byte("# Source plan\n"), - }) + })) if err != nil { t.Fatalf("planning-write must author the first artifact: %v", err) } @@ -113,7 +117,7 @@ func TestFirstWriteLatchCoversToolAndCommandPaths(t *testing.T) { } rendered := denialFor("claude", toolFindings[0]).Render(RenderPlain) - if !strings.Contains(rendered, "planning-write --repo . --feature checkout --artifact ") { + if !strings.Contains(rendered, "flow bootstrap --feature checkout") { t.Fatalf("denial must name the owned channel: %q", rendered) } if !strings.Contains(rendered, "NOT_STARTED") { diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index a2079abc..7eefe1d3 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -25,6 +25,26 @@ func planningRepo(t *testing.T) string { return repo } +func withPlanningSourceEvidence(t *testing.T, options PlanningWriteOptions) PlanningWriteOptions { + t.Helper() + if options.SourcePlan != "" || options.SourcePlanSHA256 != "" { + return options + } + path := filepath.Join(options.Repo, "bootstrap-source.md") + if !fileExists(path) { + if err := os.WriteFile(path, []byte("# Bootstrap source\n"), 0o644); err != nil { + t.Fatal(err) + } + } + hash, err := SHA256File(path) + if err != nil { + t.Fatal(err) + } + options.SourcePlan = "bootstrap-source.md" + options.SourcePlanSHA256 = hash + return options +} + func TestPlanningWriteBlocksBeforeArtifactWhenInstallationIsUnhealthy(t *testing.T) { repo := planningRepo(t) planningInstallationHealth = func(string) error { return fmt.Errorf("generated state drift") } @@ -39,10 +59,10 @@ func TestPlanningWriteBlocksBeforeArtifactWhenInstallationIsUnhealthy(t *testing func TestPlanningWriteIsBoundedMarkdownOnly(t *testing.T) { repo := planningRepo(t) - path, err := WritePlanningArtifact(PlanningWriteOptions{ + path, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{ Repo: repo, Feature: "account-recovery", Artifact: "questions.md", Content: []byte("# Questions\n\nQ-1 remains open.\n"), - }) + })) if err != nil { t.Fatal(err) } @@ -68,12 +88,12 @@ func TestPlanningWriteIsBoundedMarkdownOnly(t *testing.T) { } } -func TestPlanningWriteNormalizesPowerShellUTF8BOM(t *testing.T) { +func TestPlanningWriteNormalizesPowerShellTransportEncoding(t *testing.T) { repo := planningRepo(t) - path, err := WritePlanningArtifact(PlanningWriteOptions{ + path, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{ Repo: repo, Feature: "powershell-transport", Artifact: "plan.md", Content: append([]byte{0xef, 0xbb, 0xbf}, []byte("# Plan\r\n")...), - }) + })) if err != nil { t.Fatal(err) } @@ -81,8 +101,8 @@ func TestPlanningWriteNormalizesPowerShellUTF8BOM(t *testing.T) { if err != nil { t.Fatal(err) } - if string(written) != "# Plan\r\n" { - t.Fatalf("PowerShell transport BOM reached the Markdown artifact: %q", written) + if string(written) != "# Plan\n" { + t.Fatalf("PowerShell transport encoding reached the Markdown artifact: %q", written) } if _, err := WritePlanningArtifact(PlanningWriteOptions{ Repo: repo, Feature: "powershell-transport", Artifact: "questions.md", @@ -121,9 +141,9 @@ func TestPlanningWriteRejectsSymlinksAndPreservesExistingContentOnFailure(t *tes if err := os.Symlink(outside, productLoop); err != nil { t.Fatal(err) } - if _, err := WritePlanningArtifact(PlanningWriteOptions{ + if _, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{ Repo: repo, Feature: "feature", Artifact: "plan.md", Content: []byte("# plan\n"), - }); err == nil || !strings.Contains(err.Error(), "symlink") { + })); err == nil || !strings.Contains(err.Error(), "symlink") { t.Fatalf("expected symlink rejection, got %v", err) } if _, err := os.Stat(filepath.Join(outside, "features", "feature", "plan.md")); !os.IsNotExist(err) { @@ -133,9 +153,9 @@ func TestPlanningWriteRejectsSymlinksAndPreservesExistingContentOnFailure(t *tes if err := os.Remove(productLoop); err != nil { t.Fatal(err) } - destination, err := WritePlanningArtifact(PlanningWriteOptions{ + destination, err := WritePlanningArtifact(withPlanningSourceEvidence(t, PlanningWriteOptions{ Repo: repo, Feature: "feature", Artifact: "plan.md", Content: []byte("# known good\n"), - }) + })) if err != nil { t.Fatal(err) } diff --git a/boatstack/planning_transport.go b/boatstack/planning_transport.go index 1f3582a6..878fa1f7 100644 --- a/boatstack/planning_transport.go +++ b/boatstack/planning_transport.go @@ -21,10 +21,12 @@ import ( // control-law: planning-document-body-is-literal-data const powerShellPlanningEncodingLine = `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)` +const powerShellPlanningExitLine = `if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` 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(?:\.ps1)?['"]?[ \t]+planning-write(?:[ \t]|$)`) +var planningSHA256 = regexp.MustCompile(`^[a-f0-9]{64}$`) type planningTransportInspection struct { Matched bool @@ -143,6 +145,15 @@ func planningWriteAttempt(value string) bool { return len(words) >= 2 && planningExecutable(words[0]) && words[1] == "planning-write" } +func planningBootstrapAttempt(value string) bool { + words, _ := literalCommandWords(value) + return len(words) >= 3 && planningExecutable(words[0]) && words[1] == "flow" && words[2] == "bootstrap" +} + +func planningEnvelopeAttempt(value string) bool { + return planningWriteAttempt(value) || planningBootstrapAttempt(value) +} + func planningVerbInvocationAttempt(value string) bool { words, _ := literalCommandWords(value) return len(words) >= 2 && words[1] == "planning-write" @@ -151,7 +162,7 @@ func planningVerbInvocationAttempt(value string) bool { func powerShellPlanningAttempt(command string) bool { for position := 0; position <= len(command); { line, next, hasNewline := nextLine(command, position) - if match := powerShellPlanningClose.FindStringSubmatch(structuralLine(line)); match != nil && planningWriteAttempt(strings.TrimSpace(match[1])) { + if match := powerShellPlanningClose.FindStringSubmatch(structuralLine(line)); match != nil && planningEnvelopeAttempt(strings.TrimSpace(match[1])) { return true } if !hasNewline { @@ -164,13 +175,31 @@ func powerShellPlanningAttempt(command string) bool { func planningWriteHeader(value string) (planningWriteInvocation, bool) { words, complete := literalCommandWords(value) - if !complete || len(words) < 2 || !planningExecutable(words[0]) || words[1] != "planning-write" { + if !complete || len(words) < 2 || !planningExecutable(words[0]) { + return planningWriteInvocation{}, false + } + start := 2 + bootstrap := false + if words[1] == "flow" { + if len(words) < 3 || words[2] != "bootstrap" { + return planningWriteInvocation{}, false + } + start = 3 + bootstrap = true + } else if words[1] != "planning-write" { return planningWriteInvocation{}, false } values := map[string]string{} - for index := 2; index < len(words); index++ { + for index := start; index < len(words); index++ { flag := words[index] value := "" + if bootstrap && flag == "--json" { + if values[flag] != "" { + return planningWriteInvocation{}, false + } + values[flag] = "true" + continue + } if split := strings.IndexByte(flag, '='); split >= 0 { value = flag[split+1:] flag = flag[:split] @@ -181,7 +210,13 @@ func planningWriteHeader(value string) (planningWriteInvocation, bool) { index++ value = words[index] } - if flag != "--repo" && flag != "--feature" && flag != "--artifact" { + allowed := flag == "--repo" || flag == "--feature" || flag == "--artifact" || flag == "--source-plan" + if bootstrap { + allowed = allowed || flag == "--shell" || flag == "--json" + } else { + allowed = allowed || flag == "--source-plan-sha256" + } + if !allowed { return planningWriteInvocation{}, false } if value == "" || values[flag] != "" { @@ -192,6 +227,17 @@ func planningWriteHeader(value string) (planningWriteInvocation, bool) { if !featureSlugPattern.MatchString(values["--feature"]) || !planningArtifacts[values["--artifact"]] { return planningWriteInvocation{}, false } + if bootstrap { + if values["--source-plan"] == "" || (values["--shell"] != string(BootstrapShellPOSIX) && values["--shell"] != string(BootstrapShellPowerShell)) { + return planningWriteInvocation{}, false + } + } else { + sourcePlan := values["--source-plan"] + sourceSHA := values["--source-plan-sha256"] + if (sourcePlan == "") != (sourceSHA == "") || (sourceSHA != "" && !planningSHA256.MatchString(sourceSHA)) { + return planningWriteInvocation{}, false + } + } repository := values["--repo"] if repository == "" { repository = "." @@ -271,7 +317,7 @@ func validPlanningBody(value string) string { func inspectPosixPlanningTransport(command string, first string, bodyStart int) planningTransportInspection { match := posixPlanningHeader.FindStringSubmatch(first) if match == nil { - if planningWriteAttempt(first) { + if planningEnvelopeAttempt(first) { return planningTransportInspection{Matched: true, InvalidReason: "single-quoted-delimiter-required"} } return planningTransportInspection{} @@ -279,7 +325,7 @@ func inspectPosixPlanningTransport(command string, first string, bodyStart int) header := strings.TrimSpace(match[1]) invocation, validHeader := planningWriteHeader(header) if !validHeader { - if planningWriteAttempt(header) || planningVerbInvocationAttempt(header) { + if planningEnvelopeAttempt(header) || planningVerbInvocationAttempt(header) { return planningTransportInspection{Matched: true, Header: header, InvalidReason: "invalid-command-shape"} } return planningTransportInspection{} @@ -291,11 +337,11 @@ func inspectPosixPlanningTransport(command string, first string, bodyStart int) if next != len(command) { return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "delimiter-collision-or-trailing-command"} } - content := command[bodyStart:position] - if reason := validPlanningBody(content); reason != "" { + content := normalizePlanningTransportBytes([]byte(command[bodyStart:position])) + if reason := validPlanningBody(string(content)); reason != "" { return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: reason} } - return planningTransportInspection{Matched: true, Header: header, Content: []byte(content), Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable} + return planningTransportInspection{Matched: true, Header: header, Content: content, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable} } if !hasNewline { break @@ -341,15 +387,19 @@ func inspectPowerShellPlanningTransport(command string) planningTransportInspect if !hasNewline { return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "powershell-scope-not-closed"} } - closing, afterClosing, _ := nextLine(command, next) + exitLine, afterExit, hasExitNewline := nextLine(command, next) + if !hasExitNewline || strings.TrimSpace(structuralLine(exitLine)) != powerShellPlanningExitLine { + return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "powershell-exit-status-required"} + } + closing, afterClosing, _ := nextLine(command, afterExit) if strings.TrimSpace(structuralLine(closing)) != "}" || afterClosing != len(command) { return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "delimiter-collision-or-trailing-command"} } - content := command[bodyStart:position] - if reason := validPlanningBody(content); reason != "" { + content := normalizePlanningTransportBytes([]byte(command[bodyStart:position])) + if reason := validPlanningBody(string(content)); reason != "" { return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: reason} } - return planningTransportInspection{Matched: true, Header: header, Content: []byte(content), Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable} + return planningTransportInspection{Matched: true, Header: header, Content: content, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable} } if !hasNewline { break diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go index 982f7bcc..f0a2645c 100644 --- a/boatstack/planning_transport_conformance_test.go +++ b/boatstack/planning_transport_conformance_test.go @@ -23,7 +23,12 @@ func quotedLiteral(t *testing.T, value string) string { func planningHeader(t *testing.T, helper, repo, feature, artifact string) string { t.Helper() - return quotedLiteral(t, helper) + " planning-write --repo " + quotedLiteral(t, repo) + " --feature " + feature + " --artifact " + artifact + sourcePlan := "README.md" + sourceSHA, err := SHA256File(filepath.Join(repo, sourcePlan)) + if err != nil { + return quotedLiteral(t, helper) + " planning-write --repo " + quotedLiteral(t, repo) + " --feature " + feature + " --artifact " + artifact + } + return quotedLiteral(t, helper) + " planning-write --repo " + quotedLiteral(t, repo) + " --feature " + feature + " --artifact " + artifact + " --source-plan " + sourcePlan + " --source-plan-sha256 " + sourceSHA } func posixPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, body string) string { @@ -39,7 +44,7 @@ func powerShellPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, b if !strings.HasSuffix(body, "\n") { body += "\n" } - return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + body + "'@ | & " + planningHeader(t, helper, repo, feature, artifact) + "\n}\n" + return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + body + "'@ | & " + planningHeader(t, helper, repo, feature, artifact) + "\n" + powerShellPlanningExitLine + "\n}\n" } func planningHookInput(t *testing.T, host, command string) []byte { @@ -159,9 +164,7 @@ func TestPlanningTransportRunsHookShellHelperAndSavedArtifact(t *testing.T) { if err != nil { t.Fatal(err) } - // Windows PowerShell may serialize the terminating newline as CRLF. The - // document text and every non-ASCII code point must otherwise be identical. - if strings.ReplaceAll(string(written), "\r\n", "\n") != body { + if string(written) != body { t.Fatalf("saved Markdown differs from transported body:\nwant %q\n got %q", body, written) } } @@ -241,7 +244,7 @@ func TestPlanningTransportTreatsDocumentTextAsInertAcrossHosts(t *testing.T) { }{ {posix, body, true}, {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}, + {strings.ReplaceAll(posix, "\n", "\r\n"), body, true}, } for _, test := range commands { inspection := inspectPlanningWriteTransport(test.command) @@ -288,6 +291,7 @@ func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) "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 no exit propagation": strings.Replace(powerShellValid, powerShellPlanningExitLine+"\n", "", 1), "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", } @@ -379,20 +383,23 @@ func TestPlanningPrescriptionQuotesRepositoryPath(t *testing.T) { runGit(t, repo, "add", ".") runGit(t, repo, "commit", "-m", "base") - command, ok := prescribePlanningVerb(repo, NextStatus{ObservedStage: "NOT_STARTED", Feature: "quoted-path"}, "planning-write") - if !ok || command == nil { - t.Fatal("planning-write prescription is missing") + installPlanningTransportFixture(t, repo) + prescription, err := ResolvePlanningBootstrap(BootstrapOptions{ + Repo: repo, Feature: "quoted-path", SourcePlan: "README.md", + Artifact: "plan.md", Shell: BootstrapShellPOSIX, Document: []byte("test-value\n"), + }) + if err != nil { + t.Fatalf("resolve quoted repository bootstrap: %v", err) } - line := substituteOwedFlags(command.CommandLine()) + line := prescription.PlanningEnvelope inspection := inspectPlanningWriteTransport(line) - if !inspection.Matched || inspection.InvalidReason != "" || inspection.Repository != repo { + if !inspection.Matched || inspection.InvalidReason != "" || inspection.Repository != prescription.Repository { t.Fatalf("quoted repository path did not round trip: %q %#v", line, inspection) } if findings := ClassifyCommand(repo, line); len(findings) != 0 { t.Fatalf("guard denied the quoted repository prescription: %#v", findings) } if runtime.GOOS != "windows" { - installPlanningTransportFixture(t, repo) executePlanningEnvelope(t, repo, line) written, err := os.ReadFile(filepath.Join(repo, productLoopDirName, "features", "quoted-path", "plan.md")) if err != nil || string(written) != "test-value\n" { diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index c9e1aa0a..f7d9245c 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -269,28 +269,11 @@ 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/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. +Feature artifacts are authored only from the read-only, mode-aware `flow bootstrap` oracle. Supply the selected feature, durable in-repo source-plan path, artifact name, target shell, and complete Markdown through the current Boatstack operation entrypoint. The oracle verifies the worktree and source-plan digest and returns a `planning_envelope` bound to the exact embedded launcher or detached helper. Execute that envelope unchanged. -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. +The oracle emits a non-colliding single-quoted heredoc for Bash, zsh, and Git Bash. For Windows PowerShell it emits a UTF-8-scoped single-quoted here-string. A document containing a PowerShell closing marker must use `--shell posix` with Git Bash. Do not select, append, or rewrite an executable path yourself. -```bash -.product-loop/boatstack planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' - -BOATSTACK_PLAN_EOF -``` - -In Windows PowerShell, keep UTF-8 local to a child scope and use a single-quoted here-string. If the document contains a line beginning with the PowerShell closing mark `'@`, use the Git Bash form with a non-colliding token. - -```powershell -& { -$OutputEncoding = [System.Text.UTF8Encoding]::new($false) -@' - -'@ | & '.product-loop\boatstack.ps1' planning-write --repo . --feature --artifact -} -``` - -Send the complete envelope in one shell-tool call. Do not run a bare `planning-write`, split the command and body across calls, prepend or append another command, use an unquoted or double-quoted delimiter, or paste the Markdown at an interactive shell prompt. `PLANNING_TRANSPORT_INVALID` means nothing ran; correct the envelope instead of manually replaying its body. The host's own Markdown writer is permitted only where the host allows it; arbitrary redirection to a feature path never is. +Send the complete Markdown to `flow bootstrap` in one literal stdin envelope, then send its returned `planning_envelope` in one shell-tool call. Do not run a bare `planning-write`, construct a helper path from this document, split either envelope across calls, prepend or append another command, or paste Markdown at an interactive prompt. After `workspace-cut`, discard every earlier prescription and resolve again from `destination_repository`. `PLANNING_TRANSPORT_INVALID` means nothing ran; obtain a fresh prescription instead of replaying the body manually. The host's own Markdown writer is permitted only where the host allows it; arbitrary redirection to a feature path never is. Validation must be derived before implementation. Each check records: diff --git a/boatstack/safety.go b/boatstack/safety.go index af58a849..bfefa9c2 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -79,7 +79,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)|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b)`) +var readOnlyStage = regexp.MustCompile(`(?i)^\s*(?:env\s+[^ ]+\s+)*(?:rg|grep|git\s+(?:grep|diff|status|show|log)|cat|sed|head|tail|less|wc|awk|sort|uniq|cut|tr|jq|column|nl|comm|rev|fold|find\s+[^\n]*-(?:print|ls)|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+(?:recovery-status|mutation-status|operation-status|delivery-status|next-status|workspace-status|repair-status|check-plan|check-source-plan|check-safety|diagnose-hook|authority-context|doctor|version)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+insight\s+(?:check|list|show|frontier|evaluate)\b|(?:[^\s]*/)?boatstack(?:\.ps1)?\s+flow\s+(?:bootstrap|check|next|tasks|frontier|watch|report)\b)`) // Constitutional/Optimization split. These destruction rules are CONSTITUTIONAL: // they define the real boundary (destroying a live resource) and are never traded @@ -257,7 +257,7 @@ func controlledPhaseTransition(command, stage string) bool { if executable != "boatstack" && executable != "boatstack-helper" { return false } - if readOnlyHelperVerbs[fields[1]] { + if ownedReadOnlyHelperCommand(fields) { return true } if stageIndependentRecoveryVerbs[fields[1]] { @@ -353,7 +353,7 @@ func ownedReadOnlyHelperCommand(words []string) bool { case "insight": return map[string]bool{"check": true, "list": true, "show": true, "frontier": true, "evaluate": true}[words[2]] case "flow": - if !map[string]bool{"check": true, "next": true, "tasks": true, "frontier": true, "watch": true, "report": true}[words[2]] { + if !map[string]bool{"bootstrap": true, "check": true, "next": true, "tasks": true, "frontier": true, "watch": true, "report": true}[words[2]] { return false } for _, word := range words[3:] { diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go index ac4f4d24..b22237ad 100644 --- a/boatstack/solution_closure_conformance_test.go +++ b/boatstack/solution_closure_conformance_test.go @@ -26,8 +26,12 @@ import ( func substituteOwedFlags(line string) string { line = strings.ReplaceAll(line, "--feature ''", "--feature demo") line = strings.ReplaceAll(line, "--artifact ''", "--artifact plan.md") + line = strings.ReplaceAll(line, "--source-plan ''", "--source-plan README.md") + line = strings.ReplaceAll(line, "--shell ''", "--shell posix") line = strings.ReplaceAll(line, "--feature ", "--feature demo") line = strings.ReplaceAll(line, "--artifact ", "--artifact plan.md") + line = strings.ReplaceAll(line, "--source-plan ", "--source-plan README.md") + line = strings.ReplaceAll(line, "--shell ", "--shell posix") return strings.ReplaceAll(line, "", "test-value") } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d2b9946b..f9e63f55 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -79,28 +79,9 @@ Finish the host's Plan-mode exploration and save it as a durable file, then reru ## Plan mode cannot write an artifact -Planning is Markdown-only. Send the complete command and document in one shell-tool call. For Bash, zsh, or Git Bash: +Planning is Markdown-only. Send the selected feature, durable in-repository source plan, artifact name, target shell, and complete Markdown to `flow bootstrap --repo --feature --source-plan --artifact --shell posix|powershell` through the current Boatstack operation entrypoint. Execute only the returned `planning_envelope`, unchanged. The oracle selects the valid embedded or detached helper and renders the shell transport; documentation is never command authority. -```bash -.product-loop/boatstack planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' - -BOATSTACK_PLAN_EOF -``` - -In Git Bash on Windows, use `.product-loop/boatstack` in the same envelope. - -For Windows PowerShell: - -```powershell -& { -$OutputEncoding = [System.Text.UTF8Encoding]::new($false) -@' - -'@ | & '.product-loop\boatstack.ps1' planning-write --repo . --feature --artifact -} -``` - -The adapter must not run a bare helper, split the envelope across calls, prepend or append another command, use an expansion-capable delimiter, paste the Markdown at a shell prompt, or edit product code. `PLANNING_TRANSPORT_INVALID` means Boatstack stopped the command before execution; correct the complete envelope. If a PowerShell document contains a line beginning with `'@`, use Git Bash and choose a heredoc token that does not occur as its own line in the document. +The adapter must not run a bare helper, construct a `planning-write` command, split either envelope across calls, prepend or append another command, use an expansion-capable delimiter, paste Markdown at a shell prompt, or edit product code. After `workspace-cut`, discard the earlier prescription and resolve again from `destination_repository`. `PLANNING_TRANSPORT_INVALID` means Boatstack stopped the command before execution; obtain a fresh prescription. If PowerShell cannot represent a document or path safely, use Git Bash and request `--shell posix`. ## `/build` says it is ready but cannot start diff --git a/release-notes/2026-08-10-bootstrap-command-authority.md b/release-notes/2026-08-10-bootstrap-command-authority.md new file mode 100644 index 00000000..ed26064c --- /dev/null +++ b/release-notes/2026-08-10-bootstrap-command-authority.md @@ -0,0 +1,5 @@ +### Bind planning bootstrap to the selected workspace + +New feature planning now resolves its literal write command from the current embedded or detached workspace, verifies the selected in-repository source plan, and returns one shell-specific envelope that works consistently across supported coding hosts. + +PowerShell planning transport now preserves exact UTF-8 artifact bytes and propagates helper failures to the invoking host.