diff --git a/.agents/skills/boatstack-autoplan/SKILL.md b/.agents/skills/boatstack-autoplan/SKILL.md new file mode 100644 index 00000000..fcadbe3a --- /dev/null +++ b/.agents/skills/boatstack-autoplan/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-autoplan +description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Autoplan + +Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.agents/skills/boatstack-autoplan/agents/openai.yaml b/.agents/skills/boatstack-autoplan/agents/openai.yaml new file mode 100644 index 00000000..bb178f04 --- /dev/null +++ b/.agents/skills/boatstack-autoplan/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Boatstack Autoplan" + short_description: "Create and approve a verified delivery plan." + default_prompt: "Use $boatstack-autoplan to follow the authority-preserving Boatstack V2 driver." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/boatstack-run/SKILL.md b/.agents/skills/boatstack-run/SKILL.md new file mode 100644 index 00000000..e386e214 --- /dev/null +++ b/.agents/skills/boatstack-run/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-run +description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Run + +Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.agents/skills/boatstack-run/agents/openai.yaml b/.agents/skills/boatstack-run/agents/openai.yaml new file mode 100644 index 00000000..030b17b0 --- /dev/null +++ b/.agents/skills/boatstack-run/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Boatstack Run" + short_description: "Drive delivery to an open or updated pull request." + default_prompt: "Use $boatstack-run to follow the authority-preserving Boatstack V2 driver." +policy: + allow_implicit_invocation: false diff --git a/.agents/skills/boatstack-update/SKILL.md b/.agents/skills/boatstack-update/SKILL.md new file mode 100644 index 00000000..7d58a135 --- /dev/null +++ b/.agents/skills/boatstack-update/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-update +description: Apply a checksum-verified Boatstack update. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Update + +Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.agents/skills/boatstack-update/agents/openai.yaml b/.agents/skills/boatstack-update/agents/openai.yaml new file mode 100644 index 00000000..496a003a --- /dev/null +++ b/.agents/skills/boatstack-update/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Boatstack Update" + short_description: "Apply a checksum-verified Boatstack update." + default_prompt: "Use $boatstack-update to follow the authority-preserving Boatstack V2 driver." +policy: + allow_implicit_invocation: false diff --git a/.claude/skills/boatstack-autoplan/SKILL.md b/.claude/skills/boatstack-autoplan/SKILL.md new file mode 100644 index 00000000..fcadbe3a --- /dev/null +++ b/.claude/skills/boatstack-autoplan/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-autoplan +description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Autoplan + +Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.claude/skills/boatstack-run/SKILL.md b/.claude/skills/boatstack-run/SKILL.md new file mode 100644 index 00000000..e386e214 --- /dev/null +++ b/.claude/skills/boatstack-run/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-run +description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Run + +Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.claude/skills/boatstack-update/SKILL.md b/.claude/skills/boatstack-update/SKILL.md new file mode 100644 index 00000000..7d58a135 --- /dev/null +++ b/.claude/skills/boatstack-update/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-update +description: Apply a checksum-verified Boatstack update. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Update + +Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.cursor/commands/boatstack-autoplan.md b/.cursor/commands/boatstack-autoplan.md new file mode 100644 index 00000000..fcadbe3a --- /dev/null +++ b/.cursor/commands/boatstack-autoplan.md @@ -0,0 +1,28 @@ +--- +name: boatstack-autoplan +description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Autoplan + +Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.cursor/commands/boatstack-run.md b/.cursor/commands/boatstack-run.md new file mode 100644 index 00000000..e386e214 --- /dev/null +++ b/.cursor/commands/boatstack-run.md @@ -0,0 +1,28 @@ +--- +name: boatstack-run +description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Run + +Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.cursor/commands/boatstack-update.md b/.cursor/commands/boatstack-update.md new file mode 100644 index 00000000..7d58a135 --- /dev/null +++ b/.cursor/commands/boatstack-update.md @@ -0,0 +1,28 @@ +--- +name: boatstack-update +description: Apply a checksum-verified Boatstack update. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Update + +Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.gemini/skills/boatstack-autoplan/SKILL.md b/.gemini/skills/boatstack-autoplan/SKILL.md new file mode 100644 index 00000000..fcadbe3a --- /dev/null +++ b/.gemini/skills/boatstack-autoplan/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-autoplan +description: Create and approve a verified delivery plan. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Autoplan + +Select the `approved-plan` terminal. This trigger supplies planning intent only. It never approves unseen plan bytes. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.gemini/skills/boatstack-run/SKILL.md b/.gemini/skills/boatstack-run/SKILL.md new file mode 100644 index 00000000..e386e214 --- /dev/null +++ b/.gemini/skills/boatstack-run/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-run +description: Drive delivery to an open or updated pull request. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Run + +Select the `open-or-updated-pr` terminal. This trigger never grants merge authority. Provider authority remains a separate verified receipt. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.gemini/skills/boatstack-update/SKILL.md b/.gemini/skills/boatstack-update/SKILL.md new file mode 100644 index 00000000..7d58a135 --- /dev/null +++ b/.gemini/skills/boatstack-update/SKILL.md @@ -0,0 +1,28 @@ +--- +name: boatstack-update +description: Apply a checksum-verified Boatstack update. Use only when the user explicitly selects this Boatstack operation. +--- + +# Boatstack Update + +Select the `installation.update` transition. This trigger does not reclassify or advance a product delivery. + +Run `boatstack status --repo . --format json` once for observation. An authority-free +`FRONTIER` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `next`, `apply`, `recover`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `next`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `FRONTIER`, `BLOCKED`, `REFUSED`, or +`UNRESOLVED` result for this operation. Treat `TERMINAL` as exact goal evidence. +If recovery is active, use only a transition in `recovery_info.permitted` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. diff --git a/.github/tests/test_detached_supervision.py b/.github/tests/test_detached_supervision.py index da834f7a..a9c67e8d 100644 --- a/.github/tests/test_detached_supervision.py +++ b/.github/tests/test_detached_supervision.py @@ -151,7 +151,7 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No "schema_version": 2, "project": {"name": "fixture", "default_branch": "main", "commands": {}}, "policy": {"plan_approval": "human", "visual_evidence": "optional"}, - "hosts": ["cli", "codex"], + "hosts": ["cli", "cursor", "codex", "claude", "gemini", "mcp"], } ) ) @@ -161,7 +161,30 @@ def test_detached_installation_and_engaged_guard_use_the_same_kernel(self) -> No ) self.assertEqual(initialized["snapshot"]["invocation"]["topology"], "detached") self.assertEqual(initialized["snapshot"]["runtime"]["value"], "verified") - self.assertEqual(self.porcelain(), "?? .boatstack/project.json") + self.assertEqual( + self.porcelain(), + "\n".join( + [ + "?? .agents/skills/boatstack-autoplan/SKILL.md", + "?? .agents/skills/boatstack-autoplan/agents/openai.yaml", + "?? .agents/skills/boatstack-run/SKILL.md", + "?? .agents/skills/boatstack-run/agents/openai.yaml", + "?? .agents/skills/boatstack-update/SKILL.md", + "?? .agents/skills/boatstack-update/agents/openai.yaml", + "?? .boatstack/host-skills.json", + "?? .boatstack/project.json", + "?? .claude/skills/boatstack-autoplan/SKILL.md", + "?? .claude/skills/boatstack-run/SKILL.md", + "?? .claude/skills/boatstack-update/SKILL.md", + "?? .cursor/commands/boatstack-autoplan.md", + "?? .cursor/commands/boatstack-run.md", + "?? .cursor/commands/boatstack-update.md", + "?? .gemini/skills/boatstack-autoplan/SKILL.md", + "?? .gemini/skills/boatstack-run/SKILL.md", + "?? .gemini/skills/boatstack-update/SKILL.md", + ] + ), + ) self.helper_json( "apply", "--repo", self.repo, "--transition", "engagement.begin", @@ -220,6 +243,13 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - self.assertIn("plan.create", diagnostic["decision"]["candidates"]) self.assertEqual(self.porcelain(), before) + progressing = self.helper_json( + "next", "--repo", self.repo, *goal, *flow, + "--human", "contract", "--repository-authority", + ) + self.assertEqual(progressing["decision"]["kind"], "PRESCRIBED") + self.assertEqual(progressing["decision"]["transition"]["id"], "plan.create") + plan = Path(self.work.name) / "source-plan.md" plan.write_text("# Driver fix\n\nPreserve authority across resolution and effects.\n") parameters = ( @@ -249,8 +279,7 @@ def test_authority_free_frontier_does_not_block_authorized_plan_creation(self) - self.assertIn(field, applied_process.stdout) resolved = self.helper_json( - "next", "--repo", self.repo, "--transition", "plan.validate", - *goal, *flow, "--repository-authority", + "next", "--repo", self.repo, *goal, *flow, "--repository-authority", ) self.assertEqual(resolved["decision"]["kind"], "PRESCRIBED") self.assertEqual(resolved["decision"]["transition"]["id"], "plan.validate") diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 1a3016ff..5814072b 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -120,35 +120,55 @@ def test_manual_release_is_prerelease_only_and_exact_source_bound(self) -> None: self.assertIn("VERSION: ${{ inputs.prerelease_tag || github.ref_name }}", release) self.assertIn('RELEASE_SOURCE: ${{ github.sha }}', release) - def test_codex_modes_keep_authority_attached_after_observation(self) -> None: - # control-law: codex-mode-authority-survives-observation-and-effects - skill = (REPO / "boatstack" / "SKILL.md").read_text() - prompt = (REPO / "boatstack" / "agents" / "openai.yaml").read_text() + def test_operation_skills_are_three_distinct_authority_preserving_surfaces(self) -> None: + # control-law: operation-skill-discovery-preserves-authority-and-exact-cardinality + skills = { + path.parent.name: path.read_text() + for path in sorted((REPO / ".agents" / "skills").glob("boatstack-*/SKILL.md")) + } + prompts = { + path.parents[1].name: path.read_text() + for path in sorted((REPO / ".agents" / "skills").glob("boatstack-*/agents/openai.yaml")) + } readme = (REPO / "README.md").read_text() - for surface in (skill, prompt, readme): - for mode in ("$boatstack Autoplan", "$boatstack Run", "$boatstack Update"): - self.assertIn(mode, surface) - for mapping in ( - "`approved-plan` terminal", - "`open-or-updated-pr` terminal", - "`installation.update`", - ): - self.assertIn(mapping, skill) + self.assertEqual(set(skills), {"boatstack-autoplan", "boatstack-run", "boatstack-update"}) + self.assertEqual(set(prompts), set(skills)) + for host_root in (REPO / ".claude" / "skills", REPO / ".gemini" / "skills"): + projected = { + path.parent.name: path.read_text() + for path in sorted(host_root.glob("boatstack-*/SKILL.md")) + } + self.assertEqual(projected, skills) + cursor = { + path.stem: path.read_text() + for path in sorted((REPO / ".cursor" / "commands").glob("boatstack-*.md")) + } + self.assertEqual(cursor, skills) + self.assertFalse((REPO / "boatstack" / "SKILL.md").exists()) + mappings = { + "boatstack-autoplan": "`approved-plan` terminal", + "boatstack-run": "`open-or-updated-pr` terminal", + "boatstack-update": "`installation.update` transition", + } + for name, mapping in mappings.items(): + self.assertIn(f"name: {name}", skills[name]) + self.assertIn(mapping, skills[name]) + self.assertIn(f"${name}", prompts[name]) for contract in ( - "status` is observation only", - "authority-free `FRONTIER`", - "command-scoped authority context", - "every `next`, `apply`,\n`recover`, and re-resolution", - "complete `apply` response and stderr", - "authority-bearing resolution", + "authority-free\n`FRONTIER`", + "command-scoped context", + "every `next`, `apply`, `recover`, and re-resolution", + "untargeted authority-bearing `next`", + "immediately preceding prescription", + "complete apply response and stderr", + "authority-bearing `FRONTIER`", ): - self.assertIn(contract, skill) - self.assertIn("present exactly three choices", skill) - self.assertIn("case-insensitively", skill) - self.assertIn("It never selects\n merge authority", skill) - self.assertIn("bare $boatstack presents those three choices", prompt) - self.assertIn("Run never grants merge authority", readme) + for name, skill in skills.items(): + self.assertIn(contract, skill, name) + self.assertIn("never grants merge authority", skills["boatstack-run"]) + self.assertIn("Untargeted resolution selects\nonly a transition that advances the configured goal", readme) + self.assertIn("exactly three operation skills", readme) def test_document_links_claims_and_assets_are_valid(self) -> None: def anchors(document: Path) -> set[str]: @@ -218,10 +238,9 @@ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> current_guidance = [ REPO / "README.md", - REPO / "boatstack" / "SKILL.md", + *sorted((REPO / ".agents" / "skills").glob("boatstack-*/SKILL.md")), *sorted((REPO / "docs").glob("*.md")), *sorted((REPO / "boatstack" / "references").glob("*.md")), - *sorted((REPO / "boatstack" / "agents").glob("*.yaml")), ] deprecated = ( "plan-gate", @@ -239,7 +258,7 @@ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> def test_documented_cli_verbs_are_registered_v2_surfaces(self) -> None: documents = [ REPO / "README.md", - REPO / "boatstack" / "SKILL.md", + *sorted((REPO / ".agents" / "skills").glob("boatstack-*/SKILL.md")), *sorted((REPO / "docs").glob("*.md")), *sorted((REPO / "boatstack" / "references").glob("*.md")), ] diff --git a/README.md b/README.md index 642fda2b..9d4b37f8 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 | The installer verifies the release checksum, runs the registered `installation.initialize` transition, and then installs the launcher. Review -and commit `.boatstack/project.json`; machine-local controller state stays -outside the worktree. +and commit `.boatstack/project.json`, `.boatstack/host-skills.json`, and the +generated host skills; machine-local controller state stays outside the +worktree. Run the independent health query: @@ -62,6 +63,12 @@ See [Getting started](docs/getting-started.md) and ## Product surface +```sh +boatstack status --repo . --format json +boatstack catalog --format json +boatstack apply --repo . --transition --format json +``` + - `status`, `next`, `doctor`, `catalog`, and `events` are read-only. - `apply` and `recover` request stable transition IDs from the 61-event executable catalog. @@ -82,18 +89,30 @@ come directly from the runtime registry. The [replacement closure report](docs/architecture/boatstack-v2-closure-report.md) records the deleted V1 authority and its V2 evidence. -## Codex modes +## Coding-agent skills + +Boatstack exposes exactly three operation skills on every supported interactive +coding host: + +- `boatstack-autoplan` reaches an approved plan. +- `boatstack-run` drives delivery through a normal PR open or update and never + grants merge authority. +- `boatstack-update` runs the checksum-verified installation update path. -Codex users can invoke `$boatstack Autoplan` to reach an approved plan, -`$boatstack Run` to drive a delivery through a normal PR open or update, or -`$boatstack Update` to run the checksum-verified Boatstack installation update -path. Mode names are case-insensitive, and bare `$boatstack` presents the same -three choices. Run never grants merge authority. +Codex and compatible Agent Skills hosts show `$boatstack-autoplan`, +`$boatstack-run`, and `$boatstack-update`. Claude Code and Gemini CLI receive +the same three skill identities. Cursor receives the same three slash commands. +The initializer and updater project them from one canonical driver, so a host +cannot acquire a separate workflow state machine. -The initial `status` read is observation only. After a mode is selected, the -Codex driver keeps one command-scoped goal, flow, worktree, actor, and authority +The initial `status` read is observation only. After an operation is selected, +the host driver keeps one command-scoped goal, flow, worktree, actor, and authority context through resolution and effects. An authority-free diagnostic frontier -cannot end an otherwise authorized invocation. +cannot end an otherwise authorized invocation. Untargeted resolution selects +only a transition that advances the configured goal; maintenance, correction, +abandonment, and caller-defined markers require separate explicit intent. +The host never derives slice order from plan prose or provider authority from an +authenticated GitHub session. ## Safety diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md deleted file mode 100644 index ff4bac39..00000000 --- a/boatstack/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: boatstack -description: Use when the user explicitly asks for Boatstack or when a V2 status query proves that the current exact worktree has an active managed delivery. Do not infer engagement from repository files, a saved plan, a branch name, or prior conversation. ---- - -# Boatstack V2 - -Boatstack owns delivery control. The coding agent owns implementation. - -## Select one Codex mode - -Recognize these mode names case-insensitively. The legacy spelling -`auto-plan` remains an alias for `Autoplan`. - -- `$boatstack Autoplan` selects the `approved-plan` terminal. -- `$boatstack Run` selects the `open-or-updated-pr` terminal. It never selects - merge authority. -- `$boatstack Update` selects the checksum-verified `installation.update` - path. It does not reclassify a product delivery. - -For bare `$boatstack`, present exactly three choices: `Autoplan`, `Run`, and -`Update`. Selecting a choice is identical to invoking that mode directly. -Mode selection chooses intent and a target only. It does not approve unseen -plan bytes, supply external-provider authority, authorize merge, or broaden -repository policy. - -## Observe once - -Run: - -```sh -boatstack status --repo . --format json -``` - -Use only the returned canonical snapshot and decision. Do not inspect or edit -machine state. `status` is observation only: an authority-free `FRONTIER` is a -diagnostic result, not the delivery verdict for an explicitly selected mode. -If no goal is configured, bind `Autoplan` or `Run` to a safe goal ID, delivery -ID, and the terminal selected above. `Update` preserves any configured product -goal and requests only `installation.update`. - -## Bind authority and follow the prescription - -After mode selection, create one command-scoped authority context containing -the exact goal ID, delivery ID, repository path, worktree, flow ID, actor, and -authority receipt paths actually supplied by the user or host. Do not synthesize -missing authority. Carry this same context through every `next`, `apply`, -`recover`, and re-resolution; never fall back to the authority-free status -decision. - -- Request the stable transition ID returned by `next`. -- Execute only the typed parameters declared by `catalog`. -- Preserve the complete `apply` response and stderr before interpreting it, - including admission, receipt, postcondition evidence, error, recovery, and - transaction fields. Do not pipe away or truncate those fields. -- Re-resolve with the same authority context after every complete receipt. -- Stop on `FRONTIER`, `BLOCKED`, `REFUSED`, or `UNRESOLVED` only when that - decision was produced by an authority-bearing resolution for the selected - mode. -- Treat `TERMINAL` as exact goal evidence, not an agent completion claim. - -Friendly CLI verbs are aliases only. The registry ID is authoritative. Use -`apply --transition ` or `recover --transition ` when an alias does -not exist. - -## Authority - -Human, autonomy, repository-policy, and provider authority are separate. -Repository authority comes from the current V2 configuration fingerprint. -External publication requires human or autonomy authority **and** an unexpired -provider receipt whose fingerprint binds the exact preview or correction body. -Never infer authority from authentication, path presence, prior approval, or a -successful command. - -## Workspaces - -`workspace.cut` transfers controller authority to the returned destination and -parks the source checkout. Continue only from that exact destination. -`workspace.cleanup` is legal only for proved landing or explicit abandonment; -it verifies completion from the preserved source checkout. - -## Safety and recovery - -Send shell commands to `boatstack guard --repo . --host --command -` when the host integration requests a guard decision. Never recreate -the destructive-command policy in prompt text. - -If observation reports `RECOVERY`, use only a transition listed in -`recovery_info.permitted` and pass the exact transaction ID. Do not retry an -unknown external effect. - -## Product evidence - -Plans, approvals, gate evidence, visual manifests, and publication previews are -repository-owned inputs. Presence is never authority. Visual capture enters the -delivery model only through `evidence.visual.attach`. Retrospective analysis is -passive and cannot advance a delivery. - -## Boundaries - -Read [workflow.md](references/workflow.md) for the event families, -[artifacts.md](references/artifacts.md) for ownership, and -[host-hook-contracts.md](references/host-hook-contracts.md) for RPC integration. - -Boatstack never authorizes merge. Do not merge unless the user separately and -explicitly asks. diff --git a/boatstack/agents/gemini.yaml b/boatstack/agents/gemini.yaml deleted file mode 100644 index 1188802b..00000000 --- a/boatstack/agents/gemini.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Boatstack (Gemini)" - short_description: "Use the shared Boatstack V2 delivery kernel from Gemini CLI." - default_prompt: "Use $boatstack V2. Read status once, follow the returned semantic transition, preserve exact goal and invocation identity, and send guarded commands to the shared kernel instead of reconstructing lifecycle or safety policy." diff --git a/boatstack/agents/openai.yaml b/boatstack/agents/openai.yaml deleted file mode 100644 index cf9d4420..00000000 --- a/boatstack/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Boatstack" - short_description: "Use one V2 kernel for delivery state, authority, effects, and proof." - default_prompt: "Use $boatstack Autoplan, $boatstack Run, or $boatstack Update; bare $boatstack presents those three choices. Treat status as observation only, preserve one command-scoped authority context through next, apply, recover, and re-resolution, and never infer authority or completion from files, branches, authentication, or prior conversation." diff --git a/boatstack/internal/effects/artifacts.go b/boatstack/internal/effects/artifacts.go index 21bb40b6..ae3c535e 100644 --- a/boatstack/internal/effects/artifacts.go +++ b/boatstack/internal/effects/artifacts.go @@ -281,6 +281,13 @@ func prepareArtifacts(layout ports.ControllerLayout, admission protocol.Admissio return nil, err } } + if transition.ID == "configuration.initialize" || transition.ID == "configuration.mutate" || transition.ID == "installation.initialize" || transition.ID == "installation.update" { + hostMutations, hostErr := prepareHostSkillMutations(layout.RepositoryRoot, state.EnabledHosts) + if hostErr != nil { + return nil, hostErr + } + mutations = append(mutations, hostMutations...) + } return mutations, nil } diff --git a/boatstack/internal/effects/host_skills.go b/boatstack/internal/effects/host_skills.go new file mode 100644 index 00000000..f41dc0f3 --- /dev/null +++ b/boatstack/internal/effects/host_skills.go @@ -0,0 +1,207 @@ +package effects + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" +) + +const hostSkillManifestSchema = 1 + +type hostSkillManifest struct { + SchemaVersion int `json:"schema_version"` + Files map[string]string `json:"files"` +} + +type hostSkillMode struct { + Slug string + DisplayName string + Description string + Target string + Extra string +} + +var hostSkillModes = []hostSkillMode{ + { + Slug: "boatstack-autoplan", DisplayName: "Boatstack Autoplan", + Description: "Create and approve a verified delivery plan.", + Target: "the `approved-plan` terminal", + Extra: "This trigger supplies planning intent only. It never approves unseen plan bytes.", + }, + { + Slug: "boatstack-run", DisplayName: "Boatstack Run", + Description: "Drive delivery to an open or updated pull request.", + Target: "the `open-or-updated-pr` terminal", + Extra: "This trigger never grants merge authority. Provider authority remains a separate verified receipt.", + }, + { + Slug: "boatstack-update", DisplayName: "Boatstack Update", + Description: "Apply a checksum-verified Boatstack update.", + Target: "the `installation.update` transition", + Extra: "This trigger does not reclassify or advance a product delivery.", + }, +} + +func renderHostSkill(mode hostSkillMode) []byte { + return []byte(fmt.Sprintf(`--- +name: %s +description: %s Use only when the user explicitly selects this Boatstack operation. +--- + +# %s + +Select %s. %s + +Run `+ + "`boatstack status --repo . --format json`"+` once for observation. An authority-free +`+"`FRONTIER`"+` from status is diagnostic only and cannot terminate this selected operation. + +Bind one command-scoped context containing the exact goal, delivery, repository, +worktree, flow, actor, and supplied authority receipts. Preserve that context +through every `+"`next`"+`, `+"`apply`"+`, `+"`recover`"+`, and re-resolution. Never synthesize missing +authority or infer it from authentication, files, branches, or prior conversation. + +Begin each cycle with an untargeted authority-bearing `+"`next`"+`. Apply only the +stable transition ID from the immediately preceding prescription and only its +declared parameters. Preserve the complete apply response and stderr, including +admission, receipt, postcondition, error, recovery, and transaction fields. +Re-resolve with the same context after every complete receipt. + +Stop only on an authority-bearing `+"`FRONTIER`"+`, `+"`BLOCKED`"+`, `+"`REFUSED`"+`, or +`+"`UNRESOLVED`"+` result for this operation. Treat `+"`TERMINAL`"+` as exact goal evidence. +If recovery is active, use only a transition in `+"`recovery_info.permitted`"+` and +the exact transaction ID. Never choose maintenance, correction, abandonment, +merge, provider, or destructive authority as an escape from a frontier. +`, mode.Slug, mode.Description, mode.DisplayName, mode.Target, mode.Extra)) +} + +func renderOpenAIMetadata(mode hostSkillMode) []byte { + return []byte(fmt.Sprintf(`interface: + display_name: %q + short_description: %q + default_prompt: %q +policy: + allow_implicit_invocation: false +`, mode.DisplayName, mode.Description, "Use $"+mode.Slug+" to follow the authority-preserving Boatstack V2 driver.")) +} + +func desiredHostSkillFiles(hosts []string) map[string][]byte { + desired := map[string][]byte{} + for _, host := range hosts { + for _, mode := range hostSkillModes { + skill := renderHostSkill(mode) + switch host { + case "codex": + root := filepath.ToSlash(filepath.Join(".agents", "skills", mode.Slug)) + desired[root+"/SKILL.md"] = skill + desired[root+"/agents/openai.yaml"] = renderOpenAIMetadata(mode) + case "claude": + desired[filepath.ToSlash(filepath.Join(".claude", "skills", mode.Slug, "SKILL.md"))] = skill + case "gemini": + desired[filepath.ToSlash(filepath.Join(".gemini", "skills", mode.Slug, "SKILL.md"))] = skill + case "cursor": + desired[filepath.ToSlash(filepath.Join(".cursor", "commands", mode.Slug+".md"))] = skill + } + } + } + return desired +} + +func prepareHostSkillMutations(repository string, hosts []string) ([]ports.ResourceMutation, error) { + desired := desiredHostSkillFiles(hosts) + manifestPath := filepath.Join(repository, ".boatstack", "host-skills.json") + manifestRaw, manifestExists, _, err := readAllIfExists(manifestPath) + if err != nil { + return nil, err + } + prior := hostSkillManifest{Files: map[string]string{}} + if manifestExists { + if err := json.Unmarshal(manifestRaw, &prior); err != nil || prior.SchemaVersion != hostSkillManifestSchema || prior.Files == nil { + return nil, fmt.Errorf("Boatstack host-skill manifest is malformed") + } + } + + for relative, expected := range prior.Files { + absolute, pathErr := managedHostSkillPath(repository, relative) + if pathErr != nil { + return nil, pathErr + } + current, exists, _, readErr := readAllIfExists(absolute) + if readErr != nil { + return nil, readErr + } + if !exists || sha256Bytes(current) != expected { + return nil, fmt.Errorf("Boatstack host skill %s changed outside the managed projection", relative) + } + } + + var mutations []ports.ResourceMutation + paths := make([]string, 0, len(desired)) + for relative := range desired { + paths = append(paths, relative) + } + sort.Strings(paths) + next := hostSkillManifest{SchemaVersion: hostSkillManifestSchema, Files: map[string]string{}} + for _, relative := range paths { + absolute, pathErr := managedHostSkillPath(repository, relative) + if pathErr != nil { + return nil, pathErr + } + current, exists, _, readErr := readAllIfExists(absolute) + if readErr != nil { + return nil, readErr + } + if exists && !manifestExists && !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { + return nil, fmt.Errorf("unmanaged file collides with Boatstack host skill %s", relative) + } + if !exists || !strings.EqualFold(sha256Bytes(current), sha256Bytes(desired[relative])) { + mutation, mutationErr := mutationFor(absolute, desired[relative], 0o644, false, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + } + next.Files[relative] = sha256Bytes(desired[relative]) + } + + for relative := range prior.Files { + if _, keep := desired[relative]; keep { + continue + } + absolute, pathErr := managedHostSkillPath(repository, relative) + if pathErr != nil { + return nil, pathErr + } + mutation, mutationErr := mutationFor(absolute, nil, 0o644, false, true) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + } + + nextRaw, err := encodeJSON(next) + if err != nil { + return nil, err + } + if !manifestExists || string(manifestRaw) != string(nextRaw) { + mutation, mutationErr := mutationFor(manifestPath, nextRaw, 0o644, true, false) + if mutationErr != nil { + return nil, mutationErr + } + mutations = append(mutations, mutation) + } + return mutations, nil +} + +func managedHostSkillPath(repository, relative string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(relative)) + if filepath.IsAbs(clean) || clean == "." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid managed host-skill path %q", relative) + } + return filepath.Join(repository, clean), nil +} diff --git a/boatstack/internal/effects/host_skills_test.go b/boatstack/internal/effects/host_skills_test.go new file mode 100644 index 00000000..f964019a --- /dev/null +++ b/boatstack/internal/effects/host_skills_test.go @@ -0,0 +1,133 @@ +package effects + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/kernel/ports" +) + +func TestHostSkillProjectionExposesExactlyThreeOperationsPerInteractiveHost(t *testing.T) { + // control-law: enabled-hosts-receive-exactly-three-canonical-operation-skills + files := desiredHostSkillFiles([]string{"cli", "cursor", "codex", "claude", "gemini", "mcp"}) + counts := map[string]int{} + for path, raw := range files { + for _, mode := range hostSkillModes { + if strings.Contains(path, mode.Slug) && strings.HasSuffix(path, "SKILL.md") || strings.HasSuffix(path, mode.Slug+".md") { + if !strings.Contains(string(raw), "name: "+mode.Slug) || !strings.Contains(string(raw), mode.Target) { + t.Fatalf("%s does not bind %s to %s", path, mode.Slug, mode.Target) + } + } + } + switch { + case strings.HasPrefix(path, ".agents/") && strings.HasSuffix(path, "/SKILL.md"): + counts["codex"]++ + case strings.HasPrefix(path, ".claude/"): + counts["claude"]++ + case strings.HasPrefix(path, ".gemini/"): + counts["gemini"]++ + case strings.HasPrefix(path, ".cursor/"): + counts["cursor"]++ + } + } + for _, host := range []string{"codex", "claude", "gemini", "cursor"} { + if counts[host] != 3 { + t.Fatalf("%s discovered %d operation skills, want exactly 3", host, counts[host]) + } + } +} + +func TestHostSkillProjectionPreservesAuthorityBoundaries(t *testing.T) { + // control-law: operation-trigger-selects-target-without-broadening-authority + for path, raw := range desiredHostSkillFiles([]string{"cursor", "codex", "claude", "gemini"}) { + value := string(raw) + if strings.HasSuffix(path, "openai.yaml") { + continue + } + for _, contract := range []string{ + "authority-free\n`FRONTIER`", "command-scoped context", "every `next`, `apply`, `recover`, and re-resolution", + "complete apply response and stderr", "authority-bearing `FRONTIER`", "Never synthesize missing\nauthority", + } { + if !strings.Contains(value, contract) { + t.Fatalf("%s is missing authority contract %q", path, contract) + } + } + } +} + +func TestHostSkillProjectionFailsClosedOnUnmanagedCollision(t *testing.T) { + // control-law: unmanaged-host-file-cannot-be-overwritten-by-installation + repository := t.TempDir() + path := filepath.Join(repository, ".agents", "skills", "boatstack-run", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("user owned\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := prepareHostSkillMutations(repository, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "unmanaged file collides") { + t.Fatalf("collision was not rejected: %v", err) + } + if raw, err := os.ReadFile(path); err != nil || string(raw) != "user owned\n" { + t.Fatalf("collision mutated user file: %q %v", raw, err) + } +} + +func TestHostSkillProjectionRejectsManagedDrift(t *testing.T) { + // control-law: manifest-binds-update-bytes + repository := t.TempDir() + mutations, err := prepareHostSkillMutations(repository, []string{"codex", "gemini"}) + if err != nil { + t.Fatal(err) + } + applyMutationsForTest(t, mutations) + gemini := filepath.Join(repository, ".gemini", "skills", "boatstack-run", "SKILL.md") + if err := os.WriteFile(gemini, []byte("drift\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := prepareHostSkillMutations(repository, []string{"codex"}); err == nil || !strings.Contains(err.Error(), "changed outside") { + t.Fatalf("managed drift was not rejected: %v", err) + } +} + +func TestHostSkillProjectionRemovesOnlyManagedDisabledHosts(t *testing.T) { + // control-law: host-removal-deletes-only-manifest-owned-projections + repository := t.TempDir() + mutations, err := prepareHostSkillMutations(repository, []string{"codex", "gemini"}) + if err != nil { + t.Fatal(err) + } + applyMutationsForTest(t, mutations) + unmanaged := filepath.Join(repository, ".gemini", "skills", "unrelated", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(unmanaged), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(unmanaged, []byte("unrelated\n"), 0o644); err != nil { + t.Fatal(err) + } + mutations, err = prepareHostSkillMutations(repository, []string{"codex"}) + if err != nil { + t.Fatal(err) + } + applyMutationsForTest(t, mutations) + if _, err := os.Stat(filepath.Join(repository, ".gemini", "skills", "boatstack-run", "SKILL.md")); !os.IsNotExist(err) { + t.Fatalf("disabled managed Gemini skill still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(repository, ".agents", "skills", "boatstack-run", "SKILL.md")); err != nil { + t.Fatalf("enabled Codex skill was removed: %v", err) + } + if raw, err := os.ReadFile(unmanaged); err != nil || string(raw) != "unrelated\n" { + t.Fatalf("unmanaged neighboring skill changed: %q %v", raw, err) + } +} + +func applyMutationsForTest(t *testing.T, mutations []ports.ResourceMutation) { + t.Helper() + prepared := &preparedEffect{mutations: mutations} + if _, err := prepared.Execute(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/internal/kernel/catalog/transition.go b/boatstack/internal/kernel/catalog/transition.go index 55fd099c..990eccf2 100644 --- a/boatstack/internal/kernel/catalog/transition.go +++ b/boatstack/internal/kernel/catalog/transition.go @@ -192,6 +192,26 @@ type Transition struct { func (t Transition) Controllable() bool { return t.Class.Controllable() } +// ImplicitlySelectable reports whether an untargeted resolution may choose the +// transition as delivery progress. Maintenance, correction, abandonment, and +// caller-defined markers remain available through an explicit requested +// transition, but cannot outrank the configured goal by merely being +// admissible from the same snapshot. +func (t Transition) ImplicitlySelectable() bool { + switch t.ID { + case "engagement.renew", "engagement.release", + "invocation.rebind", "repository.attach", "repository.detach", + "runtime.replace", "configuration.mutate", "installation.update", + "plan.amend", "plan.invalidate", "plan.abandon", + "workspace.sync", "workspace.publish", "workspace.cleanup", "workspace.reap", "workspace.abandon", + "gate.change.record", "gate.journey.record", "evidence.approval.revoke", "delivery.slice.advance", + "publication.correct", "publication.abandon": + return false + default: + return true + } +} + func (t Transition) SupportsGoal(goal model.Goal) bool { if len(t.GoalKinds) == 0 { return true diff --git a/boatstack/internal/kernel/supervisor/supervisor.go b/boatstack/internal/kernel/supervisor/supervisor.go index 6c0ba688..f78bcaf0 100644 --- a/boatstack/internal/kernel/supervisor/supervisor.go +++ b/boatstack/internal/kernel/supervisor/supervisor.go @@ -2,6 +2,7 @@ package supervisor import ( "fmt" + "strings" "github.com/operatorstack/boatstack/boatstack/internal/kernel/catalog" "github.com/operatorstack/boatstack/boatstack/internal/kernel/model" @@ -40,7 +41,7 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority base.Kind, base.Reason = DecisionUnresolved, "terminal or phase evidence is not known" return base } - if snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && requested != "goal.configure" { + if requested != "" && snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value != goal && requested != "goal.configure" { base.Kind, base.Reason = DecisionRefused, "requested goal differs from configured goal; goal.configure is required" return base } @@ -91,30 +92,26 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority base.Kind, base.Reason, base.Transition = DecisionPrescribed, "requested transition is admissible", &transition return base } - available := make([]catalog.Transition, 0, len(admissible)) + selectable := make([]catalog.Transition, 0, len(admissible)) for _, candidate := range admissible { - if allowed, _ := policyAllows(snapshot, candidate); allowed && authoritySatisfies(snapshot, candidate, authority) { - available = append(available, candidate) + if !candidate.ImplicitlySelectable() || targetAlreadySatisfied(snapshot, goal, candidate) { + continue } - } - if len(available) == 0 { - if len(admissible) > 0 { - base.Kind, base.Reason = DecisionFrontier, "admissible transitions require unavailable authority" - for _, candidate := range admissible { - base.Candidates = append(base.Candidates, candidate.ID) - } - return base + if allowed, _ := policyAllows(snapshot, candidate); allowed { + selectable = append(selectable, candidate) } + } + if len(selectable) == 0 { if snapshot.Phase.Value == model.PhaseRecovery || snapshot.Phase.Value == model.PhaseUnresolved { base.Kind, base.Reason = DecisionBlocked, "no registered recovery transition is admissible" return base } - base.Kind, base.Reason = DecisionUnresolved, "no transition is safely selectable from current evidence" + base.Kind, base.Reason = DecisionUnresolved, "no goal-progressing transition is safely selectable from current evidence" return base } - topPriority := available[0].Priority + topPriority := selectable[0].Priority var top []catalog.Transition - for _, candidate := range available { + for _, candidate := range selectable { if candidate.Priority == topPriority { top = append(top, candidate) } @@ -126,10 +123,43 @@ func (s Supervisor) Resolve(snapshot model.Snapshot, goal model.Goal, authority } return base } + if !authoritySatisfies(snapshot, top[0], authority) { + base.Kind, base.Reason = DecisionFrontier, "next goal-progressing transition requires unavailable authority" + base.Candidates = []catalog.TransitionID{top[0].ID} + return base + } base.Kind, base.Reason, base.Transition = DecisionPrescribed, "deterministic highest-priority transition", &top[0] return base } +func targetAlreadySatisfied(snapshot model.Snapshot, goal model.Goal, transition catalog.Transition) bool { + if transition.ID == "goal.configure" { + return snapshot.Goal.Status == model.FactKnown && snapshot.Goal.Value == goal + } + if gate, ok := catalog.GateName(transition.ID); ok { + return currentEvidenceRecorded(snapshot, "gate-evidence:"+gate+":") + } + if transition.ID == "evidence.visual.attach" { + if snapshot.ConfigurationPolicy.Status != model.FactKnown || snapshot.ConfigurationPolicy.Value.VisualEvidence != "required" { + return true + } + return currentEvidenceRecorded(snapshot, "visual-evidence:") + } + return transition.TargetMatches(snapshot) +} + +func currentEvidenceRecorded(snapshot model.Snapshot, sourcePrefix string) bool { + if snapshot.Verification.Status != model.FactKnown || snapshot.Verification.Value != model.VerificationCurrent { + return false + } + for _, evidence := range snapshot.Verification.Evidence { + if strings.HasPrefix(evidence.Source, sourcePrefix) { + return true + } + } + return false +} + func hostEnabled(hosts []string, host string) bool { for _, candidate := range hosts { if candidate == host { diff --git a/boatstack/internal/kernel/supervisor/supervisor_test.go b/boatstack/internal/kernel/supervisor/supervisor_test.go index bccc8ef0..71dd1f6d 100644 --- a/boatstack/internal/kernel/supervisor/supervisor_test.go +++ b/boatstack/internal/kernel/supervisor/supervisor_test.go @@ -45,6 +45,32 @@ func goalFor() model.Goal { return model.Goal{ID: "goal", Kind: model.GoalVerified, DeliveryID: "delivery"} } +func recanonicalize(t *testing.T, snapshot model.Snapshot) model.Snapshot { + t.Helper() + result, err := model.Canonicalize(snapshot.Observation) + if err != nil { + t.Fatal(err) + } + return result +} + +func openPRSnapshot(t *testing.T, recordedGates ...string) (model.Snapshot, model.Goal) { + t.Helper() + snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) + goal := model.Goal{ID: "goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"} + evidence := snapshot.Verification.Evidence[0] + snapshot.Goal = model.Known(goal, evidence) + snapshot.Plan = model.Known(model.PlanLocked, evidence) + snapshot.Publication = model.Known(model.PublicationCandidate, evidence) + snapshot.Verification = model.Known(model.VerificationCurrent, evidence) + for _, gate := range recordedGates { + gateEvidence := evidence + gateEvidence.Source = "gate-evidence:" + gate + ":/fixture/" + gate + ".json" + snapshot.Verification.Evidence = append(snapshot.Verification.Evidence, gateEvidence) + } + return recanonicalize(t, snapshot), goal +} + func TestTerminalGoalOutranksLocalTransitions(t *testing.T) { // control-law: configured-terminal-outranks-local-lifecycle s := New(catalog.Default()) @@ -79,6 +105,85 @@ func TestTerminalEvidenceForOldGoalDoesNotTerminateNewGoal(t *testing.T) { } } +func TestUntargetedResolutionReconfiguresDifferentGoalAndSkipsSatisfiedGoal(t *testing.T) { + // control-law: untargeted-resolution-must-advance-the-exact-goal + snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + + newGoal := model.Goal{ID: "new-goal", Kind: model.GoalOpenPR, DeliveryID: "delivery"} + decision := New(catalog.Default()).Resolve(snapshot, newGoal, authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "goal.configure" { + t.Fatalf("different-goal decision = %#v, want goal.configure", decision) + } + + snapshot.Plan = model.Known(model.PlanValid, snapshot.Plan.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "plan.approve" { + t.Fatalf("exact-goal decision = %#v, want plan.approve without goal.configure stutter", decision) + } +} + +func TestUntargetedResolutionExcludesExplicitControlTransitions(t *testing.T) { + // control-law: untargeted-resolution-cannot-invent-repair-or-slice-intent + snapshot := snapshotFor(t, model.PhaseActive, model.TerminalNonterminal) + snapshot.Plan = model.Known(model.PlanLocked, snapshot.Plan.Evidence[0]) + snapshot.Verification = model.Known(model.VerificationUnverified, snapshot.Verification.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + + decision := New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.build.record" { + t.Fatalf("untargeted decision = %#v, want gate.build.record", decision) + } + decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "plan.invalidate") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "plan.invalidate" { + t.Fatalf("explicit invalidation decision = %#v, want requested plan.invalidate", decision) + } + decision = New(catalog.Default()).Resolve(snapshot, goalFor(), authority, "delivery.slice.advance") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "delivery.slice.advance" { + t.Fatalf("explicit slice-marker decision = %#v, want requested delivery.slice.advance", decision) + } +} + +func TestUntargetedResolutionUsesCurrentGateEvidenceForProgress(t *testing.T) { + // control-law: verified-gate-progress-is-derived-from-canonical-evidence + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + snapshot, goal := openPRSnapshot(t, "build") + snapshot.Publication = model.Known(model.PublicationNone, snapshot.Publication.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + + decision := New(catalog.Default()).Resolve(snapshot, goal, authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "gate.test.record" { + t.Fatalf("one-gate decision = %#v, want gate.test.record", decision) + } + + snapshot, goal = openPRSnapshot(t, "build", "test", "review") + snapshot.Publication = model.Known(model.PublicationNone, snapshot.Publication.Evidence[0]) + snapshot = recanonicalize(t, snapshot) + decision = New(catalog.Default()).Resolve(snapshot, goal, authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.preview" { + t.Fatalf("complete-gates decision = %#v, want publication.preview", decision) + } +} + +func TestUntargetedResolutionStopsAtSelectedProviderBoundary(t *testing.T) { + // control-law: unavailable-authority-cannot-be-skipped-for-a-lower-priority-effect + snapshot, goal := openPRSnapshot(t, "build", "test", "review") + supervisor := New(catalog.Default()) + authority := catalog.AuthoritySet{catalog.AuthorityHuman: true, catalog.AuthorityRepository: true} + + decision := supervisor.Resolve(snapshot, goal, authority, "") + if decision.Kind != DecisionFrontier || len(decision.Candidates) != 1 || decision.Candidates[0] != "publication.execute" { + t.Fatalf("provider-free decision = %#v, want publication.execute FRONTIER", decision) + } + authority[catalog.AuthorityProvider] = true + decision = supervisor.Resolve(snapshot, goal, authority, "") + if decision.Kind != DecisionPrescribed || decision.Transition == nil || decision.Transition.ID != "publication.execute" { + t.Fatalf("provider-authorized decision = %#v, want publication.execute", decision) + } +} + func TestRequestedTransitionRequiresExactAuthority(t *testing.T) { // control-law: useful-action-is-not-effect-authority s := New(catalog.Default()) diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index 3bf86371..b1bba642 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -493,7 +493,7 @@ func observeRepositoryArtifacts(layout ports.ControllerLayout, state durable.Sta } transitionID, known := gateNames[gate.Gate] path := filepath.Join(layout.EvidenceRoot, deliveryID, gate.Gate+".json") - evidence, _, exists, err := fileEvidence(path, "gate-evidence", now) + evidence, _, exists, err := fileEvidence(path, "gate-evidence:"+gate.Gate, now) if err != nil { return plan, verification, terminal, nil, nil, err } diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index f0f17ab0..80f4f5b7 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -29,3 +29,8 @@ Hosts may render commands differently. They may not change the transition ID, goal, source predicate, authority clauses, parameters, or expected postcondition. CLI, Cursor, Codex, Claude, Gemini, and MCP are capability labels, not controllers. + +A host that claims it can complete external publication must expose a provider +receipt issuer before the delivery begins. If that capability is absent, the +host must declare that it can progress only to the authority-bearing +publication frontier. Authentication state is not a receipt issuer. diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 5d0303e1..ccf843b3 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -20,6 +20,10 @@ Event families: The supervisor returns one of `PRESCRIBED`, `TERMINAL`, `FRONTIER`, `BLOCKED`, `REFUSED`, or `UNRESOLVED`. Only `PRESCRIBED` can produce an admission. Only an independently verified postcondition can produce a receipt. +Untargeted resolution excludes transitions whose target is already established +and transitions that encode separate maintenance, repair, abandonment, or +caller-defined marker intent. Requested resolution can still admit those +transitions when the caller explicitly selected them. A normal verified-delivery path is: @@ -36,5 +40,9 @@ PR delivery continues through `publication.preview`, `publication.execute`, and `publication.observe`. Merged delivery also needs the exact workspace to become `landed`. +`delivery.slice.advance` records an explicit opaque marker only. It does not +parse a plan, persist a slice cursor, define PR cardinality, or advance a +publication sequence. + Recovery outranks ordinary progress. Goal reconfiguration is explicit and may change an active delivery only with human or autonomy authority. diff --git a/install.ps1 b/install.ps1 index 877cacd2..39a5c3d3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -89,7 +89,7 @@ try { Move-Item -LiteralPath $LauncherTemporary -Destination $Launcher -Force Write-Host "Boatstack V2 installed at $Runtime" - Write-Host "Review and commit $Repository\.boatstack\project.json" + Write-Host "Review and commit $Repository\.boatstack\project.json and the generated host skills" Write-Host "Run: $Launcher doctor --repo `"$Repository`" --format text" } finally { Remove-Item -LiteralPath $Temporary -Recurse -Force -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index 2782d246..89714560 100755 --- a/install.sh +++ b/install.sh @@ -95,5 +95,5 @@ ln -s "$(basename "$runtime")" "$launcher_temp" mv -f "$launcher_temp" "$install_dir/boatstack" echo "Boatstack V2 installed at $runtime" -echo "Review and commit $repository/.boatstack/project.json" +echo "Review and commit $repository/.boatstack/project.json and the generated host skills" echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/release-notes/2026-08-11-goal-progressing-resolution.md b/release-notes/2026-08-11-goal-progressing-resolution.md new file mode 100644 index 00000000..969f42d8 --- /dev/null +++ b/release-notes/2026-08-11-goal-progressing-resolution.md @@ -0,0 +1,8 @@ +### Keep goal resolution and operation discovery aligned + +Untargeted coding-agent delivery runs now avoid already-satisfied and explicitly +requested maintenance transitions, preserve authority frontiers, and keep +plan-slice markers from implying publication order. Every enabled interactive +host now discovers exactly three Boatstack operations: Autoplan, Run, and +Update. Installation and updates preserve user-owned collisions instead of +overwriting them.