diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bd2449..a22f236 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ # Contributing -Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/3c9ed10dcf0c6781023a547effb4469879b8a375/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/53f0f064a116c6d0edbacd2b07dba935481bba96/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 5375dd3..c83db56 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 96335, - "estimated_tokens": 24084, + "characters": 97918, + "estimated_tokens": 24480, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,14 +12,14 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "9b54670bd54b1bc7e5375667af4b6c17f420d81789a5f7e8cb7533ed8ff73f9a", + "CONTRIBUTING.md": "3fd277a4f4bc4ea63b33094aa52671235609e363a0073ac949371b67172b4c38", "README.md": "534091974042589c31978080b0268761164f2279850f02c30e07f48945ef2321", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", "assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346", "boatstack/AGENTS.md": "bc76221e1fe90a91afbacd7c6bc9b41a70e6c10fc128c275a6a0b9bc094d9506", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", - "boatstack/SKILL.md": "055f2be8a3cfa63a9f6110b8e31561b8dda3b193c31e4c7527a0e4bd3eeec7ac", + "boatstack/SKILL.md": "c0f8ace95892e5caac1fa98e68e15150afacbef8e48a228c503f7140ce8d0463", "boatstack/activation.go": "deb7712d20aed37371254596613bfaccfc05fcb59634408b03378d1a0bf693ea", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", @@ -69,11 +69,11 @@ "boatstack/delivery_terminal_conformance_test.go": "50b88bdfb9bdfc9dc50c11f46276e1eb13eed61618e75b4c951dfa2ac6075df0", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "706be08bdc89192cf200f0b2cf889c8f4bb0e666a5d8a557996d7215101ce2ca", - "boatstack/denial.go": "3b6419412c7672ca2dd9cf80140675b8326d6b54460bbcb650cf3dde48dddd56", + "boatstack/denial.go": "e3eb8007e7a98f3aad48304e85fd88cfebe40e9a7ff3f23263c43864b0ee5933", "boatstack/denial_escalation_conformance_test.go": "d50f0e1c803c8f5731a46dbe6f82c7935c0ccbd582f513cbe07211f5e902157e", "boatstack/denial_ledger.go": "a35bf8fd8c1f6b9302109cf0087e1b9158e43e07b92b18df5e589a637d58491d", - "boatstack/denial_solutions.go": "7d1cae6b8a5fd373795f8a83f62861c79a931e630972ea04490378e0400ab0fc", - "boatstack/denial_solutions_conformance_test.go": "0b329dabffbc2666dffd2ff9e2b7d9d27816472d6fbee91a47a79c67527dce8d", + "boatstack/denial_solutions.go": "097141290cc684ea688887ae37dfdbbf3cffecf43fe12f64f22e09496d71cee6", + "boatstack/denial_solutions_conformance_test.go": "aace80640a4e0d331263bb3f82172ad8ff2e4a27e0544000fca3d475a8afb9aa", "boatstack/denial_test.go": "9dc9f0f79328c4947073efaa785479b34e70eb214da57cd72348f39fd672e4fd", "boatstack/detached.go": "3e3f2b81e2d79107ede2adc55fa296d0f487aa90d8d847d3cac2aa82120c3b12", "boatstack/detached_external_config_conformance_test.go": "6554739e80d32672f472fa55b1599e9594f4ba8790eb9939c3aef9e883316f1c", @@ -82,11 +82,11 @@ "boatstack/detached_test.go": "2cd744335a80b9fbc2db8fa7955658dd31691c43150d67bf15029d06ce84277a", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "96534cf341b8ae43f569fd1c3d25d4bf88cb7afc7418689457b461d4d2895082", - "boatstack/export_test.go": "9157acf1993aa75b825cdf771798cc1c056054c4ddc725ad735f4fa9eac73193", + "boatstack/export.go": "86eeea828a0bb354114a6606aeb2a1bb1f374c10f7af0363797e5406ab69a290", + "boatstack/export_test.go": "076cc6c31ceadbc8c4ba0ebdff047490a84a2cd8d7638ddfbb2835800fdc65d0", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "c6a4b3db41570ab92efc7f90115414e9a86dcd26cd2b7f7999d8d171e005e82a", + "boatstack/flow_control.go": "75de1b2fcb2468c6a36de3c31c4da95d31f8ccf14d211583386e57b2d2a9041c", "boatstack/flow_control_test.go": "02d788c83be55ebd79ffc73875bfd019de45325151eb1f70f506980eb8e77f29", "boatstack/flow_drive.go": "90f57e178884aff017195a126954ac0aeb85f27707b9d42844323341dc1fefd9", "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", @@ -98,7 +98,7 @@ "boatstack/flow_prescribe_conformance_test.go": "42dc20b26472531deedcaace94cf0ca5d086f0d76d1d91b37e38fc0138283ddf", "boatstack/flow_report.go": "a31d0764725eb38f717bab1e5509da82860d3bed23dd2b65ff388bb39573f418", "boatstack/flow_report_test.go": "ec989f2f14c7ae52840f61822a11dd0ac91055390f775d6a4b04da4993166b50", - "boatstack/flow_solutions.go": "78d639ebd1012326f3e7e67cb5a4068de24898148a29db9176a1237af932f6aa", + "boatstack/flow_solutions.go": "f9674a970ca018b33865b49809c7e3aee138d06420860a98e5e3feaeba3c2299", "boatstack/flow_tasks.go": "a3a8699ac0fd6cda3a3420acf83cc5881c5a821b17a3f9cf0d20da460c6e5252", "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", "boatstack/flow_trace.go": "5097d90f02f69ed49f25a04098fd0f2b9c62f4406fce8314c79ccc4a6dbf2763", @@ -166,14 +166,16 @@ "boatstack/next_test.go": "6b5ec46ecf1a197d7644846cecbb6d99873a06b7c4e5562772b5016fa0a4cb11", "boatstack/operation.go": "63a58c3e1247624b4dcd71d0c8d6f19818b1d17ff128e019564841949e3df659", "boatstack/operation_test.go": "9580b71ed4fa70cf73f02975737c824484341fa6751e670e7d183e898e1ffdde", - "boatstack/paths.go": "17f50de1eeefc4e023383eadae3f41bc2813a666baa5eb967650505237236bba", + "boatstack/paths.go": "621b7bcad09ba9372140a4bae1a4cf7c24f14e3b4875193d82c367609a5bf6f7", "boatstack/plan.go": "04ad740d2aab9802baefc963534965c3ea1a1bd19238569c3a3e54bea435cde6", "boatstack/plan_test.go": "1b01e7d9d7794eb11c998e19a2f3532d3b8509d984eca934cf5f1662a0a7e573", "boatstack/plan_validation.go": "06ff8fa8c22525bd371848a7776682a2b041ea5e1aacc913170856d0dda83edd", "boatstack/plan_validation_test.go": "ce17eb7449c1d9ec2e829943082bb9680414e02b8f0d5049418bace5488bbedb", - "boatstack/planning.go": "3263587dea8d055bdfb8bf791d98a0a0a1abcb11433e739f2bfa8a8a882cf58b", - "boatstack/planning_first_write_conformance_test.go": "873097aa9384b75bf01e74a475f3ec2ac7cca4a28f733e82f2c82959032c6a30", - "boatstack/planning_test.go": "06ec7022222d926040c3ae28b84ab50c3d2f804ae6473e61b303804dd992d884", + "boatstack/planning.go": "2f1f28930740d5139d5838ff250ed329676c02f1b41500ab1494bc150d8cbfd4", + "boatstack/planning_first_write_conformance_test.go": "eb8725417ea5704dff89d09d03602918e1d5f8eeac7e7002cbb4518aaa80104f", + "boatstack/planning_test.go": "c5966d293a0e71b0c8b4391368b837e807737b9fa7c5c2604758240ac6c03017", + "boatstack/planning_transport.go": "8beaa1cc139e55f1899dc7ae4280ac18183b1a87c1ef22122d63e2ba55bc7434", + "boatstack/planning_transport_conformance_test.go": "e1ac2c9d410f07037dec0d065aed2117957a88ca99aa0b0403772bf14ead0a77", "boatstack/post_publish_prescribe_conformance_test.go": "3c20d359ff84648db7dedb227b4d64e6574d9f41d3cdca0adefec1c60bfbf4ae", "boatstack/pr.go": "843f83e4a5d9ad0e997c6d8bd5795b6e8324f34f0f4264d2500933f1bcf36917", "boatstack/pr_phase.go": "59f8cbb75b6b538a5345474acd6a725450979579bf8ecf9591956cbbe1cc4737", @@ -197,7 +199,7 @@ "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "b52fef435362d2e80ab5569a0d145d5dd840ebd7cc77f02505b19588a7786386", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "53953e9ffb7cb6d375ecc951cd797e9af7f344d75787651076453c5541b1c224", + "boatstack/references/workflow.md": "cf25e53a58279cf7b3e1d8b3619a76ffc8b947e7035fcef31f1ccf21e420e772", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_budget_conformance_test.go": "05793600dac06bbf39075b15bc1262a9bd1e916738d801dcd6ff515771262b4c", @@ -210,13 +212,13 @@ "boatstack/runtime_cache.go": "6f6b023170cce982bf155e7c2fc7752cca2f7acff771967b4a523c1c13ea876f", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "71507e937a887707befdda03e9d0ed9803d4eeeaa38c9351fea2b8968f95ffa6", - "boatstack/safety_corpus_test.go": "824051705dd893338ac2246e2d75231703e36574cc9c23902237f72ab2d35960", + "boatstack/safety.go": "2c40922405e08c2bb2eb2381e8ad879673d14c73fa93ff6d2e68ea1df0b5f6af", + "boatstack/safety_corpus_test.go": "a91106d6bf3972bd13382faba9688cbe6da2d5f27df4f6da8adb0d223f57e847", "boatstack/safety_test.go": "7f40f09ce5cb2b815882a529ee7d8516e82b6686fe7e600085a3c04ece58b35a", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "5ebf971d2fb0d02b2144a47883067bca7c89863e4285933dfac6f2c5816d560a", - "boatstack/solution_closure_conformance_test.go": "f73e6748dac373e2a10bc9269c2f2e060bd220bb4113bd0d5ff66ca4c8e91a54", + "boatstack/solution_closure_conformance_test.go": "366c0f7d8ed427717572da38ad623ad50f14276af62cdc115939042d040a3ab4", "boatstack/statemap.go": "0db3a980f2fd00538498f6599b488a6719a5ee0f0764172834165e7d9d8f8057", "boatstack/statemap_conformance_test.go": "504debd406c1b5ad6cc9cb38715dea08954ef9c258cb5f91c59fb04d1398e53e", "boatstack/supervisory_control_test.go": "c7ea4bcd678e8ec211dac772c834981c4e21762914be2770a5e181bc24605e06", @@ -243,14 +245,14 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "2ceb050bb67737c725b66d7e191949c2f3b652ecbafc5e44002e99bc785114ab", - "docs/evidence-engineered-coding.md": "e8284c01dea52bb09df673f23588944757b08c831d3d61a106c5989744525e86", + "docs/evidence-engineered-coding.md": "b48fed361457a1a12b426a277447b946096d839956e7d7a8058aa13ab36a0da9", "docs/generated-files.md": "8679b960bacbdf2ca7b898aa44eb3a486ebb325eca8ce9cc4e316191e7ef5087", "docs/getting-started.md": "834e6d1c33d5198743a3f896c4e205801713762dcd2fa339e47c99b532df2cf5", - "docs/public-claims.json": "cc6879c53a2598b02e474436d92acd7f5ea7d4f6edf88c231d9ae190f0b84c5f", + "docs/public-claims.json": "93cc3bba122ad9519f1a280e8d0ef5bddafdb59efae08e9f8a5be631e441a6a0", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "1a2b84e0a4b9aa6322d35d6677ff52662c306089031295692b569233cdd94d8f", - "docs/troubleshooting.md": "84f61b8f61d0ca02274f2e91a4da17802b99bd4bbfe0d116674e1b9398e8192a", + "docs/troubleshooting.md": "252937c97d40197e9122e20f421f90616aecdd5ddf50cee8bdd348bc6c5caf32", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09", "install.ps1": "f48d0f26a26e806b780d10fa916c261ff9f84ab39758cf9e229f647836845e86", @@ -260,7 +262,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "ac9dd9b50170095d2a1c172695c0ed7e682d910eb834b106640ed2c93a436da8", + "labs/diagram-json/plan.lock.json": "95dfd06cfc3e25dade87d9770659f06904d98a55d95f666948d3f8a24ed6e339", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -416,12 +418,13 @@ "release-notes/2026-08-02-strengthen-screenshot-delivery.md": "2e56eeb08702f4dd58dce75e26a52fbcb7e48af0cd5dd062d4e2a204704705df", "release-notes/2026-08-04-external-authority-boundary.md": "0bc788db940f7fbc3624a01fff554d7002137e9915b10c47fd287dc1b24cdd66", "release-notes/2026-08-05-codex-operation-skills.md": "2402adbe3ef21762418648f6ac19ba282b8c9524546369e3f8aa76ad33d2c53d", - "release-notes/2026-08-05-detached-external-config.md": "b8b5ab914eae695f67c4e25deba3957894439e054b42ab93751bccb614235fda" + "release-notes/2026-08-05-detached-external-config.md": "b8b5ab914eae695f67c4e25deba3957894439e054b42ab93751bccb614235fda", + "release-notes/2026-08-08-literal-planning-transport.md": "d1abc9e64cb724fffa71fd81eeab2bb07bb982be28de1a50a845016cd878560a" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "3c9ed10dcf0c6781023a547effb4469879b8a375", + "commit": "53f0f064a116c6d0edbacd2b07dba935481bba96", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 4223d25..1286763 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -117,7 +117,7 @@ Before starting `/auto-plan` for a new feature, check `next-status --repo . --js 13. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative. 14. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`. 14. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names. -15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Author every feature artifact through the owned channel: pass the document to `boatstack-helper planning-write --repo . --feature --artifact ` on stdin — it is the primary writer for `.product-loop/features/`, not a fallback, and it remains available after the planning latch denies raw writes. Put the authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan /plan.md`; this command is read-only. The host's ordinary Markdown writer may be used only where the host explicitly permits it. Never use arbitrary shell redirection to evade a host write boundary. +15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Author every feature artifact through the owned channel: pass the complete document to `.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact ` using the literal planning transport in `.product-loop/workflow.md` — a single-quoted heredoc in a POSIX shell or the UTF-8-scoped single-quoted here-string in PowerShell. This is the primary writer for `.product-loop/features/`, not a fallback, and it remains available after the planning latch denies raw writes. Send the complete envelope in one tool call. Never run the helper without input, split the envelope across calls, use an expansion-capable delimiter, target another repository or helper, or paste Markdown at a shell prompt. Put the authoritative JSON inside the marked Boatstack block and run `.product-loop/bin/boatstack-helper check-plan --plan /plan.md`; this command is read-only. The host's ordinary Markdown writer may be used only where the host explicitly permits it. Never use arbitrary shell redirection to evade a host write boundary. 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/denial.go b/boatstack/denial.go index 8981cc1..de3f275 100644 --- a/boatstack/denial.go +++ b/boatstack/denial.go @@ -473,6 +473,13 @@ func denialFor(host string, finding SafetyFinding) Denial { } return d + 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.Reassurance = reassureUntouched + return d + case "workflow-state-invalid": d.Severity = SeverityAdvisory d.Qualifier = "delivery state unverified" @@ -511,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: `boatstack-helper planning-write --repo . --feature %s --artifact ` with the document on stdin — never a raw host write into `.product-loop/features/`.", slug) + d.Detail += fmt.Sprintf(" Planning Markdown is authored through the owned channel: one complete literal `.product-loop/bin/boatstack-helper planning-write --repo . --feature %s --artifact ` envelope from `.product-loop/workflow.md` — never a raw host write into `.product-loop/features/` or a manual shell paste.", slug) } d.Reassurance = reassureUntouched return d diff --git a/boatstack/denial_solutions.go b/boatstack/denial_solutions.go index 10ae73f..a92bb9d 100644 --- a/boatstack/denial_solutions.go +++ b/boatstack/denial_solutions.go @@ -28,9 +28,10 @@ func denialMarker(verb string) deliverycontrol.TransitionID { // no solution set, with the reason. The totality sweep fails a new category // until it gains an enumeration rule or an entry here. var denialSolutionExceptions = map[string]string{ - "malformed-tool-input": "the tool event itself is unreadable; the Detail already names diagnose-hook with the exact host", - "unsupported-host": "an unknown host has no trusted verb surface to enumerate", - "unresolved-repository": "without a repository identity no command can be assembled faithfully", + "malformed-tool-input": "the tool event itself is unreadable; the Detail already names diagnose-hook with the exact host", + "planning-transport-invalid": "the Markdown body is owed input; the Detail names both complete literal transport forms without fabricating content", + "unsupported-host": "an unknown host has no trusted verb surface to enumerate", + "unresolved-repository": "without a repository identity no command can be assembled faithfully", } // enumerateDenialSolutions computes the solution set for a denial finding. @@ -117,7 +118,7 @@ func enumerateDenialSolutions(repo, host string, finding SafetyFinding) Solution // The artifact name and its Markdown (stdin) are authored content — owed. appendSolution(&set, PrescribedCommand{ Verb: "planning-write", Args: append(repoFlagArgs(repo), "--feature", feature), - RequiresHumanInput: []string{"--artifact"}, + RequiresHumanInput: []string{"--artifact", planningMarkdownInput}, Transition: denialMarker("planning-write"), }) } diff --git a/boatstack/denial_solutions_conformance_test.go b/boatstack/denial_solutions_conformance_test.go index b4e1087..d183e9b 100644 --- a/boatstack/denial_solutions_conformance_test.go +++ b/boatstack/denial_solutions_conformance_test.go @@ -22,6 +22,7 @@ import ( // is neither enumerated nor excepted. var denialCategoryInventory = []SafetyFinding{ {Category: "malformed-tool-input", Reason: "empty-command", Source: "hook"}, + {Category: "planning-transport-invalid", Reason: "missing-terminator", Source: "planning-transport"}, {Category: "workflow-state-invalid", NextOperation: "discard-delivery", BlockingFeature: "stale", Source: "delivery-state"}, {Category: "workflow-state-tamper", Source: "delivery-state", AttemptedPath: ".git/boatstack/deliveries/demo/state.json"}, {Category: "workflow-phase-bypass", Source: "planning-state", WorkflowStage: "DRAFT_PLAN", NextOperation: "plan-gate", BlockingFeature: "demo"}, diff --git a/boatstack/export.go b/boatstack/export.go index 62e6942..78ab967 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -255,7 +255,7 @@ func normalizedAdapters(adapters []string) []string { func commandBody(operation, extra string) string { preflight := "" if operation == "auto-plan" { - preflight = `Before reading repository context or drafting artifacts, identify the path of the plan produced in the active host/system conversation — the user supplies it as the invocation argument, ` + "`/auto-plan `" + ` — and run the project-local helper with ` + "`check-source-plan --repo . --plan `" + `. Use its ` + "`SOURCE_PLAN`" + ` result. Boatstack does not scan directories for plans: ` + "`--plan`" + ` is required, so no unshipped saved plan becomes ambient context. If no plan path is available, stop and ask the user for the plan to build; do not create or guess a substitute inside auto-plan. The plan file must remain present and unchanged through build, so point ` + "`--plan`" + ` at a durable in-repo path, not an ephemeral scratch file; a path outside the repository is rejected because it cannot stay committed and hash-current through build. Author each known planning document through ` + "`boatstack-helper planning-write`" + ` on stdin — the owned channel for ` + "`.product-loop/features/`" + `, and the writer that remains valid once the planning latch denies raw writes; use the host's own Markdown writer only where it is explicitly permitted, and never bypass the host boundary with arbitrary shell redirection.` + preflight = `Before reading repository context or drafting artifacts, identify the path of the plan produced in the active host/system conversation — the user supplies it as the invocation argument, ` + "`/auto-plan `" + ` — and run the project-local helper with ` + "`check-source-plan --repo . --plan `" + `. Use its ` + "`SOURCE_PLAN`" + ` result. Boatstack does not scan directories for plans: ` + "`--plan`" + ` is required, so no unshipped saved plan becomes ambient context. If no plan path is available, stop and ask the user for the plan to build; do not create or guess a substitute inside auto-plan. The plan file must remain present and unchanged through build, so point ` + "`--plan`" + ` at a durable in-repo path, not an ephemeral scratch file; a path outside the repository is rejected because it cannot stay committed and hash-current through build. Author each known planning document through ` + "`boatstack-helper planning-write`" + ` using one complete literal envelope from ` + "`.product-loop/workflow.md`" + ` — the single-quoted POSIX heredoc or the UTF-8-scoped single-quoted PowerShell here-string. Never run a bare helper, split the envelope across calls, paste Markdown at a shell prompt, or bypass the host boundary with arbitrary redirection.` } return fmt.Sprintf(`# %s @@ -369,7 +369,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 project-local helper insight check --repo . --json. Display the complete Value Map, suggested topics, unknowns, returned preview fingerprint, and a prominent warning that the exact source and Value Map will enter the repository and may become public through Git history. Respond Insight ready to save and make the one next action: Reply `s` to save this exact insight as a repository diff. Only an exact state-scoped s for the currently displayed fingerprint authorizes piping the unchanged draft to insight save with the same preview nonce and fingerprint. If any source byte, map field, topic, nonce, or fingerprint changes, check again and require a new s. Never save on the initial request, on r, or when Value Map is unavailable. After a successful save respond Insight saved as a repository diff and show its ID and repository path. Do not create a feature, plan, branch, commit, or PR; publication remains a separate explicit action.", "insight-frontier": "Run the project-local helper insight frontier --repo . and present the independent captures needing classification, delivery, evidence, terminal observation, or human completion. This operation is strictly read-only: do not append events, change associations, bind deliveries, evaluate by mutation, disposition captures, or alter the authoritative delivery frontier. Respond Insight frontier ready and show one suggested pending action per capture without presenting any insight as Boatstack's single delivery next action.", "root-cause": "Perform failure-mode elimination on a bug, not a patch. This operation is strictly read-only: do not edit product code, create or update artifacts, advance a gate, or contact GitHub; the user supplies the symptom, stack trace, error log, or failing signal as the argument. Locate the failure below its surface symptom and classify it against the failure classes in @.product-loop/failure-moves.md; name the failure CLASS, not the one instance, and if no class fits, name the new class in that vocabulary. Investigate with read-only tools and produce a numbered root-cause chain in which every step is cited to file:line and which distinguishes the crashing frame (the victim) from the true origin (the cause); label authoritative repository facts DISCOVERED and any inference PROPOSED. State the blast radius: every other call site or path exposed to the same class. Propose the minimal STRUCTURAL elimination that makes the whole class unreachable and covers every exposed site, reusing an existing repository pattern or utility where one exists, rather than a local guard on the single line in the trace. Present this as a material product decision with the same tiered paths auto-plan uses under boundary_analysis: [1a] Symptom Patch or [1b] Programmatic Enforcement (a boundary that eliminates the class), and recommend one. Require a regression that reproduces the failure mode before the fix plus the project's own gates as the proof the class is gone, and name related latent hazards left out of scope as non-goals. Then format the result as a host Plan-mode source plan (symptom, root-cause chain, failure mode, blast radius, elimination, non-goals, verification, delivery base branch) and respond Root cause found, making the one next action: save this plan to a durable in-repo path and run auto-plan with it via --plan. Do not implement the fix; hand off to the plan gate.", - "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If an earlier hand-authored draft was never registered and its plan cannot be verified, the guard denies every product mutation at INVALID_STATE with next operation repair-state; run repair-state to quarantine that unregistered malformed draft and return to auto-plan, then re-author the planning Markdown through the owned planning-write channel (stdin), never a raw file write. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", + "auto-plan": "Take the plan produced in the host conversation, supplied explicitly via --plan (Boatstack never scans directories for plans), and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. When a scenario is relevant but no capability command resolves, surface a material provisioning decision with tiered paths: [1a] provision the capture capability now as its own ordered delivery slice, [1b] bundle the capture harness into the feature slice, or [1c] record the gap and defer; this is a surfaced choice, never an imposed framework. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions. If an earlier hand-authored draft was never registered and its plan cannot be verified, the guard denies every product mutation at INVALID_STATE with next operation repair-state; run repair-state to quarantine that unregistered malformed draft and return to auto-plan, then re-author the planning Markdown through the complete literal planning-write envelope in .product-loop/workflow.md, never a raw file write or manual shell paste. It is reversible, refuses any feature carrying a plan lock, pr.md, delivery state, tracked files, or an active or published delivery, and never edits product code.", "plan-gate": "Run check-plan read-only and present its plan fingerprint, baseline product diff fingerprint, changed paths, exact baseline diff when non-empty, and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the displayed baseline fingerprint, omitting it only when the baseline is clean, so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", "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.", diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 4fb5302..cdf1a00 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -279,7 +279,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, "PROPOSED") { + 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") { t.Fatal("auto-plan adapter does not enforce the Markdown and question boundaries") } // Conformance: no ambient plan context. The exported auto-plan adapter must @@ -438,6 +438,10 @@ 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)", + "PLANNING_TRANSPORT_INVALID", "## User-facing response contract", "Exactly one primary action", "### Reply shortcuts", diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 51ce6ef..8015385 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -261,10 +261,11 @@ type FlowNext struct { // PrescribedCommand is the exact next command that makes the oracle's lowest-cost // move. Args carries only auto-derivable flags (repo/feature/slice/gate/preview -// path/action). RequiresHumanInput names the flags that must be supplied by a -// human/CI and must NEVER be fabricated (evidence, gate status, the human-confirmed -// preview fingerprint, reviewer identity); those flags are deliberately absent from -// Args. AutoDerivable is true exactly when RequiresHumanInput is empty — the only +// path/action). RequiresHumanInput names flags or bounded content that must be +// supplied by a human/CI/authoring agent and must NEVER be fabricated (evidence, +// gate status, the planning document, the human-confirmed preview fingerprint, +// reviewer identity); those inputs are deliberately absent from Args. +// AutoDerivable is true exactly when RequiresHumanInput is empty — the only // commands the opt-in execute driver may run. Transition is the registry // TransitionID of an oracle move, or a planning./recovery.-prefixed marker for a // pre-activation prescription outside the delivery model; markers never pass the @@ -285,20 +286,61 @@ type PrescribedCommand struct { Program string `json:"program,omitempty"` } +const planningMarkdownInput = "stdin:markdown" + +func posixPlanningWord(value string) string { + if value != "" { + safe := true + for _, char := range []byte(value) { + if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || strings.ContainsRune("_@%+=:,./-", rune(char))) { + safe = false + break + } + } + if safe { + return value + } + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + // CommandLine renders the auto-derivable part of the prescribed command as a -// runnable string. Human-required flags are appended as explicit -// placeholders so the rendering is never a fabricated, runnable-as-is command -// when input is still owed. +// runnable string. Human-required inputs use explicit placeholders; +// planning Markdown is placed inside the same literal envelope the hook admits. +// The rendering is never fabricated or runnable as-is while input is still owed. func (p PrescribedCommand) CommandLine() string { + literalPlanningInput := false + for _, input := range p.RequiresHumanInput { + if input == planningMarkdownInput { + literalPlanningInput = true + break + } + } program := p.Program if program == "" { - program = "boatstack-helper" + if literalPlanningInput { + program = projectLocalHelperCommand() + } else { + program = "boatstack-helper" + } } parts := append([]string{program, p.Verb}, p.Args...) for _, flag := range p.RequiresHumanInput { + if flag == planningMarkdownInput { + literalPlanningInput = true + continue + } parts = append(parts, flag, "") } - return strings.Join(parts, " ") + line := strings.Join(parts, " ") + if literalPlanningInput { + for index := range parts { + parts[index] = posixPlanningWord(parts[index]) + } + line = strings.Join(parts, " ") + return line + " <<'BOATSTACK_PLAN_EOF'\n\nBOATSTACK_PLAN_EOF" + } + return line } // prescribeCommand assembles the runnable command for a forward delivery @@ -396,7 +438,7 @@ func prescribePlanning(repo string, status NextStatus) (*PrescribedCommand, stri Verb: "check-source-plan", Args: repoArgs, RequiresHumanInput: []string{"--plan"}, Transition: MarkerPlanningCheckSource, - }, "Then run auto-plan with the validated SOURCE_PLAN path; author every feature artifact through `boatstack-helper planning-write` (document on stdin).") + }, fmt.Sprintf("Then run auto-plan with the validated SOURCE_PLAN path; author every feature artifact through one complete literal `%s planning-write` envelope from `%s`.", projectLocalHelperCommand(), generatedWorkflowReference())) case "DRAFT_PLAN": return finish(&PrescribedCommand{ Verb: "check-plan", @@ -440,7 +482,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: `boatstack-helper planning-write --repo . --feature %s --artifact ` with the document on stdin.", slug)) + return finish(cmd, fmt.Sprintf("After repair, re-author the planning Markdown through the owned channel: one complete literal `%s planning-write --repo . --feature %s --artifact ` envelope from `%s`.", projectLocalHelperCommand(), slug, generatedWorkflowReference())) } return nil, "" default: diff --git a/boatstack/flow_solutions.go b/boatstack/flow_solutions.go index 0e926c1..3e4bc57 100644 --- a/boatstack/flow_solutions.go +++ b/boatstack/flow_solutions.go @@ -173,6 +173,7 @@ func prescribePlanningVerb(repo string, status NextStatus, verb string) (*Prescr } // The artifact name and its Markdown (stdin) are authored content — owed. cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, "--artifact") + cmd.RequiresHumanInput = append(cmd.RequiresHumanInput, planningMarkdownInput) case "record-approval": if status.Feature == "" { return nil, false diff --git a/boatstack/paths.go b/boatstack/paths.go index 9d4cb77..6c913ec 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -225,6 +225,22 @@ func (w WorkspaceContext) GeneratedRoot() string { return filepath.Join(w.configBase(), productLoopDirName) } +// HelperPath is the generated checkout runtime binary owned by this workspace. +func (w WorkspaceContext) HelperPath() string { + return filepath.Join(w.GeneratedRoot(), "bin", helperName()) +} + +// projectLocalHelperCommand is the portable repository-relative spelling used +// in rendered embedded-mode prescriptions. Path ownership remains centralized +// here even though the command is displayed before a concrete workspace exists. +func projectLocalHelperCommand() string { + return filepath.ToSlash(filepath.Join(productLoopDirName, "bin", helperName())) +} + +func 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 97cc3a0..610b0a0 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -228,10 +228,15 @@ 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(), ", ")) } - if !utf8.Valid(options.Content) { + // 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}) + if !utf8.Valid(content) { return "", fmt.Errorf("planning artifact must be valid UTF-8 Markdown") } - if strings.TrimSpace(string(options.Content)) == "" { + if strings.TrimSpace(string(content)) == "" { return "", fmt.Errorf("planning artifact must not be empty") } repo, err := ResolveRepository(options.Repo) @@ -246,7 +251,7 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if err := rejectSymlinkComponents(ctx.ExportRoot(), destination); err != nil { return "", err } - if err := atomicWrite(destination, options.Content); err != nil { + if err := atomicWrite(destination, content); err != nil { return "", err } relative, err := filepath.Rel(ctx.ExportRoot(), destination) diff --git a/boatstack/planning_first_write_conformance_test.go b/boatstack/planning_first_write_conformance_test.go index b8bdef8..9fa979d 100644 --- a/boatstack/planning_first_write_conformance_test.go +++ b/boatstack/planning_first_write_conformance_test.go @@ -27,7 +27,7 @@ func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) { repo := safetyTestRepo(t) for _, command := range []string{ - "boatstack-helper planning-write --repo . --feature checkout --artifact plan.md", + ".product-loop/bin/boatstack-helper planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "boatstack-helper check-source-plan --repo . --plan docs/plan.md", } { if findings := ClassifyCommand(repo, command); len(findings) > 0 { diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index 5d9a120..0381370 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -50,6 +50,30 @@ func TestPlanningWriteIsBoundedMarkdownOnly(t *testing.T) { } } +func TestPlanningWriteNormalizesPowerShellUTF8BOM(t *testing.T) { + repo := planningRepo(t) + path, err := WritePlanningArtifact(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) + } + written, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(path))) + if err != nil { + t.Fatal(err) + } + if string(written) != "# Plan\r\n" { + t.Fatalf("PowerShell transport BOM reached the Markdown artifact: %q", written) + } + if _, err := WritePlanningArtifact(PlanningWriteOptions{ + Repo: repo, Feature: "powershell-transport", Artifact: "questions.md", + Content: []byte{0xef, 0xbb, 0xbf}, + }); err == nil || !strings.Contains(err.Error(), "must not be empty") { + t.Fatalf("a BOM without Markdown must remain empty input: %v", err) + } +} + // Discoverability: the natural guess for an artifact token is "plan" — but the // tokens carry a .md suffix, so it is always wrong. The rejection must name the // accepted set and the transform at the point of failure, so the caller is not diff --git a/boatstack/planning_transport.go b/boatstack/planning_transport.go new file mode 100644 index 0000000..f2faf6e --- /dev/null +++ b/boatstack/planning_transport.go @@ -0,0 +1,382 @@ +package boatstack + +import ( + "path" + "path/filepath" + "regexp" + "runtime" + "strings" + "unicode" + "unicode/utf8" +) + +// Planning Markdown crosses a shell hook as literal data, not as executable +// command text. The guard admits exactly two envelopes: +// +// - a POSIX single-quoted heredoc; and +// - a PowerShell single-quoted here-string inside a local UTF-8 output scope. +// +// The parser removes only a structurally complete body from effect scanning. +// Any truncation, delimiter collision, expansion-capable delimiter, malformed +// helper command, or trailing command stays closed before the shell runs. +// control-law: planning-document-body-is-literal-data + +const powerShellPlanningEncodingLine = `$OutputEncoding = [System.Text.UTF8Encoding]::new($false)` + +var posixPlanningHeader = regexp.MustCompile(`^(.*\S)[ \t]+<<'([A-Za-z_][A-Za-z0-9_]{0,63})'[ \t]*$`) +var powerShellPlanningClose = regexp.MustCompile(`^'@[ \t]+\|[ \t]+&[ \t]+(.+)$`) +var planningWriteMention = regexp.MustCompile(`(?i)\bboatstack-helper(?:\.exe)?['"]?[ \t]+planning-write(?:[ \t]|$)`) + +type planningTransportInspection struct { + Matched bool + Header string + Content []byte + Feature string + Repository string + Executable string + InvalidReason string +} + +type planningWriteInvocation struct { + Executable string + Repository string + Feature string +} + +func structuralLine(value string) string { + return strings.TrimSuffix(value, "\r") +} + +func nextLine(value string, start int) (line string, next int, hasNewline bool) { + if start > len(value) { + return "", len(value), false + } + if relative := strings.IndexByte(value[start:], '\n'); relative >= 0 { + end := start + relative + return value[start:end], end + 1, true + } + return value[start:], len(value), false +} + +// literalCommandWords is a deliberately smaller grammar than either shell. It +// accepts ordinary quoted argv (including paths with spaces) but no expansion, +// compound syntax, glob, grouping, redirection, or pipeline syntax. The partial +// words returned on failure let the guard recognize a malformed planning-write +// attempt and deny it with the transport-specific recovery instead of executing +// a prefix with shell side effects. +func literalCommandWords(value string) (words []string, complete bool) { + runes := []rune(strings.TrimSpace(value)) + var current strings.Builder + var quote rune + inWord := false + flush := func() { + if inWord { + words = append(words, current.String()) + current.Reset() + inWord = false + } + } + for index := 0; index < len(runes); index++ { + char := runes[index] + if quote != 0 { + if char == quote { + quote = 0 + inWord = true + continue + } + if char == 0 || char == '\n' || char == '\r' || (quote == '"' && (char == '$' || char == '`')) { + flush() + return words, false + } + current.WriteRune(char) + inWord = true + continue + } + if unicode.IsSpace(char) { + flush() + continue + } + switch char { + case '\'', '"': + quote = char + inWord = true + case '\\': + inWord = true + if index+1 < len(runes) { + next := runes[index+1] + if unicode.IsSpace(next) || next == '\\' || next == '\'' || next == '"' { + current.WriteRune(next) + index++ + continue + } + } + // Preserve Windows separators and ordinary POSIX backslashes. + current.WriteRune(char) + case ';', '&', '|', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '[', ']', '#', 0: + flush() + return words, false + default: + current.WriteRune(char) + inWord = true + } + } + if quote != 0 { + flush() + return words, false + } + flush() + return words, true +} + +func portableExecutableBase(value string) string { + return strings.TrimSuffix(strings.ToLower(path.Base(strings.ReplaceAll(value, "\\", "/"))), ".exe") +} + +func planningWriteAttempt(value string) bool { + words, _ := literalCommandWords(value) + return len(words) >= 2 && portableExecutableBase(words[0]) == "boatstack-helper" && words[1] == "planning-write" +} + +func planningVerbInvocationAttempt(value string) bool { + words, _ := literalCommandWords(value) + return len(words) >= 2 && words[1] == "planning-write" +} + +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])) { + return true + } + if !hasNewline { + return false + } + position = next + } + return false +} + +func planningWriteHeader(value string) (planningWriteInvocation, bool) { + words, complete := literalCommandWords(value) + if !complete || len(words) < 2 || portableExecutableBase(words[0]) != "boatstack-helper" || words[1] != "planning-write" { + return planningWriteInvocation{}, false + } + values := map[string]string{} + for index := 2; index < len(words); index++ { + flag := words[index] + value := "" + if split := strings.IndexByte(flag, '='); split >= 0 { + value = flag[split+1:] + flag = flag[:split] + } else { + if index+1 >= len(words) { + return planningWriteInvocation{}, false + } + index++ + value = words[index] + } + if flag != "--repo" && flag != "--feature" && flag != "--artifact" { + return planningWriteInvocation{}, false + } + if value == "" || values[flag] != "" { + return planningWriteInvocation{}, false + } + values[flag] = value + } + if !featureSlugPattern.MatchString(values["--feature"]) || !planningArtifacts[values["--artifact"]] { + return planningWriteInvocation{}, false + } + repository := values["--repo"] + if repository == "" { + repository = "." + } + return planningWriteInvocation{Executable: words[0], Repository: repository, Feature: values["--feature"]}, true +} + +func planningTransportBinding(repo string, transport planningTransportInspection) string { + root, err := filepath.Abs(repo) + if err != nil { + return "repository-mismatch" + } + if canonical, canonicalErr := filepath.EvalSymlinks(root); canonicalErr == nil { + root = canonical + } + normalizedRepo := filepath.FromSlash(strings.ReplaceAll(transport.Repository, "\\", "/")) + targetRepo := normalizedRepo + if !filepath.IsAbs(targetRepo) { + targetRepo = filepath.Join(root, targetRepo) + } + targetRepo, err = filepath.Abs(targetRepo) + if err != nil { + return "repository-mismatch" + } + if canonical, canonicalErr := filepath.EvalSymlinks(targetRepo); canonicalErr == nil { + targetRepo = canonical + } + if filepath.Clean(targetRepo) != filepath.Clean(root) { + return "repository-mismatch" + } + + normalizedExecutable := filepath.FromSlash(strings.ReplaceAll(transport.Executable, "\\", "/")) + executable := normalizedExecutable + if !filepath.IsAbs(executable) { + executable = filepath.Join(root, executable) + } + executable, err = filepath.Abs(executable) + if err != nil { + return "helper-path-mismatch" + } + if canonical, canonicalErr := filepath.EvalSymlinks(executable); canonicalErr == nil { + executable = canonical + } + base := strings.ToLower(filepath.Base(executable)) + canonicalBase := strings.ToLower(helperName()) + if base != canonicalBase && !(runtime.GOOS == "windows" && base == "boatstack-helper") { + return "helper-path-mismatch" + } + workspace, workspaceErr := ResolveWorkspaceContext(root) + if workspaceErr != nil { + return "workspace-binding-unverified" + } + expected := workspace.HelperPath() + if runtime.GOOS == "windows" && base == "boatstack-helper" { + expected = strings.TrimSuffix(expected, ".exe") + } + if canonical, canonicalErr := filepath.EvalSymlinks(expected); canonicalErr == nil { + expected = canonical + } + if filepath.Clean(executable) != filepath.Clean(expected) { + return "helper-path-mismatch" + } + return "" +} + +func validPlanningBody(value string) string { + if !utf8.ValidString(value) || strings.IndexByte(value, 0) >= 0 { + return "invalid-content" + } + if strings.TrimSpace(value) == "" { + return "empty-content" + } + return "" +} + +func inspectPosixPlanningTransport(command string, first string, bodyStart int) planningTransportInspection { + match := posixPlanningHeader.FindStringSubmatch(first) + if match == nil { + if planningWriteAttempt(first) { + return planningTransportInspection{Matched: true, InvalidReason: "single-quoted-delimiter-required"} + } + return planningTransportInspection{} + } + header := strings.TrimSpace(match[1]) + invocation, validHeader := planningWriteHeader(header) + if !validHeader { + if planningWriteAttempt(header) || planningVerbInvocationAttempt(header) { + return planningTransportInspection{Matched: true, Header: header, InvalidReason: "invalid-command-shape"} + } + return planningTransportInspection{} + } + delimiter := match[2] + for position := bodyStart; position <= len(command); { + line, next, hasNewline := nextLine(command, position) + if structuralLine(line) == delimiter { + 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 != "" { + 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} + } + if !hasNewline { + break + } + position = next + } + return planningTransportInspection{Matched: true, Header: header, Feature: invocation.Feature, Repository: invocation.Repository, Executable: invocation.Executable, InvalidReason: "missing-terminator"} +} + +func inspectPowerShellPlanningTransport(command string) planningTransportInspection { + position := 0 + line, next, ok := nextLine(command, position) + if !ok || strings.TrimSpace(structuralLine(line)) != "& {" { + return planningTransportInspection{} + } + position = next + line, next, ok = nextLine(command, position) + if !ok || strings.TrimSpace(structuralLine(line)) != powerShellPlanningEncodingLine { + if powerShellPlanningAttempt(command) { + return planningTransportInspection{Matched: true, InvalidReason: "powershell-utf8-scope-required"} + } + return planningTransportInspection{} + } + position = next + line, next, ok = nextLine(command, position) + if !ok || strings.TrimSpace(structuralLine(line)) != "@'" { + return planningTransportInspection{Matched: true, InvalidReason: "single-quoted-here-string-required"} + } + bodyStart := next + for position = bodyStart; position <= len(command); { + line, next, hasNewline := nextLine(command, position) + structural := structuralLine(line) + if strings.HasPrefix(structural, "'@") { + match := powerShellPlanningClose.FindStringSubmatch(structural) + if match == nil { + return planningTransportInspection{Matched: true, InvalidReason: "delimiter-collision-or-trailing-command"} + } + header := strings.TrimSpace(match[1]) + invocation, validHeader := planningWriteHeader(header) + if !validHeader { + return planningTransportInspection{Matched: true, Header: header, InvalidReason: "invalid-command-shape"} + } + 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) + 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 != "" { + 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} + } + if !hasNewline { + break + } + position = next + } + return planningTransportInspection{Matched: true, InvalidReason: "missing-terminator"} +} + +func inspectPlanningWriteTransport(command string) planningTransportInspection { + first, next, hasNewline := nextLine(command, 0) + first = structuralLine(first) + if strings.TrimSpace(first) == "& {" { + if transport := inspectPowerShellPlanningTransport(command); transport.Matched { + return transport + } + } + if (strings.TrimSpace(first) == "@'" || strings.HasPrefix(strings.TrimSpace(first), "$OutputEncoding")) && powerShellPlanningAttempt(command) { + return planningTransportInspection{Matched: true, InvalidReason: "powershell-utf8-scope-required"} + } + if hasNewline { + if transport := inspectPosixPlanningTransport(command, first, next); transport.Matched { + return transport + } + } + if planningWriteAttempt(first) { + return planningTransportInspection{Matched: true, Header: strings.TrimSpace(first), InvalidReason: "missing-literal-input"} + } + // A planning-write occurrence that did not match either complete envelope is + // still owned by this boundary. This closes leading commands, compound shell + // wrappers, and other alternate paths that could otherwise avoid binding and + // body classification merely by moving the helper away from column zero. + if planningWriteMention.MatchString(command) { + return planningTransportInspection{Matched: true, InvalidReason: "complete-literal-envelope-required"} + } + return planningTransportInspection{} +} diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go new file mode 100644 index 0000000..5c7549a --- /dev/null +++ b/boatstack/planning_transport_conformance_test.go @@ -0,0 +1,376 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const planningTransportDelimiter = "BOATSTACK_PLAN_EOF" + +func quotedLiteral(t *testing.T, value string) string { + t.Helper() + if strings.Contains(value, "'") { + t.Fatalf("test path cannot be represented by the bounded literal fixture: %q", value) + } + return "'" + value + "'" +} + +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 +} + +func posixPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, body string) string { + t.Helper() + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + return planningHeader(t, helper, repo, feature, artifact) + " <<'" + planningTransportDelimiter + "'\n" + body + planningTransportDelimiter + "\n" +} + +func powerShellPlanningEnvelope(t *testing.T, helper, repo, feature, artifact, body string) string { + t.Helper() + if !strings.HasSuffix(body, "\n") { + body += "\n" + } + return "& {\n" + powerShellPlanningEncodingLine + "\n@'\n" + body + "'@ | & " + planningHeader(t, helper, repo, feature, artifact) + "\n}\n" +} + +func planningHookInput(t *testing.T, host, command string) []byte { + t.Helper() + var event map[string]any + switch host { + case "cursor": + event = map[string]any{"hook_event_name": "beforeShellExecution", "command": command} + case "claude", "codex": + event = map[string]any{"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": map[string]any{"command": command}} + case "gemini": + event = map[string]any{"hook_event_name": "BeforeTool", "tool_name": "run_shell_command", "tool_input": map[string]any{"command": command}} + default: + t.Fatalf("unsupported test host %q", host) + } + value, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + return value +} + +func buildPlanningHelperAt(t *testing.T, binary string) string { + t.Helper() + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve package source") + } + if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil { + t.Fatal(err) + } + command := exec.Command("go", "build", "-o", binary, "./cmd/boatstack-helper") + command.Dir = filepath.Dir(source) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("build helper: %v: %s", err, output) + } + return binary +} + +func buildPlanningHelper(t *testing.T, repo string) string { + t.Helper() + return buildPlanningHelperAt(t, filepath.Join(repo, ".product-loop", "bin", helperName())) +} + +func executePlanningEnvelope(t *testing.T, repo, command string) { + t.Helper() + var execution *exec.Cmd + if runtime.GOOS == "windows" { + powershell, err := exec.LookPath("powershell") + if err != nil { + t.Skip("Windows PowerShell is unavailable") + } + execution = exec.Command(powershell, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command) + } else { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is unavailable") + } + execution = exec.Command(bash, "-c", command) + } + execution.Dir = repo + if output, err := execution.CombinedOutput(); err != nil { + t.Fatalf("execute admitted planning transport: %v: %s", err, output) + } +} + +// control-law: planning-document-body-is-literal-data +// +// Relation conformance: the real host event admits the exact command that the +// real shell executes, and the real helper receives a non-empty document. This +// closes the old split test (bare command classification plus a direct library +// write), which never exercised the transport between the hook and stdin. +func TestPlanningTransportRunsHookShellHelperAndSavedArtifact(t *testing.T) { + repo := safetyTestRepo(t) + helper := buildPlanningHelper(t, repo) + body := "# Literal transport\n\nUnicode survives: ü 船\n`rm -rf /` and $(git reset --hard HEAD~1) are documentation.\n" + + var command string + if runtime.GOOS == "windows" { + command = powerShellPlanningEnvelope(t, helper, repo, "literal-transport", "source-plan.md", body) + } else { + command = posixPlanningEnvelope(t, helper, repo, "literal-transport", "source-plan.md", body) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, command)}) + if denied { + t.Fatalf("%s denied the complete literal envelope: %s", host, output) + } + } + + executePlanningEnvelope(t, repo, command) + written, err := os.ReadFile(filepath.Join(repo, ".product-loop", "features", "literal-transport", "source-plan.md")) + 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 { + t.Fatalf("saved Markdown differs from transported body:\nwant %q\n got %q", body, written) + } +} + +func TestPlanningTransportRunsThroughDetachedWorkspaceBinding(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/planning-transport.git") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach detached workspace: %+v %v", result, err) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil || workspace.Mode != SupervisionDetached { + t.Fatalf("resolve detached workspace: %+v %v", workspace, err) + } + helper := buildPlanningHelperAt(t, workspace.HelperPath()) + body := "# Detached literal transport\n\nThe controller remains outside the product repository.\n" + command := posixPlanningEnvelope(t, helper, repo, "detached-transport", "plan.md", body) + if runtime.GOOS == "windows" { + command = powerShellPlanningEnvelope(t, helper, repo, "detached-transport", "plan.md", body) + } + output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: planningHookInput(t, "codex", command)}) + if denied { + t.Fatalf("detached literal envelope was denied: %s", output) + } + executePlanningEnvelope(t, repo, command) + written, err := os.ReadFile(filepath.Join(workspace.FeatureDir("detached-transport"), "plan.md")) + if err != nil || strings.ReplaceAll(string(written), "\r\n", "\n") != body { + t.Fatalf("detached planning artifact mismatch: %v %q", err, written) + } + if _, err := os.Stat(filepath.Join(repo, productLoopDirName)); !os.IsNotExist(err) { + t.Fatal("detached planning transport leaked controller state into the product repository") + } +} + +func TestPlanningTransportRejectsUnverifiedDetachedBinding(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/planning-binding.git") + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach detached workspace: %+v %v", result, err) + } + workspace, err := ResolveWorkspaceContext(repo) + if err != nil { + t.Fatal(err) + } + command := posixPlanningEnvelope(t, workspace.HelperPath(), repo, "detached-binding", "plan.md", "# Plan\n") + identity, err := repoIdentity(repo) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(os.Getenv(stateRootEnv), "boatstack") + if err := os.WriteFile(bindingPath(root, identity.RepoID), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + findings := ClassifyCommand(repo, command) + if len(findings) != 1 || findings[0].Category != "planning-transport-invalid" || findings[0].Reason != "workspace-binding-unverified" { + t.Fatalf("unverified detached binding did not fail closed: %#v", findings) + } +} + +func TestPlanningTransportTreatsDocumentTextAsInertAcrossHosts(t *testing.T) { + repo := safetyTestRepo(t) + body := strings.Join([]string{ + "# Threat-model examples", + "`rm -rf /`", + "$(git reset --hard HEAD~1)", + "DROP TABLE accounts;", + ".git/boatstack/deliveries/demo/state.json > /tmp/example", + "terraform destroy | Remove-Item -Recurse -Force $HOME", + "secret-marker-that-must-never-be-rendered", + }, "\n") + "\n" + posix := posixPlanningEnvelope(t, ".product-loop/bin/boatstack-helper", repo, "threat-model", "questions.md", body) + commands := []struct { + command, expected string + hostConformanceRun bool + }{ + {posix, body, true}, + {powerShellPlanningEnvelope(t, `.product-loop\bin\boatstack-helper.exe`, repo, "threat-model", "questions.md", body), body, runtime.GOOS == "windows"}, + {strings.ReplaceAll(posix, "\n", "\r\n"), strings.ReplaceAll(body, "\n", "\r\n"), true}, + } + for _, test := range commands { + inspection := inspectPlanningWriteTransport(test.command) + if !inspection.Matched || inspection.InvalidReason != "" || string(inspection.Content) != test.expected { + t.Fatalf("literal envelope was not recovered exactly: %#v", inspection) + } + if !test.hostConformanceRun { + continue + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: planningHookInput(t, host, test.command)}) + if denied { + t.Fatalf("%s treated literal Markdown as an effect: %s", host, output) + } + } + } +} + +func TestPlanningTransportFailureClassesFailClosedWithoutExecuting(t *testing.T) { + repo := safetyTestRepo(t) + otherRepo := t.TempDir() + header := planningHeader(t, ".product-loop/bin/boatstack-helper", repo, "transport-failures", "plan.md") + valid := posixPlanningEnvelope(t, ".product-loop/bin/boatstack-helper", repo, "transport-failures", "plan.md", "# Plan\n") + powerShellValid := powerShellPlanningEnvelope(t, `.product-loop\bin\boatstack-helper.exe`, repo, "transport-failures", "plan.md", "# Plan\n") + cases := map[string]string{ + "bare command": header, + "leading command": "touch sentinel\n" + valid, + "compound prefix": "cd . && " + valid, + "environment wrapper": "env BOATSTACK_TEST=1 " + valid, + "unquoted delimiter": header + " < --artifact ` (Markdown on stdin). The host's own Markdown writer is permitted only where the host allows it; arbitrary shell redirection never is. +### Literal planning transport + +Feature artifacts are authored through the owned channel `.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact `. The complete Markdown document and command must cross the host hook in one literal envelope. The hook binds the command to the current repository's project-local helper, validates the command and closing delimiter, treats the body as data, and denies truncation or trailing commands before the shell runs. + +In Bash, zsh, and Git Bash, use a single-quoted heredoc. The closing token must not occur as a line in the Markdown; choose another simple token when it does. In Git Bash on Windows, append `.exe` to the project-local helper path. + +```bash +.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' + +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\bin\boatstack-helper.exe' 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. Validation must be derived before implementation. Each check records: diff --git a/boatstack/safety.go b/boatstack/safety.go index 7b4c3f1..5cfab1a 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -229,14 +229,19 @@ var stageMutationVerbs = map[string][]string{ } func controlledPhaseTransition(command, stage string) bool { - if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") { - return false + if transport := inspectPlanningWriteTransport(command); transport.Matched { + if transport.InvalidReason != "" && transport.InvalidReason != "missing-literal-input" { + return false + } + if transport.Header != "" { + command = transport.Header + } } - fields := strings.Fields(strings.TrimSpace(command)) - if len(fields) < 2 { + fields, complete := literalCommandWords(command) + if !complete || len(fields) < 2 { return false } - executable := strings.TrimSuffix(strings.ToLower(filepath.Base(fields[0])), ".exe") + executable := portableExecutableBase(fields[0]) if executable != "boatstack-helper" { return false } @@ -827,7 +832,29 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if strings.TrimSpace(command) == "" { return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty-command", Source: "tool-input"}} } - if deliveryStatePathPattern.MatchString(command) && !isPureReadOnlyCommand(command) && !approvedUpdatePublisherPattern.MatchString(command) { + validatedPlanningTransport := false + // A complete literal planning envelope carries two different types in one + // host command: an executable helper header and inert Markdown bytes. Parse + // that boundary before applying any effect classifier. An incomplete or + // ambiguous envelope is denied before the shell can interpret its body. + // control-law: planning-document-body-is-literal-data + if transport := inspectPlanningWriteTransport(command); transport.Matched { + if transport.InvalidReason != "" { + return []SafetyFinding{{ + Category: "planning-transport-invalid", Reason: transport.InvalidReason, Source: "planning-transport", + BlockingFeature: transport.Feature, NextOperation: "planning-write", + }} + } + if reason := planningTransportBinding(repo, transport); reason != "" { + return []SafetyFinding{{ + Category: "planning-transport-invalid", Reason: reason, Source: "planning-transport", + BlockingFeature: transport.Feature, NextOperation: "planning-write", + }} + } + validatedPlanningTransport = true + command = transport.Header + } + if deliveryStatePathPattern.MatchString(command) && !validatedPlanningTransport && !isPureReadOnlyCommand(command) && !approvedUpdatePublisherPattern.MatchString(command) { // AttemptedPath carries the matched managed-path fragment (bounded and // secret-free, like the phase-bypass finding) so the denial can name the // path's declared owner verbs from the state-ownership map. diff --git a/boatstack/safety_corpus_test.go b/boatstack/safety_corpus_test.go index 44baa99..615fb14 100644 --- a/boatstack/safety_corpus_test.go +++ b/boatstack/safety_corpus_test.go @@ -91,7 +91,7 @@ func TestGuardCorpusDualReward(t *testing.T) { }, "routine", false}, // The owned planning channel and ordinary product writes stay open at zero // candidates — the first-write latch is path-scoped, never a blanket deny. - {"planning-write-first-artifact", "", `boatstack-helper planning-write --repo . --feature checkout --artifact plan.md`, "routine", false}, + {"planning-write-first-artifact", "", ".product-loop/bin/boatstack-helper planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", "routine", false}, {"check-source-plan", "", `boatstack-helper check-source-plan --repo . --plan docs/plan.md`, "routine", false}, {"write-product-source", "Write", map[string]any{ "file_path": filepath.Join(repo, "src", "app.ts"), diff --git a/boatstack/solution_closure_conformance_test.go b/boatstack/solution_closure_conformance_test.go index 864d581..59a42d6 100644 --- a/boatstack/solution_closure_conformance_test.go +++ b/boatstack/solution_closure_conformance_test.go @@ -23,6 +23,10 @@ import ( // as shell metacharacters). The closure property is defined over substituted // lines: what the user runs after filling the owed input. func substituteOwedFlags(line string) string { + line = strings.ReplaceAll(line, "--feature ''", "--feature demo") + line = strings.ReplaceAll(line, "--artifact ''", "--artifact plan.md") + line = strings.ReplaceAll(line, "--feature ", "--feature demo") + line = strings.ReplaceAll(line, "--artifact ", "--artifact plan.md") return strings.ReplaceAll(line, "", "test-value") } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 9640a1f..7dccc33 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **24084 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **24480 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`3c9ed10dcf0c6781023a547effb4469879b8a375`](https://github.com/operatorstack/intelligence-flow/tree/3c9ed10dcf0c6781023a547effb4469879b8a375/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`53f0f064a116c6d0edbacd2b07dba935481bba96`](https://github.com/operatorstack/intelligence-flow/tree/53f0f064a116c6d0edbacd2b07dba935481bba96/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index b393f0f..e5cdb1f 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "3c9ed10dcf0c6781023a547effb4469879b8a375", + "source_commit": "53f0f064a116c6d0edbacd2b07dba935481bba96", "statuses": ["verified", "observed", "still_being_evaluated"], "claims": [ { @@ -12,7 +12,7 @@ "readable_evidence": "why-these-steps.md#portable-workflow-and-state", "implementation": ["../boatstack/export.go", "../boatstack/references/artifacts.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "human-decisions", @@ -23,7 +23,7 @@ "readable_evidence": "why-these-steps.md#human-decisions", "implementation": ["../boatstack/references/workflow.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "validation-provenance", @@ -34,7 +34,7 @@ "readable_evidence": "why-these-steps.md#validation-provenance", "implementation": ["validation-and-evidence.md", "../boatstack/plan.go"], "verification": ["../boatstack/plan_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "irreversible-operations", @@ -46,7 +46,7 @@ "readable_evidence": "why-these-steps.md#irreversible-operations", "implementation": ["safety.md", "../boatstack/safety.go", "../boatstack/hooks.go"], "verification": ["../boatstack/safety_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:3c9ed10dcf0c6781023a547effb4469879b8a375" + "last_verified_version": "source:53f0f064a116c6d0edbacd2b07dba935481bba96" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c069388..3c37d54 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -79,7 +79,28 @@ 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. The adapter may use Boatstack's bounded planning writer for known feature documents; it must not use arbitrary shell redirection or edit product code. If the host cannot support the bounded write, keep the plan and report the missing permission rather than leaving Plan mode early. +Planning is Markdown-only. Send the complete command and document in one shell-tool call. For Bash, zsh, or Git Bash: + +```bash +.product-loop/bin/boatstack-helper planning-write --repo . --feature --artifact <<'BOATSTACK_PLAN_EOF' + +BOATSTACK_PLAN_EOF +``` + +In Git Bash on Windows, use `.product-loop/bin/boatstack-helper.exe` in the same envelope. + +For Windows PowerShell: + +```powershell +& { +$OutputEncoding = [System.Text.UTF8Encoding]::new($false) +@' + +'@ | & '.product-loop\bin\boatstack-helper.exe' 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. ## `/build` says it is ready but cannot start diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 51da0e3..a785675 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "3c9ed10dcf0c6781023a547effb4469879b8a375", + "source_commit": "53f0f064a116c6d0edbacd2b07dba935481bba96", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-08-08-literal-planning-transport.md b/release-notes/2026-08-08-literal-planning-transport.md new file mode 100644 index 0000000..19a491b --- /dev/null +++ b/release-notes/2026-08-08-literal-planning-transport.md @@ -0,0 +1,3 @@ +### Planning documents now cross guarded shells safely + +Boatstack now accepts a complete planning document through a literal Bash, Git Bash, or PowerShell envelope while treating the Markdown as data. PowerShell encoding markers are removed at the planning-input boundary. Missing input, truncated bodies, delimiter collisions, expansion-capable or compound forms, and leading or trailing commands stop before execution with a corrective message, so users are no longer asked to paste planning text into a shell or restart the task.