From 948b871ec302a972b8492691829e8c9d17e0ab27 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Wed, 22 Jul 2026 18:56:58 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ a063adb7e10c --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 47 ++-- boatstack/assets/templates/approval.md | 6 +- boatstack/assets/templates/plan-lock.json | 2 + boatstack/cmd/boatstack-helper/main.go | 10 +- boatstack/export.go | 8 +- boatstack/hooks.go | 52 ++++- boatstack/hooks_test.go | 25 +- boatstack/init.go | 9 +- boatstack/plan.go | 143 ++++++++---- boatstack/planning.go | 156 ++++++++++++- boatstack/planning_test.go | 69 ++++++ boatstack/references/artifacts.md | 4 +- boatstack/references/host-hook-contracts.md | 11 +- boatstack/references/workflow.md | 12 +- boatstack/safety.go | 215 +++++++++++++++++- boatstack/safety_test.go | 138 +++++++++++ docs/evidence-engineered-coding.md | 4 +- docs/public-claims.json | 24 +- docs/troubleshooting.md | 6 + labs/diagram-json/plan.lock.json | 2 +- ...07-22-pre-activation-mutation-interlock.md | 10 + 22 files changed, 823 insertions(+), 132 deletions(-) create mode 100644 release-notes/2026-07-22-pre-activation-mutation-interlock.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6bc15c..1856027 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/c457045039b798a9db660b0f102753fc6bb0a975/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/a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9/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 b70f410..364df05 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 51814, - "estimated_tokens": 12954, + "characters": 53187, + "estimated_tokens": 13297, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "bf4632277b4acced8795c57427be006a3d998b9a536fff24c143480f5152ecf4", + "CONTRIBUTING.md": "224a7dc56b254511c0f149effcce668590f3df06c8eedfdc88798c17e75ffe0d", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -21,12 +21,12 @@ "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", - "boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5", + "boatstack/assets/templates/approval.md": "b4a400b14c0a42338ae985475078eb8c4d14d87dca6d1d6d3e80646f3d0b1b82", "boatstack/assets/templates/evidence.md": "12dac552bc5373ab443367d5797f41988f14284bcf46d16dfd72015cfddf9ad1", "boatstack/assets/templates/feature-spec.md": "c7e007cc4295ed4c599642c0587021ef978e729cf0946f6bf3a6c4f01d366ad4", "boatstack/assets/templates/gaps.md": "911cc2f086104d35071b952950c2ec44258641419f10b2355c594f33eb492cbe", "boatstack/assets/templates/move.md": "91bfd9a9b9426ac023eb88fd19f4f638190481c1855f1239acc73830528e50f0", - "boatstack/assets/templates/plan-lock.json": "fe5507bfdb99ff892e56be0f3015056a72171183a06930b4555d6075b3ee04d9", + "boatstack/assets/templates/plan-lock.json": "526ba903c42eed3a1dee4c46c47ca90837db35ff79631a737984bf9f5988bb9b", "boatstack/assets/templates/plan.md": "46b3f9017fe72d97efc429191aafa1711515101b7372cc5488813abc29a64547", "boatstack/assets/templates/questions.md": "1133b557a832d4988545f3694b365ebffa808640ed696b6c04b5a390266eed80", "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", @@ -34,7 +34,7 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "dada2c26e5947cd4cf7c47b3c853781b4834a721eb833fc290c393f585367597", + "boatstack/cmd/boatstack-helper/main.go": "e769b9aa4894260a53352865741bc52ad1e257213e270fa3a1d13cf68d07d5d3", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/config_documentation_test.go": "bd34ac570d08c8d1d1e4f5e86c327c02a55dd5e96bd785b895a05f28fcd3f7a2", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", @@ -42,13 +42,13 @@ "boatstack/delivery.go": "f203cbfa78a79609fb4f9e66dd013c0c9d8ff8da478cc1ab7e75b425c66b3920", "boatstack/delivery_test.go": "e669e24bb5bb3cc5d48c6dcfb1e20ad83ddaa26161785254a7aab9d3b1219e11", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "0f12f06796d0748a14d99118f1b20bcc75631b8c557915af94a854b6c4e8d850", + "boatstack/export.go": "71a3e1493947537cdf271c7a2445ecd7157dee89af84ea548a06f3165cb69780", "boatstack/export_test.go": "51b0340bec3decdb3e13b2a6cd73dd15e16e906031acb0c98cc553da54622162", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "2b7d899f92efef8a68a160c423a46bcd6fd37daa0eba2ac8ad44ffa3dc5f88c9", - "boatstack/hooks_test.go": "d71271c0b9ea59b907cc0407a7173d3542281fb2984e6e3c3062ac39b131ec59", - "boatstack/init.go": "bc484058959202a832c9b6a76c121fcc5a666b5bcfc52ab7a7e9e3f9b37c0887", + "boatstack/hooks.go": "2895ecb6f20397f2c39b3af8fe37c704a6048cc05d742efce6aae554ce659cfe", + "boatstack/hooks_test.go": "eedef3fe5d0f5b6d27c3290965cadc10f76f6b4fef5860c690d62672850caf3f", + "boatstack/init.go": "013e1fe1f4d058442558e8057e261178af53b0dfd7ea4d72517d0ad8d9d8ca23", "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", @@ -56,23 +56,23 @@ "boatstack/migrate_test.go": "9f4bda2fb158c5e54bcc0242dace1da3c1965f9846a213c573956a35b7d1724e", "boatstack/next.go": "6bf119965704e8979921aaf514b205f85082716f34a5d1901ed63c175103227b", "boatstack/next_test.go": "641bf32cf160c426531134a4756b0c41c6a6d876d515bb151cd01fd9c1ae5056", - "boatstack/plan.go": "dc34c2b37767574b6bd1327688f002e09b7ed39465a2851712434f151a169825", + "boatstack/plan.go": "8189ee42902bce62dcd39ce7a2e423cecb4c9e0d1dd34a5497e5a732a45c851f", "boatstack/plan_test.go": "ea96bf0047a43e8224f02e0b8761978ce48636a83a63b6296c9bd3336f51250c", "boatstack/plan_validation.go": "412f06750832fe46f01190ea5e475fc6f6ea59c8ba78131f94ec031053a405d2", "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", - "boatstack/planning.go": "4b86ae9dc16393f099ca26812bf3e62909fee42a541b33c33275cc16a80262aa", - "boatstack/planning_test.go": "6b156a64182ed76d4c3d392b4c5a26abe5d8b81cea27ee12ac7c4627c827e186", + "boatstack/planning.go": "ef4507a9fecc900f0691372c50883f328c9232c3dfd6986fbde26fb7ef436ae3", + "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", "boatstack/pr.go": "173392fd40ff1c111ec89b1f3dabaf666347be299ac7653bfe5ea2de407fc26f", "boatstack/pr_test.go": "838e6c244aed84317d00e6f49de6b65bb08f6fe1e1a11fb37aad8428ae2f6ea1", "boatstack/recovery.go": "dd816b18b54a0085b8d8276a93ee98d2b1e90099059a0d85cf6e24edf6f37d5b", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", - "boatstack/references/artifacts.md": "20bc466f7eac1c834a5d4d0b4cd102e6898e1af908e1905ff9ebf9b1c347baf6", + "boatstack/references/artifacts.md": "d49b3c207546ba788a833078c422c3d04390a4997a3322451dcb8fddd4ccff08", "boatstack/references/config-schema.md": "c07c2d532ef95ea6ae538a1fffefb92ded1f8dc6a06eb3b8d463e371d1ed8416", "boatstack/references/failure-moves.md": "3c3f654fd3a2319ca21218e921d37b31b4d926c2df5febd9c92402bffae40cd4", - "boatstack/references/host-hook-contracts.md": "8e97bc3591367afd82c6822c8e45283b97fdd84faaa9cc8a21862df8f61e403b", + "boatstack/references/host-hook-contracts.md": "36b125ac121b9ace5de30f0783e2068a47e8cb6d20a75852e5aff52f25334622", "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "5a6d3ae0c7f267b4a9a773f0cd8a2d4f6bcf58cfc1c7e48f8a47ca432aeffebc", + "boatstack/references/workflow.md": "a9e5a6330e1ce850b8a2a3e5af3ed9d9ea4c62d56a16638a7c9cd8ef015a5fbd", "boatstack/release.go": "fa2ac926df89c90c5844e938a2e02d4b8dbbaefbf85bb7a1a89fc51690bea520", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "fbdbf583c862c41f23d1a200f53d042842db72f19c29fe94e4288a69b0ac4a6b", @@ -80,8 +80,8 @@ "boatstack/runtime.go": "687b3543c22acbbed91c94e798c2a813bd828e087ac180ab29a21e3a429f9971", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", - "boatstack/safety.go": "546a2c9c6d164185990ce7f515f95bb962f8b2e7d82947895fb651400b20e11b", - "boatstack/safety_test.go": "efe5990631134cf469d09e8ff1138007132006b50b8e757ff55749f0d968dec7", + "boatstack/safety.go": "6e213386fcaa6471f178cceb4154baca2d07254a2eb0274b40b6bfebe4f3b8c0", + "boatstack/safety_test.go": "c889117eee9d288da67781747886ddde01c4552abd6a6c389a9361ee4fa2c177", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", @@ -97,14 +97,14 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "f530c5dcbacf32dcb6fdab590901d8f658a4f29bd93f6264cec5d4f449c2cbd1", - "docs/evidence-engineered-coding.md": "315080bb71a2fccb673db2bcbd2792ce7662bfbcd3172bb32aa802a6912072c0", + "docs/evidence-engineered-coding.md": "9dcbd6ba9ba02aa263de2cb15fa8ed7722e07358e04913fd4b63aa9aeb00295e", "docs/generated-files.md": "bb8cd6e4b7d0042c899685a916ea0170ece363388328786cb0ba8fad6df3b780", "docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316", - "docs/public-claims.json": "e639e90ecd2905089e54864c77da36ede26768120524ded054eb5e3fb2673ab3", + "docs/public-claims.json": "e00c0b7cf4a5bc4d3cc672ccf7162c650aa315a5e0ed8a6bd3bf8ce8e6ad0f98", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "648a189f260834a1923ef1f27b4b2cc6f7314dd6153b8bf5699369fc2f29945b", + "docs/troubleshooting.md": "b975f818360440d992d19f601040c7248c9fc23edc0c4f902e09daf056df926d", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", @@ -114,7 +114,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": "4810b9344d3944a692b0f68408ac583fb1d70fd39986bebf53cb953d817cab32", + "labs/diagram-json/plan.lock.json": "e112e544a997e89821b3a49ada2d789cd47c2bbcd974f6cf5a08b1b2ed687218", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -167,6 +167,7 @@ "release-notes/2026-07-22-boundary-oracle-loop.md": "698c2ed7dd0a000e6e210f521989992b8fa476376987819ba92c12feb3528f7c", "release-notes/2026-07-22-enforce-public-configuration-controls.md": "3c0904e206c483fa1c148c20125dd0f63e4fdac165775fbde984f168e62f4a96", "release-notes/2026-07-22-pr-visual-evidence.md": "097e66a778a1d3197796c44c374b346a0cf29f24baa895080863372816696921", + "release-notes/2026-07-22-pre-activation-mutation-interlock.md": "bebc6104159acc7c91e45c3d80d6e714b79486ab204c619fc715971303adedd1", "release-notes/2026-07-22-product-configuration-guide.md": "45e96862336bd53a2628ce3fe718c829ea20315555f53187eb7f04ba749f3a97", "release-notes/2026-07-22-projection-layout-validation.md": "fd0d2935f3f0c4caa41bda678e3bd9a9b496eb2652ab38d80c1c1434cbba3159", "release-notes/2026-07-22-value-translation-boundary.md": "9cf168ff7caaf3906b78533935bdfb2c86e753984ed1e5ec8204cb373d083390" @@ -174,7 +175,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "c457045039b798a9db660b0f102753fc6bb0a975", + "commit": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/assets/templates/approval.md b/boatstack/assets/templates/approval.md index 6ee7708..438b7ed 100644 --- a/boatstack/assets/templates/approval.md +++ b/boatstack/assets/templates/approval.md @@ -5,11 +5,13 @@ This receipt may be created only after the named human explicitly approves the e ```json { - "schema_version": 1, + "schema_version": 2, "status": "APPROVED", "approved_by": "", "approved_at": "", - "approval_fingerprint": "" + "approval_fingerprint": "", + "baseline_diff_sha256": "", + "baseline_changed_paths": [] } ``` diff --git a/boatstack/assets/templates/plan-lock.json b/boatstack/assets/templates/plan-lock.json index cf5273e..b1f0f00 100644 --- a/boatstack/assets/templates/plan-lock.json +++ b/boatstack/assets/templates/plan-lock.json @@ -14,6 +14,8 @@ "plan_sha256": "", "task_graph_path": "compiled/tasks.json", "task_graph_sha256": "", + "baseline_diff_sha256": "", + "baseline_changed_paths": [], "invalidated_at": null, "invalidation_reason": null } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index de846c7..67689cf 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -197,7 +197,12 @@ func checkPlanCommand(arguments []string) int { if err != nil { return fail(fmt.Errorf("invalid Markdown plan: %w", err)) } - fmt.Printf("PASS: Markdown plan is structurally valid\nPLAN_FINGERPRINT=%s\nSOURCE_PLAN=%s\nSPEC=%s\n", check.Fingerprint, check.SourcePlanPath, check.SpecPath) + baseline, err := boatstack.PlanningBaselineForPlan(*plan) + if err != nil { + return fail(fmt.Errorf("cannot fingerprint the pre-activation product baseline: %w", err)) + } + paths, _ := json.Marshal(baseline.ChangedPaths) + fmt.Printf("PASS: Markdown plan is structurally valid\nPLAN_FINGERPRINT=%s\nSOURCE_PLAN=%s\nSPEC=%s\nBASELINE_DIFF_SHA256=%s\nBASELINE_CHANGED_PATHS=%s\n", check.Fingerprint, check.SourcePlanPath, check.SpecPath, baseline.DiffSHA256, paths) return 0 } @@ -269,6 +274,7 @@ func recordApprovalCommand(arguments []string) int { approvedBy := flags.String("approved-by", "", "named human approver") approvedAt := flags.String("approved-at", "", "RFC3339 approval timestamp") fingerprint := flags.String("fingerprint", "", "exact fingerprint displayed before approval") + baselineDiffSHA256 := flags.String("baseline-diff-sha256", "", "exact product baseline fingerprint displayed before approval; omit only when clean") if err := flags.Parse(arguments); err != nil { return 2 } @@ -277,7 +283,7 @@ func recordApprovalCommand(arguments []string) int { } if err := boatstack.RecordApproval(boatstack.ApprovalRecordOptions{ PlanPath: *plan, OutputPath: *output, ApprovedBy: *approvedBy, - ApprovedAt: *approvedAt, Fingerprint: *fingerprint, + ApprovedAt: *approvedAt, Fingerprint: *fingerprint, BaselineDiffSHA256: *baselineDiffSHA256, }); err != nil { return fail(err) } diff --git a/boatstack/export.go b/boatstack/export.go index f65d47d..0e8e348 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -218,7 +218,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte files[".product-loop/intake/.gitkeep"] = []byte{} files[".product-loop/hooks/guard.sh"] = guardShellScript() files[".product-loop/hooks/guard.ps1"] = guardPowerShellScript() - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { if contains(adapters, host) { fragment, fragmentErr := hookFragmentJSON(host) if fragmentErr != nil { @@ -268,7 +268,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub beyond the helper's bounded published-PR inspection, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from PUBLISHED, which responds PR published and makes reviewing its checks the one action, and FEATURE_COMPLETE, which is reserved for a verified merged PR and responds Feature complete with No action required. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If PUBLISHED, report that the PR is awaiting or lacks verified completion and make reviewing its checks the one next action; do not claim completion. If FEATURE_COMPLETE, respond Feature complete with No action required. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "auto-plan": "Discover exactly one saved Plan-mode file 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. 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.", - "plan-gate": "Run check-plan read-only and present its fingerprint 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 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.", + "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. 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.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", @@ -293,7 +293,7 @@ The source of truth is @.product-loop/workflow.md and @.product-loop/project.jso Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation. -Before modifying product code, check for an active managed delivery or a published delivery associated with the current branch or recorded PR. When one exists and the user reports a CI failure, review finding, denied publication, problem, or modification in ordinary language, route through the Boatstack repair operation before editing. The repair operation resolves active and published work, records the exact request, and either resumes the active delivery or prepares an independently approved corrective child. Never ask the user to manually repeat a push or PR mutation denied by the safety hook. If no managed delivery matches, continue ordinary conversation. +Before modifying product code, resolve complete Boatstack state. A saved feature plan latches managed authority: draft, approved, policy-ready, ambiguous, stale, or invalid pre-activation state cannot mutate product files, and only exact planning or activation transitions remain available. Once a current plan lock exists, preserve active managed delivery and published-delivery recovery behavior. Async task completion and conversation state never grant authority. When active or published work receives a CI failure, review finding, denied publication, problem, or modification in ordinary language, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no saved plan or managed delivery exists, continue ordinary conversation. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. @@ -317,7 +317,7 @@ description: Use when the user asks what is next in Boatstack, asks Boatstack to # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery or a published delivery associated with the current branch or recorded PR. If one exists and ordinary user language reports a CI failure, review finding, denied publication, problem, or modification, automatically use repair even when the user did not name the operation. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair for ordinary failure or change language. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. diff --git a/boatstack/hooks.go b/boatstack/hooks.go index c7db030..754c23b 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -27,8 +27,10 @@ func canonicalHookEvent(host string) ([]byte, error) { return []byte(`{"hook_event_name":"beforeShellExecution","command":"git status --short"}`), nil case "claude", "codex": return []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`), nil + case "gemini": + return []byte(`{"hook_event_name":"BeforeTool","tool_name":"run_shell_command","tool_input":{"command":"git status --short"}}`), nil default: - return nil, fmt.Errorf("unsupported hook host %q; expected cursor, claude, or codex", host) + return nil, fmt.Errorf("unsupported hook host %q; expected cursor, claude, codex, or gemini", host) } } @@ -40,6 +42,13 @@ func validateCanonicalHookOutput(host string, output []byte) error { } return nil } + if host == "gemini" { + var decision map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output), &decision); err != nil || stringValue(decision["decision"]) != "allow" { + return fmt.Errorf("gemini hook diagnostic returned a malformed or non-allow response") + } + return nil + } if len(bytes.TrimSpace(output)) != 0 { return fmt.Errorf("%s hook diagnostic returned unexpected allow output", host) } @@ -218,13 +227,17 @@ func hookCommandWindows(host string) string { func desiredHostHookForEvent(host, event string) map[string]any { switch host { case "cursor": - return map[string]any{ + entry := map[string]any{ "command": hookCommand(host), "commandWindows": hookCommandWindows(host), "failClosed": true, "timeout": 10, } + if event == "preToolUse" { + entry["matcher"] = "Write|Edit|ApplyPatch|Create|Delete|Move|Rename" + } + return entry case "claude": return map[string]any{ - "matcher": "Bash|Shell|mcp__.*", + "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), "shell": "bash", "timeout": 10, "statusMessage": "Checking Boatstack execution policy", @@ -232,12 +245,20 @@ func desiredHostHookForEvent(host, event string) map[string]any { } case "codex": return map[string]any{ - "matcher": "Bash|Shell|mcp__.*", + "matcher": "Bash|Shell|Write|Edit|ApplyPatch|Create|Delete|Move|Rename|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), "commandWindows": hookCommandWindows(host), "timeout": 10, "statusMessage": "Checking Boatstack execution policy", }}, } + case "gemini": + return map[string]any{ + "matcher": ".*", "sequential": true, + "hooks": []any{map[string]any{ + "name": "boatstack-safety-guard", "type": "command", "command": hookCommand(host), + "timeout": 10000, "description": "Checking Boatstack execution policy", + }}, + } default: return map[string]any{} } @@ -245,7 +266,10 @@ func desiredHostHookForEvent(host, event string) map[string]any { func hookEvents(host string) []string { if host == "cursor" { - return []string{"beforeShellExecution", "beforeMCPExecution"} + return []string{"preToolUse", "beforeShellExecution", "beforeMCPExecution"} + } + if host == "gemini" { + return []string{"BeforeTool"} } return []string{"PreToolUse"} } @@ -270,6 +294,8 @@ func hostHookConfigPath(repo, host string) string { return filepath.Join(repo, ".claude", "settings.json") case "codex": return filepath.Join(repo, ".codex", "hooks.json") + case "gemini": + return filepath.Join(repo, ".gemini", "settings.json") default: return "" } @@ -277,7 +303,7 @@ func hostHookConfigPath(repo, host string) string { func HostHookPaths(adapters []string) []string { paths := []string{} - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { if contains(adapters, host) { paths = append(paths, filepath.ToSlash(strings.TrimPrefix(hostHookConfigPath("", host), string(filepath.Separator)))) } @@ -312,7 +338,7 @@ func validateBoatstackHookEntry(host, event string, value any) error { } allowedOuter := map[string]bool{} if host == "cursor" { - allowedOuter = map[string]bool{"command": true, "commandWindows": true, "failClosed": true, "timeout": true} + allowedOuter = map[string]bool{"command": true, "commandWindows": true, "failClosed": true, "timeout": true, "matcher": true} if stringValue(entry["command"]) == "" { return fmt.Errorf("cursor Boatstack hook for %s has no command", event) } @@ -321,6 +347,9 @@ func validateBoatstackHookEntry(host, event string, value any) error { } } else { allowedOuter = map[string]bool{"matcher": true, "hooks": true} + if host == "gemini" { + allowedOuter["sequential"] = true + } if stringValue(entry["matcher"]) == "" { return fmt.Errorf("%s Boatstack hook for %s has no matcher", host, event) } @@ -341,8 +370,11 @@ func validateBoatstackHookEntry(host, event string, value any) error { if handler["shell"] != nil && handler["shell"] != "bash" { return fmt.Errorf("claude Boatstack hook for %s must use the bash harness", event) } - } else { + } else if host == "codex" { allowedHandler["commandWindows"] = true + } else if host == "gemini" { + allowedHandler["name"] = true + allowedHandler["description"] = true } for key := range handler { if !allowedHandler[key] { @@ -451,7 +483,7 @@ func InstallHostHooks(repo string, adapters []string) error { // writing, allowing initialization to fail before entering its commit phase. func PrepareHostHooks(repo string, adapters []string) (map[string][]byte, error) { prepared := map[string][]byte{} - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { if !contains(adapters, host) { continue } @@ -512,7 +544,7 @@ func CheckInstalledHostHooks(repo string, adapters []string) error { } func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, event string) (any, error)) error { - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { if !contains(adapters, host) { continue } diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index c48b6bc..6814cab 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -21,17 +21,17 @@ func TestHostHookMergePreservesUnrelatedConfiguration(t *testing.T) { if err := os.WriteFile(path, []byte(initial), 0o644); err != nil { t.Fatal(err) } - if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil { + if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil { t.Fatal(err) } - if err := CheckHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil { + if err := CheckHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil { t.Fatal(err) } value, _ := os.ReadFile(path) if !strings.Contains(string(value), `"theme": "kept"`) || !strings.Contains(string(value), "existing-check.sh") { t.Fatalf("hook merge discarded unrelated configuration: %s", value) } - if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex"}); err != nil { + if err := InstallHostHooks(repo, []string{"cursor", "claude", "codex", "gemini"}); err != nil { t.Fatalf("idempotent reinstall failed: %v", err) } } @@ -64,7 +64,7 @@ func TestHostHookMergeRejectsAmbiguousCollisionAndDrift(t *testing.T) { } func TestGeneratedHostHooksSatisfyHarnessShapes(t *testing.T) { - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { t.Run(host, func(t *testing.T) { for _, event := range hookEvents(host) { entry := desiredHostHookForEvent(host, event) @@ -83,6 +83,15 @@ func TestGeneratedHostHooksSatisfyHarnessShapes(t *testing.T) { t.Fatalf("Codex hook lacks commandWindows: %#v", handler) } } + if host == "cursor" && event == "preToolUse" && !strings.Contains(stringValue(entry["matcher"]), "Write") { + t.Fatalf("Cursor preToolUse does not supervise native writes: %#v", entry) + } + if host == "gemini" { + handler := entry["hooks"].([]any)[0].(map[string]any) + if entry["sequential"] != true || handler["timeout"] != 10000 { + t.Fatalf("Gemini BeforeTool hook has an invalid fail-closed shape: %#v", entry) + } + } } }) } @@ -186,9 +195,12 @@ func TestDiagnoseHookAcceptsCanonicalEventsForEveryHost(t *testing.T) { if host == "cursor" { return []byte(`{"continue":true,"permission":"allow"}`), nil } + if host == "gemini" { + return []byte(`{"decision":"allow"}`), nil + } return nil, nil } - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { t.Run(host, func(t *testing.T) { diagnostic, err := DiagnoseHook(repo, host) if err != nil { @@ -237,6 +249,7 @@ func TestHookDiagnosticRejectsMalformedAllowOutput(t *testing.T) { {"cursor", `not-json`}, {"claude", `{}`}, {"codex", `unexpected`}, + {"gemini", `{}`}, } { if err := validateCanonicalHookOutput(test.host, []byte(test.output)); err == nil { t.Fatalf("%s malformed output was accepted: %q", test.host, test.output) @@ -281,7 +294,7 @@ func TestGuardRejectsTamperedSharedRuntimeBeforeExecution(t *testing.T) { } func TestHookFragmentsAreValidJSON(t *testing.T) { - for _, host := range []string{"cursor", "claude", "codex"} { + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { value, err := hookFragmentJSON(host) if err != nil { t.Fatal(err) diff --git a/boatstack/init.go b/boatstack/init.go index d951194..92ae256 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -587,9 +587,12 @@ func RunInit(options InitOptions) (returnErr error) { fmt.Fprintln(options.Output, " 1. Describe the product change and save the host plan (use .product-loop/intake/ if the host exposes no path).") } fmt.Fprintln(options.Output, "Host activation checklist:") - fmt.Fprintln(options.Output, " Cursor: reload the window and confirm beforeShellExecution and beforeMCPExecution are enabled; the hook is defense in depth.") + fmt.Fprintln(options.Output, " Cursor: reload the window and confirm beforeShellExecution and beforeMCPExecution, plus synchronous preToolUse for native edits, are enabled; the hook is defense in depth.") fmt.Fprintln(options.Output, " Claude Code: reload, then use /hooks to confirm the Boatstack PreToolUse hook is active (Bash is required).") fmt.Fprintln(options.Output, " Codex: trust this exact linked-worktree path, use /hooks to review and trust the Boatstack hook, then start a new task.") + if contains(config.Adapters, "gemini") { + fmt.Fprintln(options.Output, " Gemini CLI: reload and confirm the Boatstack BeforeTool hook is active.") + } fmt.Fprintln(options.Output, "Boatstack start command by host:") fmt.Fprintln(options.Output, " Claude Code: /auto-plan") fmt.Fprintln(options.Output, " Cursor: /auto-plan") @@ -607,7 +610,8 @@ const ExecutionBoundaryDX = ` **Boatstack Execution Boundary:** When the user approves a plan within your native Plan Mode, **do not immediately transition to Auto-Edit or begin mutating product files.** Because this repository is managed by Boatstack, execution must pass through verifiable gates. Instead of executing the code: 1. Save your proposed plan to ` + "`.product-loop/intake/source-plan.md`" + `. -2. Pause and ask the user: *"I have saved the plan. Should we execute this through Boatstack (` + "`/boatstack run`" + `), or do you want me to write the code directly?"* +2. Before auto-plan succeeds, the user may still choose an unmanaged workflow. Once auto-plan creates a saved feature plan, do not offer direct product editing: resolve Boatstack state and continue through plan-gate, approval when configured, and build activation. +3. Async task completion, conversation state, or an execution-mode transition never creates implementation authority. Only a current plan lock does. ` const interceptorHeader = "\n" @@ -655,7 +659,6 @@ func InstallExecutionInterceptors(repo string, adapters []string) error { return nil } - func RunUpdate(options InitOptions) error { options.Update = true return RunInit(options) diff --git a/boatstack/plan.go b/boatstack/plan.go index ccf3d47..e39b88c 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -785,23 +785,27 @@ func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { } type ApprovalOptions struct { - SourcePlanPath string - SpecPath string - PlanPath string - TasksPath string - ApprovedBy string - ApprovedAt string - AuthorizationMode string - SourceCommit string - OutputPath string + SourcePlanPath string + SpecPath string + PlanPath string + TasksPath string + ApprovedBy string + ApprovedAt string + AuthorizationMode string + SourceCommit string + OutputPath string + BaselineDiffSHA256 string + BaselineChangedPaths []string } type ApprovalReceipt struct { - SchemaVersion int - Status string - ApprovedBy string - ApprovedAt string - Fingerprint string + SchemaVersion int + Status string + ApprovedBy string + ApprovedAt string + Fingerprint string + BaselineDiffSHA256 string + BaselineChangedPaths []string } func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { @@ -813,14 +817,21 @@ func LoadApprovalReceipt(path string) (ApprovalReceipt, error) { return ApprovalReceipt{}, err } receipt := ApprovalReceipt{ - SchemaVersion: intValue(value["schema_version"]), - Status: stringValue(value["status"]), - ApprovedBy: stringValue(value["approved_by"]), - ApprovedAt: stringValue(value["approved_at"]), - Fingerprint: stringValue(value["approval_fingerprint"]), - } - if receipt.SchemaVersion != 1 { - return ApprovalReceipt{}, fmt.Errorf("approval receipt schema_version must be 1") + SchemaVersion: intValue(value["schema_version"]), + Status: stringValue(value["status"]), + ApprovedBy: stringValue(value["approved_by"]), + ApprovedAt: stringValue(value["approved_at"]), + Fingerprint: stringValue(value["approval_fingerprint"]), + BaselineDiffSHA256: stringValue(value["baseline_diff_sha256"]), + BaselineChangedPaths: []string{}, + } + if paths, ok := stringSlice(value["baseline_changed_paths"]); ok { + receipt.BaselineChangedPaths = paths + } else if receipt.SchemaVersion == 2 { + return ApprovalReceipt{}, fmt.Errorf("approval receipt baseline_changed_paths must be a string list") + } + if receipt.SchemaVersion != 1 && receipt.SchemaVersion != 2 { + return ApprovalReceipt{}, fmt.Errorf("approval receipt schema_version must be 1 or 2") } if receipt.Status != "APPROVED" { return ApprovalReceipt{}, fmt.Errorf("approval receipt status must be APPROVED") @@ -853,6 +864,21 @@ func CheckApprovalReceipt(path string, planCheck PlanCheck) (ApprovalReceipt, er if receipt.Fingerprint != planCheck.Fingerprint { return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: fingerprint does not match the current source plan, spec, and plan") } + repo, err := ResolveRepository(filepath.Dir(planCheck.PlanPath)) + if err != nil { + return ApprovalReceipt{}, err + } + baseline, err := productBaseline(repo, planCheck.PlanPath, planCheck.SourcePlanPath, planCheck.SpecPath, path) + if err != nil { + return ApprovalReceipt{}, err + } + if receipt.SchemaVersion == 1 { + if baseline.DiffSHA256 != "" { + return ApprovalReceipt{}, fmt.Errorf("schema-v1 approval receipts remain valid only with a clean pre-activation product baseline; changed paths: %s", strings.Join(baseline.ChangedPaths, ", ")) + } + } else if receipt.BaselineDiffSHA256 != baseline.DiffSHA256 || strings.Join(receipt.BaselineChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") { + return ApprovalReceipt{}, fmt.Errorf("stale approval receipt: baseline product diff changed after approval") + } return receipt, nil } @@ -880,6 +906,7 @@ func ActivatePlan(options ActivationOptions) error { authorizationMode := "policy" structuredPlanStatus := "POLICY_ACTIVATED" receipt := ApprovalReceipt{} + baseline := PlanningBaseline{} if config.Workflow.HumanPlanApproval { authorizationMode = "human" structuredPlanStatus = "HUMAN_APPROVED" @@ -890,6 +917,12 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return err } + baseline = PlanningBaseline{DiffSHA256: receipt.BaselineDiffSHA256, ChangedPaths: receipt.BaselineChangedPaths} + } else { + baseline, err = productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.OutputPath) + if err != nil { + return err + } } safety, err := CheckRepositorySafety(repo) if err != nil { @@ -900,15 +933,17 @@ func ActivatePlan(options ActivationOptions) error { } tasksPath := filepath.Join(options.OutDir, "tasks.json") approval := ApprovalOptions{ - SourcePlanPath: check.SourcePlanPath, - SpecPath: check.SpecPath, - PlanPath: options.PlanPath, - TasksPath: tasksPath, - ApprovedBy: receipt.ApprovedBy, - ApprovedAt: receipt.ApprovedAt, - AuthorizationMode: authorizationMode, - SourceCommit: options.SourceCommit, - OutputPath: options.OutputPath, + SourcePlanPath: check.SourcePlanPath, + SpecPath: check.SpecPath, + PlanPath: options.PlanPath, + TasksPath: tasksPath, + ApprovedBy: receipt.ApprovedBy, + ApprovedAt: receipt.ApprovedAt, + AuthorizationMode: authorizationMode, + SourceCommit: options.SourceCommit, + OutputPath: options.OutputPath, + BaselineDiffSHA256: baseline.DiffSHA256, + BaselineChangedPaths: baseline.ChangedPaths, } if fileExists(options.OutputPath) { if err := CheckApprovalLock(approval); err == nil { @@ -936,6 +971,13 @@ func ActivatePlan(options ActivationOptions) error { if err := compilePlanFiles(options.PlanPath, options.OutDir, structuredPlanStatus); err != nil { return err } + currentBaseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.OutputPath, options.OutDir) + if err != nil { + return err + } + if currentBaseline.DiffSHA256 != baseline.DiffSHA256 || strings.Join(currentBaseline.ChangedPaths, "\x00") != strings.Join(baseline.ChangedPaths, "\x00") { + return fmt.Errorf("pre-activation product baseline drifted before the plan lock could be created; expected paths %s, observed paths %s", strings.Join(baseline.ChangedPaths, ", "), strings.Join(currentBaseline.ChangedPaths, ", ")) + } if err := CreateApprovalLock(approval); err != nil { return err } @@ -985,22 +1027,28 @@ func CreateApprovalLock(options ApprovalOptions) error { sourcePlanHash, _ := SHA256File(options.SourcePlanPath) planHash, _ := SHA256File(options.PlanPath) tasksHash, _ := SHA256File(options.TasksPath) + baselinePaths := options.BaselineChangedPaths + if baselinePaths == nil { + baselinePaths = []string{} + } lock := map[string]any{ - "schema_version": 2, - "status": "LOCKED", - "authorization_mode": mode, - "activated_at": approvedAt, - "source_commit": sourceCommit, - "source_plan_path": options.SourcePlanPath, - "source_plan_sha256": sourcePlanHash, - "spec_path": options.SpecPath, - "spec_sha256": specHash, - "plan_path": options.PlanPath, - "plan_sha256": planHash, - "task_graph_path": options.TasksPath, - "task_graph_sha256": tasksHash, - "invalidated_at": nil, - "invalidation_reason": nil, + "schema_version": 2, + "status": "LOCKED", + "authorization_mode": mode, + "activated_at": approvedAt, + "source_commit": sourceCommit, + "source_plan_path": options.SourcePlanPath, + "source_plan_sha256": sourcePlanHash, + "spec_path": options.SpecPath, + "spec_sha256": specHash, + "plan_path": options.PlanPath, + "plan_sha256": planHash, + "task_graph_path": options.TasksPath, + "task_graph_sha256": tasksHash, + "invalidated_at": nil, + "invalidation_reason": nil, + "baseline_diff_sha256": options.BaselineDiffSHA256, + "baseline_changed_paths": baselinePaths, } if mode == "human" { lock["approved_by"] = options.ApprovedBy @@ -1054,6 +1102,9 @@ func CheckApprovalLock(options ApprovalOptions) error { if expectedMode == "policy" && schemaVersion == 1 { mismatches = append(mismatches, "authorization_mode") } + if options.BaselineDiffSHA256 != "" && stringValue(lock["baseline_diff_sha256"]) != options.BaselineDiffSHA256 { + mismatches = append(mismatches, "baseline_diff") + } if schemaVersion != 1 && schemaVersion != 2 { mismatches = append(mismatches, "schema_version") } diff --git a/boatstack/planning.go b/boatstack/planning.go index 5887f1d..e08b25c 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -1,10 +1,13 @@ package boatstack import ( + "bytes" "fmt" "os" + "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" "unicode/utf8" @@ -29,11 +32,128 @@ type PlanningWriteOptions struct { } type ApprovalRecordOptions struct { - PlanPath string - OutputPath string - ApprovedBy string - ApprovedAt string - Fingerprint string + PlanPath string + OutputPath string + ApprovedBy string + ApprovedAt string + Fingerprint string + BaselineDiffSHA256 string +} + +type PlanningBaseline struct { + DiffSHA256 string + ChangedPaths []string +} + +func relativeBaselineExclusions(repo string, paths ...string) map[string]bool { + excluded := map[string]bool{} + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + absolute := path + if !filepath.IsAbs(absolute) { + absolute = filepath.Join(repo, absolute) + } + if canonicalParent, err := filepath.EvalSymlinks(filepath.Dir(absolute)); err == nil { + absolute = filepath.Join(canonicalParent, filepath.Base(absolute)) + } + if relative, err := filepath.Rel(repo, filepath.Clean(absolute)); err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + excluded[filepath.ToSlash(relative)] = true + } + } + return excluded +} + +func productBaseline(repo string, artifactPaths ...string) (PlanningBaseline, error) { + excluded := relativeBaselineExclusions(repo, artifactPaths...) + baselineRef := "HEAD" + if err := exec.Command("git", "-C", repo, "rev-parse", "--verify", "HEAD").Run(); err != nil { + emptyTree := exec.Command("git", "-C", repo, "hash-object", "-t", "tree", "--stdin") + emptyTree.Stdin = strings.NewReader("") + value, hashErr := emptyTree.Output() + if hashErr != nil { + return PlanningBaseline{}, fmt.Errorf("resolve empty repository baseline: %w", hashErr) + } + baselineRef = strings.TrimSpace(string(value)) + } + ignored := func(path string) bool { + path = filepath.ToSlash(path) + if path == ".product-loop" || strings.HasPrefix(path, ".product-loop/") || excluded[path] { + return true + } + for prefix := range excluded { + if strings.HasPrefix(path, strings.TrimSuffix(prefix, "/")+"/") { + return true + } + } + return false + } + changedCommand := exec.Command("git", "-C", repo, "diff", "--name-only", "-z", baselineRef, "--") + changedValue, err := changedCommand.Output() + if err != nil { + return PlanningBaseline{}, fmt.Errorf("inspect tracked planning baseline: %w", err) + } + paths := []string{} + for _, raw := range bytes.Split(changedValue, []byte{0}) { + path := filepath.ToSlash(string(raw)) + if path != "" && !ignored(path) { + paths = append(paths, path) + } + } + untrackedCommand := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard", "-z", "--") + untrackedValue, err := untrackedCommand.Output() + if err != nil { + return PlanningBaseline{}, fmt.Errorf("inspect untracked planning baseline: %w", err) + } + untracked := []string{} + for _, raw := range bytes.Split(untrackedValue, []byte{0}) { + path := filepath.ToSlash(string(raw)) + if path != "" && !ignored(path) { + paths = append(paths, path) + untracked = append(untracked, path) + } + } + sort.Strings(paths) + if len(paths) == 0 { + return PlanningBaseline{ChangedPaths: []string{}}, nil + } + args := []string{"-C", repo, "diff", "--binary", "--no-ext-diff", baselineRef, "--", ".", ":(exclude).product-loop/**"} + for path := range excluded { + args = append(args, ":(exclude)"+path) + } + diffValue, err := exec.Command("git", args...).Output() + if err != nil { + return PlanningBaseline{}, fmt.Errorf("render tracked planning baseline: %w", err) + } + var canonical bytes.Buffer + canonical.Write(diffValue) + sort.Strings(untracked) + for _, path := range untracked { + absolute := filepath.Join(repo, filepath.FromSlash(path)) + info, statErr := os.Lstat(absolute) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return PlanningBaseline{}, fmt.Errorf("untracked planning baseline path is not a regular non-symlink file: %s", path) + } + value, readErr := os.ReadFile(absolute) + if readErr != nil { + return PlanningBaseline{}, fmt.Errorf("read untracked planning baseline path %s: %w", path, readErr) + } + fmt.Fprintf(&canonical, "\nuntracked %s %s\n", path, SHA256Bytes(value)) + } + return PlanningBaseline{DiffSHA256: SHA256Bytes(canonical.Bytes()), ChangedPaths: paths}, nil +} + +func PlanningBaselineForPlan(planPath string) (PlanningBaseline, error) { + check, err := CheckPlan(planPath) + if err != nil { + return PlanningBaseline{}, err + } + repo, err := ResolveRepository(filepath.Dir(planPath)) + if err != nil { + return PlanningBaseline{}, err + } + return productBaseline(repo, planPath, check.SourcePlanPath, check.SpecPath, filepath.Join(filepath.Dir(planPath), "approval.md")) } func rejectSymlinkComponents(root, target string) error { @@ -133,6 +253,10 @@ func RecordApproval(options ApprovalRecordOptions) error { if options.Fingerprint != check.Fingerprint { return fmt.Errorf("approval fingerprint does not match the current plan") } + repo, err := ResolveRepository(filepath.Dir(options.PlanPath)) + if err != nil { + return err + } expectedOutput := filepath.Join(filepath.Dir(options.PlanPath), "approval.md") output := options.OutputPath if output == "" { @@ -156,12 +280,24 @@ func RecordApproval(options ApprovalRecordOptions) error { if err := rejectSymlinkComponents(planDirectory, outputAbsolute); err != nil { return err } + baseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, outputAbsolute) + if err != nil { + return err + } + if baseline.DiffSHA256 != strings.TrimSpace(options.BaselineDiffSHA256) { + if baseline.DiffSHA256 != "" && strings.TrimSpace(options.BaselineDiffSHA256) == "" { + return fmt.Errorf("approval requires the displayed baseline-diff fingerprint because product edits existed when planning began") + } + return fmt.Errorf("baseline product diff drifted after it was displayed") + } payload, err := MarshalJSON(map[string]any{ - "schema_version": 1, - "status": "APPROVED", - "approved_by": strings.TrimSpace(options.ApprovedBy), - "approved_at": approvedAt.Format(time.RFC3339), - "approval_fingerprint": check.Fingerprint, + "schema_version": 2, + "status": "APPROVED", + "approved_by": strings.TrimSpace(options.ApprovedBy), + "approved_at": approvedAt.Format(time.RFC3339), + "approval_fingerprint": check.Fingerprint, + "baseline_diff_sha256": baseline.DiffSHA256, + "baseline_changed_paths": baseline.ChangedPaths, }) if err != nil { return err diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index fb05a74..26306c0 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -92,6 +92,11 @@ func TestPlanningWriteRejectsSymlinksAndPreservesExistingContentOnFailure(t *tes func TestRecordApprovalChecksFingerprintAndWritesOnlyReceipt(t *testing.T) { root := t.TempDir() _, _, planPath := writePlanInputs(t, root, true) + runGit(t, root, "init", "-b", "main") + runGit(t, root, "config", "user.name", "Boatstack Test") + runGit(t, root, "config", "user.email", "boatstack@example.invalid") + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record planning inputs") check, err := CheckPlan(planPath) if err != nil { t.Fatal(err) @@ -123,6 +128,70 @@ func TestRecordApprovalChecksFingerprintAndWritesOnlyReceipt(t *testing.T) { } } +func TestApprovalBindsAndPreservesExistingProductBaseline(t *testing.T) { + root := t.TempDir() + _, _, planPath := writePlanInputs(t, root, true) + runGit(t, root, "init", "-b", "main") + runGit(t, root, "config", "user.name", "Boatstack Test") + runGit(t, root, "config", "user.email", "boatstack@example.invalid") + if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("before\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record planning baseline") + if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("pre-existing operator edit\n"), 0o644); err != nil { + t.Fatal(err) + } + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + baseline, err := PlanningBaselineForPlan(planPath) + if err != nil { + t.Fatal(err) + } + if baseline.DiffSHA256 == "" || len(baseline.ChangedPaths) != 1 || baseline.ChangedPaths[0] != "app.ts" { + t.Fatalf("product baseline was not exposed for approval: %+v", baseline) + } + approval := filepath.Join(root, "approval.md") + if err := RecordApproval(ApprovalRecordOptions{ + PlanPath: planPath, ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z", + Fingerprint: check.Fingerprint, BaselineDiffSHA256: baseline.DiffSHA256, + }); err != nil { + t.Fatal(err) + } + if _, err := CheckApprovalReceipt(approval, check); err != nil { + t.Fatalf("unchanged product baseline invalidated approval: %v", err) + } + writeActivationConfig(t, root, true) + compiled := filepath.Join(root, ".product-loop", "features", "feature-one", "compiled") + lockPath := filepath.Join(root, ".product-loop", "features", "feature-one", "plan.lock.json") + if err := ActivatePlan(ActivationOptions{PlanPath: planPath, ApprovalPath: approval, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { + t.Fatalf("unchanged pre-existing product diff blocked activation: %v", err) + } + content, err := os.ReadFile(filepath.Join(root, "app.ts")) + if err != nil || string(content) != "pre-existing operator edit\n" { + t.Fatalf("activation rewrote the pre-existing product diff: %q %v", content, err) + } + lockValue, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + lock := map[string]any{} + if err := json.Unmarshal(lockValue, &lock); err != nil { + t.Fatal(err) + } + if stringValue(lock["baseline_diff_sha256"]) != baseline.DiffSHA256 { + t.Fatalf("plan lock lost baseline provenance: %#v", lock) + } + if err := os.WriteFile(filepath.Join(root, "app.ts"), []byte("drift after approval\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := CheckApprovalReceipt(approval, check); err == nil || !strings.Contains(err.Error(), "baseline product diff changed") { + t.Fatalf("product drift did not invalidate approval: %v", err) + } +} + func TestDoctorDetectsMissingConfigAdapterAndVersionDrift(t *testing.T) { repo := planningRepo(t) if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module fixture\n"), 0o644); err != nil { diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 457b09d..6549b52 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -87,11 +87,11 @@ ledger while the publisher rechecks the matching receipts. ## Planning boundary -`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and `approval.md` is the human-approval receipt. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt is verified. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents. +`auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and schema-v2 `approval.md` binds human approval to both the plan fingerprint and the displayed pre-activation product-diff baseline. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt and unchanged baseline are verified. Schema-v1 receipts remain compatible only when that baseline is clean. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents while preserving edits that predated managed authority. ## Safety boundary -The generated host hook fragments and launchers are committed installation infrastructure. Their policy is immutable in project configuration. The machine-local helper is ignored and restored by the installer. Safety evidence belongs in the feature evidence ledger: target identity, failure behavior, independent oracle, operational-diff scan, and the operator-only recovery boundary. A source edit is reviewable evidence, not permission to execute it. +The generated host hook fragments and launchers are committed installation infrastructure. Their policy is immutable in project configuration. Cursor `preToolUse`, shell, and MCP events; Claude and Codex `PreToolUse`; and Gemini `BeforeTool` all project into one classifier. The machine-local helper is ignored and restored by the installer. Safety evidence belongs in the feature evidence ledger: target identity, failure behavior, independent oracle, operational-diff scan, and the operator-only recovery boundary. A source edit is reviewable evidence, not permission to execute it. ## PR visual evidence boundary diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 9b43219..67255de 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -1,20 +1,22 @@ # Host hook contracts -Verified against the published host contracts on 2026-07-19. Recheck these +Verified against the published host contracts on 2026-07-22. Recheck these sources before changing a generated adapter or making a stronger enforcement claim. | Host | Configuration and event | Blocking result | Activation boundary | | --- | --- | --- | --- | -| Cursor | `.cursor/hooks.json`; `beforeShellExecution` and `beforeMCPExecution` | JSON `permission: "deny"`; generated entries set `failClosed: true` | Reload and host enablement are operator-visible. A current fast-exit output race is mitigated with a 50 ms settle delay, but Cursor remains defense in depth. | +| Cursor | `.cursor/hooks.json`; synchronous `preToolUse` plus `beforeShellExecution` and `beforeMCPExecution` | JSON `permission: "deny"`; generated entries set `failClosed: true` | Reload and host enablement are operator-visible. Native Write/Edit tools, shell, and MCP mutations all cross the same guard. | | Claude Code | `.claude/settings.json`; `PreToolUse` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The generated command explicitly uses Bash and `${CLAUDE_PROJECT_DIR}`. Reload and confirm with `/hooks`. | | Codex | `.codex/hooks.json`; `PreToolUse` | Exit 0 with `hookSpecificOutput.permissionDecision: "deny"`, or exit 2 with a secret-free error | The project path and exact hook hash must be reviewed and trusted. A linked worktree is a distinct project path. Start a new task after trust changes. | +| Gemini CLI | `.gemini/settings.json`; `BeforeTool` | JSON `decision: "deny"` with a secret-free reason | The generated sequential hook supervises every tool and uses the same repository guard. Reload after installation. | Sources: - Cursor: https://cursor.com/docs/hooks - Claude Code: https://code.claude.com/docs/en/hooks - Codex: https://learn.chatgpt.com/docs/hooks +- Gemini CLI: https://geminicli.com/docs/hooks/reference/ ## Compatibility policy @@ -32,3 +34,8 @@ and slice, branch relation, parent delivery, and the read-only next operation. Every host receives the same instruction to preserve edits and enter managed recovery. A host must never translate denial into a request that the user repeat the push or PR mutation manually. + +Pre-activation denials use `workflow-phase-bypass` and may add only the feature, +observed workflow stage, attempted repository path, and deterministic next +operation. No task notification, conversation turn, or async completion changes +the authorization decision. diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 783e9ce..63595a6 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -247,11 +247,11 @@ When `workflow.pr_visual_evidence` is `suggest` or `require`, every managed plan ### `PLAN -> PLAN_GATE` -Run `boatstack-helper check-plan --plan /plan.md` and present the full draft and returned fingerprint. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only. +Run `boatstack-helper check-plan --plan /plan.md` and present the full draft, plan fingerprint, and product baseline returned by the check. A non-empty baseline includes its exact diff, changed paths, and SHA-256 so edits that existed when managed planning began remain visible and preserved. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only. ### `PLAN_GATE -> PLAN_APPROVED` -When human approval is enabled, invoke `record-approval` with the named human, RFC3339 timestamp, and exact fingerprint; it creates only `approval.md`. When disabled, skip that operation and preserve the checked plan for policy activation. Remain in the host's Plan mode; do not compile machine artifacts or edit product code. +When human approval is enabled, invoke `record-approval` with the named human, RFC3339 timestamp, exact plan fingerprint, and displayed baseline-diff fingerprint (omitted only for a clean baseline); it recomputes both and creates only `approval.md`. When disabled, skip that operation and preserve the checked plan and baseline for policy activation. Remain in the host's Plan mode; do not compile machine artifacts or edit product code. Ask 1-3 finite questions using the global keyed-choice format whether the host renders them through a structured question tool or plain text, then return `WAITING_FOR_INPUT`. Never convert an unavailable question UI into permission to choose a default. A standalone `r` is an explicit human acceptance of all recommendations displayed in that response, not an agent-selected default. Authoritative repository facts are `DISCOVERED`; agent suggestions and repository-derived product choices are `PROPOSED`; only explicit human responses are `ANSWERED`. Every material proposal remains in `blocking_questions` until answered. @@ -260,9 +260,9 @@ Ask 1-3 finite questions using the global keyed-choice format whether the host r At the host's normal Build transition, first confirm the host is in an execution-capable mode. If the transition is rejected or product-code writes remain unavailable, return `READY_FOR_BUILD` without compiling or writing a lock. Once execution is available and before the first product-code edit, `activate-plan` deterministically: 1. parse and validate the marked structured block in `plan.md`; -2. hash the complete source plan, spec, and `plan.md`, matching them to `approval.md` when human approval is enabled; +2. hash the complete source plan, spec, `plan.md`, and pre-activation product baseline, matching them to `approval.md` when human approval is enabled; 3. compile the task graph, requirement-test traceability rows, and evidence skeleton without adding semantics; -4. record authorization mode, timestamp, source commit, and all artifact hashes in plan-lock schema v2, plus approver provenance only for human authorization; +4. record authorization mode, timestamp, source commit, artifact hashes, and baseline diff/path provenance in plan-lock schema v2, plus approver provenance only for human authorization; 5. write the lock last and recheck it before permitting implementation. Activation also initializes ignored, worktree-local Git delivery state bound to the lock. @@ -270,7 +270,9 @@ One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An explicit multi-slice plan starts only its first slice in `BUILD`; later slices remain `PENDING`. -Missing required human approval, unresolved `blocking_questions`, or any change to the source plan, spec, or complete `plan.md` blocks activation and returns the feature to `PLAN_GATE`. A failed or partial compilation never creates a valid lock. Existing schema-v1 human locks remain valid; policy activation always writes schema v2. +Missing required human approval, unresolved `blocking_questions`, or any change to the source plan, spec, complete `plan.md`, or displayed product baseline blocks activation and returns the feature to `PLAN_GATE`. Existing schema-v1 approval receipts remain valid only with a clean product baseline. A failed or partial compilation never creates a valid lock. Existing schema-v1 human locks remain valid; policy activation always writes schema v2. + +After `auto-plan` successfully saves a feature plan, managed authority is latched before activation. Reads and bounded Markdown planning transitions remain available, but native edits, mutation-capable MCP tools, and shell commands not proven read-only are denied until activation creates a current lock. Approval itself does not authorize product edits. Ambiguous, stale, malformed, or unverifiable phase state fails closed with one recovery operation; repositories with no saved managed plan retain ordinary unmanaged behavior. ### `PLAN_LOCKED -> BUILD` diff --git a/boatstack/safety.go b/boatstack/safety.go index c710892..573ede2 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -23,6 +23,8 @@ type SafetyFinding struct { BranchRelation string `json:"branch_relation,omitempty"` NextOperation string `json:"next_operation,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` + WorkflowStage string `json:"workflow_stage,omitempty"` + AttemptedPath string `json:"attempted_path,omitempty"` } type SafetyReport struct { @@ -70,6 +72,144 @@ var mutationStatementPattern = regexp.MustCompile(`(?is)\b(?:delete\s+from\s+(?: var directPublicationPattern = regexp.MustCompile(`(?i)(?:\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|edit|ready|merge)\b|\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)|\bhub\s+pull-request\b|\bcurl\b[^\n;&|]*(?:api\.github\.com|/pulls\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH)))`) var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper\s+publish-pr\b[^\n;&|]*$`) var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\]deliveries|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`) +var mutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create|delete|remove|move|rename|update|insert|upload|install)`) +var planningMutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create)`) +var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|query|fetch|open)(?:[_-]|$)`) + +func controlledPhaseTransition(command, stage string) bool { + if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") { + return false + } + fields := strings.Fields(strings.TrimSpace(command)) + if len(fields) < 2 { + return false + } + executable := strings.TrimSuffix(strings.ToLower(filepath.Base(fields[0])), ".exe") + if executable != "boatstack-helper" { + return false + } + readOnlyHelpers := map[string]bool{ + "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, + "recovery-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, + "doctor": true, "version": true, + } + if readOnlyHelpers[fields[1]] { + return true + } + switch stage { + case "DRAFT_PLAN": + return fields[1] == "planning-write" || fields[1] == "record-approval" + case "INVALID_STATE": + return fields[1] == "planning-write" || fields[1] == "record-approval" + case "APPROVED", "POLICY_READY": + return fields[1] == "activate-plan" || fields[1] == "workspace-cut" + default: + return false + } +} + +func attemptedRepositoryPath(repo string, input any) string { + keys := map[string]bool{"path": true, "file_path": true, "filepath": true, "target_path": true, "destination": true} + var visit func(any) string + visit = func(value any) string { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if keys[strings.ToLower(key)] { + if candidate, ok := child.(string); ok && strings.TrimSpace(candidate) != "" { + path := candidate + if !filepath.IsAbs(path) { + path = filepath.Join(repo, filepath.FromSlash(path)) + } + absolute, err := filepath.Abs(path) + if err != nil { + return "" + } + relative, err := filepath.Rel(repo, absolute) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "" + } + if err := rejectSymlinkComponents(repo, absolute); err != nil { + return "" + } + return filepath.ToSlash(relative) + } + } + } + for _, child := range typed { + if found := visit(child); found != "" { + return found + } + } + case []any: + for _, child := range typed { + if found := visit(child); found != "" { + return found + } + } + } + return "" + } + return visit(input) +} + +func planningMarkdownPath(path string) bool { + if strings.HasPrefix(path, ".product-loop/intake/") && strings.HasSuffix(strings.ToLower(path), ".md") { + return true + } + if !strings.HasPrefix(path, ".product-loop/features/") { + return false + } + parts := strings.Split(filepath.ToSlash(path), "/") + return len(parts) == 4 && featureSlugPattern.MatchString(parts[2]) && planningArtifacts[parts[3]] +} + +func preActivationFinding(repo, attemptedPath string) (SafetyFinding, bool) { + active, err := ActiveManagedDeliveries(repo) + if err != nil { + return SafetyFinding{Category: "workflow-state-invalid", Reason: "managed delivery state cannot be verified", Source: "delivery-state", NextOperation: "repair-state"}, true + } + if len(active) > 0 { + return SafetyFinding{}, false + } + candidates, err := featurePlanCandidates(repo) + if err != nil { + return SafetyFinding{Category: "workflow-state-invalid", Reason: "saved feature plans cannot be verified", Source: "planning-state", NextOperation: "repair-state"}, true + } + if len(candidates) == 0 { + return SafetyFinding{}, false + } + status, err := ResolveNext(repo, "") + if err != nil { + return SafetyFinding{Category: "workflow-state-invalid", Reason: "workflow state cannot be resolved", Source: "planning-state", NextOperation: "repair-state"}, true + } + if status.ObservedStage != "DRAFT_PLAN" && status.ObservedStage != "APPROVED" && status.ObservedStage != "POLICY_READY" && status.ObservedStage != "AMBIGUOUS" && status.ObservedStage != "INVALID_STATE" { + return SafetyFinding{}, false + } + if len(candidates) == 1 && status.ObservedStage != "AMBIGUOUS" { + planPath := filepath.Join(repo, ".product-loop", "features", candidates[0], "plan.md") + check, checkErr := CheckPlan(planPath) + if checkErr != nil { + return SafetyFinding{ + Category: "workflow-phase-bypass", Reason: "saved plan state is invalid", Source: "planning-state", + BlockingFeature: candidates[0], WorkflowStage: "INVALID_STATE", AttemptedPath: attemptedPath, NextOperation: "repair-state", + }, true + } + if status.ObservedStage == "APPROVED" { + approvalPath := filepath.Join(filepath.Dir(planPath), "approval.md") + if _, approvalErr := CheckApprovalReceipt(approvalPath, check); approvalErr != nil { + return SafetyFinding{ + Category: "workflow-phase-bypass", Reason: "approval or product baseline is stale", Source: "planning-state", + BlockingFeature: candidates[0], WorkflowStage: "INVALID_STATE", AttemptedPath: attemptedPath, NextOperation: "plan-gate", + }, true + } + } + } + return SafetyFinding{ + Category: "workflow-phase-bypass", Reason: "product mutation is denied until the saved plan reaches its controlled activation boundary", Source: "planning-state", + BlockingFeature: status.Feature, WorkflowStage: status.ObservedStage, AttemptedPath: attemptedPath, NextOperation: status.NextOperation, + }, true +} func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) { active, err := ActiveManagedDeliveries(repo) @@ -248,6 +388,14 @@ func ClassifyCommand(repo, command string) []SafetyFinding { } } findings := classifySafetyText(command, "command") + if len(findings) > 0 { + return dedupeFindings(findings) + } + if !isPureReadOnlyCommand(command) { + if finding, blocked := preActivationFinding(repo, ""); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) { + return []SafetyFinding{finding} + } + } if regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\b`).MatchString(command) && strings.Contains(command, repo) { findings = append(findings, SafetyFinding{Category: "filesystem-destruction", Reason: "recursive deletion of the repository is denied", Source: "command"}) } @@ -282,7 +430,7 @@ func ClassifyCommand(repo, command string) []SafetyFinding { } func ClassifyTool(repo, name string, input any) []SafetyFinding { - if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") { + if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") || strings.EqualFold(name, "run_shell_command") { if object, ok := input.(map[string]any); ok { return ClassifyCommand(repo, stringValue(object["command"])) } @@ -294,6 +442,15 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { combined := name + " " + string(value) findings := classifySafetyText(combined, "tool-input") nameLower := strings.ToLower(name) + attemptedPath := attemptedRepositoryPath(repo, input) + mutationCapable := mutationToolPattern.MatchString(nameLower) || (strings.HasPrefix(nameLower, "mcp__") && !externalReadOnlyToolPattern.MatchString(nameLower)) + if mutationCapable { + if finding, blocked := preActivationFinding(repo, attemptedPath); blocked { + if finding.WorkflowStage != "DRAFT_PLAN" || attemptedPath == "" || !planningMarkdownPath(attemptedPath) || !planningMutationToolPattern.MatchString(nameLower) { + findings = append(findings, finding) + } + } + } publicationText := strings.ToLower(combined) if deliveryStatePathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) { findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}) @@ -374,6 +531,17 @@ func decodeCursorHook(value []byte) (string, any, error) { toolInput := event["tool_input"] switch eventName { + case "preToolUse": + if _, present := event["tool_name"]; !present { + return "", nil, malformedHookInput("missing-tool-name") + } + if strings.TrimSpace(toolName) == "" { + return "", nil, malformedHookInput("empty-tool-name") + } + if toolInput == nil { + return "", nil, malformedHookInput("missing-tool-input") + } + return toolName, toolInput, nil case "beforeShellExecution": if _, present := event["command"]; !present { return "", nil, malformedHookInput("missing-command") @@ -440,6 +608,25 @@ func decodePreToolUseHook(host string, value []byte) (string, any, error) { return name, input, nil } +func decodeGeminiHook(value []byte) (string, any, error) { + event, err := decodeJSONObject("gemini", value) + if err != nil { + return "", nil, err + } + if eventName := stringValue(event["hook_event_name"]); eventName != "" && eventName != "BeforeTool" { + return "", nil, malformedHookInput("unsupported-event") + } + name := stringValue(event["tool_name"]) + if strings.TrimSpace(name) == "" { + return "", nil, malformedHookInput("missing-tool-name") + } + input, present := event["tool_input"] + if !present || input == nil { + return "", nil, malformedHookInput("missing-tool-input") + } + return name, input, nil +} + func structuredHookDeny(host string, finding SafetyFinding) ([]byte, error) { message := denialMessage(host, finding) value, err := json.Marshal(map[string]any{ @@ -475,6 +662,17 @@ var hookHostContracts = map[string]hookHostContract{ allow: func() ([]byte, error) { return nil, nil }, deny: func(finding SafetyFinding) ([]byte, error) { return structuredHookDeny("codex", finding) }, }, + "gemini": { + decode: decodeGeminiHook, + allow: func() ([]byte, error) { + value, err := json.Marshal(map[string]any{"decision": "allow"}) + return append(value, '\n'), err + }, + deny: func(finding SafetyFinding) ([]byte, error) { + value, err := json.Marshal(map[string]any{"decision": "deny", "reason": denialMessage("gemini", finding)}) + return append(value, '\n'), err + }, + }, } func denialMessage(host string, finding SafetyFinding) string { @@ -497,6 +695,21 @@ func denialMessage(host string, finding SafetyFinding) string { if finding.Category == "workflow-state-tamper" { return "Boatstack denied direct delivery-state mutation. Use the active build, test, review, or ship transition instead of editing runtime authority." } + if finding.Category == "workflow-phase-bypass" { + target := "the saved Boatstack plan" + if finding.BlockingFeature != "" { + target = fmt.Sprintf("Boatstack feature %q", finding.BlockingFeature) + } + path := "" + if finding.AttemptedPath != "" { + path = " Attempted path: " + finding.AttemptedPath + "." + } + next := finding.NextOperation + if next == "" { + next = "repair-state" + } + return fmt.Sprintf("Boatstack denied product mutation because %s is at %s.%s Continue with %s; unrelated task completions do not authorize implementation.", target, finding.WorkflowStage, path, next) + } if finding.Category == "workflow-publication-bypass" { target := "the active managed delivery" if finding.BlockingFeature != "" { diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index 6ec254c..e1fb5f5 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -24,6 +24,25 @@ func safetyTestRepo(t *testing.T) string { return repo } +func writeValidSavedFeaturePlan(t *testing.T, repo, feature string) string { + t.Helper() + directory := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Source plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "spec.md"), []byte("# Feature spec\n"), 0o644); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["feature_id"] = feature + planPath := filepath.Join(directory, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + return planPath +} + func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { repo := safetyTestRepo(t) cases := map[string]string{ @@ -363,3 +382,122 @@ func TestSafetyGuardLatencyIsBounded(t *testing.T) { t.Fatalf("2,000 safety classifications exceeded the 2s fixture bound: %s", elapsed) } } + +func TestPreActivationMutationInterlockLatchesAfterAutoPlan(t *testing.T) { + repo := nextTestRepo(t) + writeValidSavedFeaturePlan(t, repo, "guarded-feature") + statusBefore, err := gitCommand(repo, "status", "--short") + if err != nil { + t.Fatal(err) + } + + assertBlocked := func(label string, findings []SafetyFinding) { + t.Helper() + if len(findings) == 0 || findings[0].Category != "workflow-phase-bypass" || findings[0].WorkflowStage != "DRAFT_PLAN" || findings[0].NextOperation != "plan-gate" { + t.Fatalf("%s escaped the draft plan interlock: %#v", label, findings) + } + } + assertBlocked("native edit", ClassifyTool(repo, "Write", map[string]any{"file_path": "src/app.ts", "content": "changed"})) + assertBlocked("patch", ClassifyTool(repo, "ApplyPatch", map[string]any{"path": "src/app.ts", "patch": "diff"})) + assertBlocked("shell redirection", ClassifyCommand(repo, "printf changed > src/app.ts")) + assertBlocked("package installation", ClassifyCommand(repo, "npm install example")) + assertBlocked("MCP mutation", ClassifyTool(repo, "mcp__files__update", map[string]any{"path": "src/app.ts"})) + assertBlocked("unknown MCP capability", ClassifyTool(repo, "mcp__files__act", map[string]any{"path": "src/app.ts"})) + + if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 { + t.Fatalf("read-only inspection was denied: %#v", findings) + } + if findings := ClassifyTool(repo, "mcp__files__read", map[string]any{"path": "src/app.ts"}); len(findings) != 0 { + t.Fatalf("explicitly read-only MCP inspection was denied: %#v", findings) + } + if findings := ClassifyCommand(repo, ".product-loop/bin/boatstack-helper check-plan --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { + t.Fatalf("bounded plan inspection was denied: %#v", findings) + } + if findings := ClassifyTool(repo, "Write", map[string]any{"file_path": ".product-loop/features/guarded-feature/plan.md", "content": "# revised plan"}); len(findings) != 0 { + t.Fatalf("bounded planning Markdown was denied: %#v", findings) + } + if findings := ClassifyCommand(repo, ".product-loop/bin/boatstack-helper record-approval --plan .product-loop/features/guarded-feature/plan.md"); len(findings) != 0 { + t.Fatalf("exact approval transition was denied: %#v", findings) + } + statusAfter, err := gitCommand(repo, "status", "--short") + if err != nil { + t.Fatal(err) + } + if statusAfter != statusBefore { + t.Fatalf("denied operations changed the worktree: before=%q after=%q", statusBefore, statusAfter) + } +} + +func TestPreActivationInterlockPreservesUnmanagedAndActivatedBehavior(t *testing.T) { + unmanaged := nextTestRepo(t) + if findings := ClassifyTool(unmanaged, "Write", map[string]any{"file_path": "src/app.ts"}); len(findings) != 0 { + t.Fatalf("unmanaged product editing changed: %#v", findings) + } + + approved := nextTestRepo(t) + planPath := writeValidSavedFeaturePlan(t, approved, "approved-feature") + runGit(t, approved, "config", "user.name", "Boatstack Test") + runGit(t, approved, "config", "user.email", "boatstack@example.invalid") + runGit(t, approved, "add", ".") + runGit(t, approved, "commit", "-m", "record approved plan") + approvalPath := filepath.Join(approved, ".product-loop", "features", "approved-feature", "approval.md") + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + writeApprovalReceipt(t, approvalPath, check.Fingerprint) + if _, err := CheckApprovalReceipt(approvalPath, check); err != nil { + t.Fatal(err) + } + findings := ClassifyTool(approved, "Edit", map[string]any{"path": "src/app.ts"}) + if len(findings) == 0 || findings[0].WorkflowStage != "APPROVED" || findings[0].NextOperation != "build" { + t.Fatalf("approved-but-not-activated product edit escaped: %#v", findings) + } + + policy := nextTestRepo(t) + config := testConfig() + config.Workflow.HumanPlanApproval = false + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(policy, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } + writeValidSavedFeaturePlan(t, policy, "policy-feature") + findings = ClassifyTool(policy, "Write", map[string]any{"path": "src/app.ts"}) + if len(findings) == 0 || findings[0].WorkflowStage != "POLICY_READY" || findings[0].NextOperation != "build" { + t.Fatalf("policy-ready product edit escaped: %#v", findings) + } +} + +func TestCursorPreToolUseDeniesNativeEditAfterAutoPlan(t *testing.T) { + repo := nextTestRepo(t) + writeValidSavedFeaturePlan(t, repo, "cursor-feature") + input := []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`) + for attempt := 0; attempt < 2; attempt++ { // a conversation notification cannot change authority + output, denied := HookDecision(SafetyHookOptions{Host: "cursor", Repo: repo, Input: input}) + if !denied || !strings.Contains(string(output), `"permission":"deny"`) || !strings.Contains(string(output), "plan-gate") { + t.Fatalf("Cursor native edit was not deterministically denied: %s", output) + } + } +} + +func TestPreActivationNativeEditIsDeniedAcrossHostContracts(t *testing.T) { + repo := nextTestRepo(t) + writeValidSavedFeaturePlan(t, repo, "host-conformance") + tests := map[string][]byte{ + "cursor": []byte(`{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`), + "claude": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`), + "codex": []byte(`{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"src/app.ts","content":"changed"}}`), + "gemini": []byte(`{"hook_event_name":"BeforeTool","tool_name":"write_file","tool_input":{"file_path":"src/app.ts","content":"changed"}}`), + } + for host, input := range tests { + t.Run(host, func(t *testing.T) { + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: input}) + if !denied || !strings.Contains(string(output), "plan-gate") { + t.Fatalf("%s native mutation escaped: %s", host, output) + } + }) + } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index cae7a41..600049f 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 **12954 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 **13297 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 [`c457045039b798a9db660b0f102753fc6bb0a975`](https://github.com/operatorstack/intelligence-flow/tree/c457045039b798a9db660b0f102753fc6bb0a975/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 [`a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9`](https://github.com/operatorstack/intelligence-flow/tree/a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9/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 f5e02a3..66daefb 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "c457045039b798a9db660b0f102753fc6bb0a975", + "source_commit": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" }, { "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:c457045039b798a9db660b0f102753fc6bb0a975" + "last_verified_version": "source:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d269f12..faa2429 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -147,3 +147,9 @@ update confirmation. Successful publication activates the next declared slice. ## GitHub CLI is unavailable Boatstack retains the validated `pr.md`. Authenticate or install GitHub CLI and repeat the open/update confirmation, or copy the exact preview into GitHub manually. Neither path authorizes merge. + +## A product edit is denied before build + +After `auto-plan` saves a feature plan, Boatstack owns the workflow boundary even though implementation has not started. Review the reported stage and continue with `plan-gate` when the plan is draft, or `build` when it is approved or policy-ready. Product files remain unchanged until build activation creates a current lock. Do not retry through another editor, shell redirection, package installer, or MCP tool; all supported host events share the same decision. If the denial reports ambiguous, stale, or invalid state, follow its single recovery operation. + +If `check-plan` reports a non-empty product baseline, inspect its exact diff and changed paths. Pass the displayed baseline fingerprint to `record-approval`. A changed fingerprint means the pre-existing edits drifted; preserve them, review the new diff, and approve again. Schema-v1 receipts remain valid only when this baseline is clean. diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 4d89392..aa46b84 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": "c457045039b798a9db660b0f102753fc6bb0a975", + "source_commit": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", "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-07-22-pre-activation-mutation-interlock.md b/release-notes/2026-07-22-pre-activation-mutation-interlock.md new file mode 100644 index 0000000..b3c0c11 --- /dev/null +++ b/release-notes/2026-07-22-pre-activation-mutation-interlock.md @@ -0,0 +1,10 @@ +### Block product mutation between auto-plan and activation + +Boatstack now latches managed authority when `auto-plan` saves a feature plan. +Native edits, mutation-capable MCP calls, package installation, and shell commands +not proven read-only are denied until build activation creates a current lock. +Cursor native tools and Gemini `BeforeTool` now join the existing shell and host +guards. Plan approval also fingerprints any pre-existing product diff, preserves +it, and rejects drift before approval or activation. Human approval remains enabled +by default, unmanaged pre-auto-plan work is unchanged, and schema-v1 approval +receipts remain valid with a clean baseline.