From 0ef7b73ceef21dde19f463757e69394eb349a455 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Wed, 22 Jul 2026 22:21:54 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 3fc5f6eb68c2 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 61 +- boatstack/SKILL.md | 8 +- boatstack/cmd/boatstack-helper/main.go | 80 ++- boatstack/command.go | 49 ++ boatstack/command_test.go | 64 ++ boatstack/delivery.go | 7 + boatstack/delivery_test.go | 22 + boatstack/export.go | 4 +- boatstack/export_test.go | 2 +- boatstack/hooks.go | 20 +- boatstack/hooks_test.go | 2 +- boatstack/init.go | 41 +- boatstack/operation.go | 667 ++++++++++++++++++ boatstack/operation_test.go | 313 ++++++++ boatstack/pr.go | 103 ++- boatstack/references/artifacts.md | 5 +- boatstack/references/failure-moves.md | 2 + boatstack/references/host-hook-contracts.md | 15 +- boatstack/references/workflow.md | 25 +- boatstack/release.go | 6 +- boatstack/safety.go | 247 ++++++- boatstack/safety_test.go | 4 +- boatstack/update_publication.go | 349 +++++++++ boatstack/update_publication_test.go | 188 +++++ docs/evidence-engineered-coding.md | 4 +- docs/generated-files.md | 2 +- docs/getting-started.md | 4 +- docs/public-claims.json | 24 +- docs/troubleshooting.md | 10 + labs/diagram-json/plan.lock.json | 2 +- ...026-07-22-durable-operation-supervision.md | 12 + 32 files changed, 2227 insertions(+), 117 deletions(-) create mode 100644 boatstack/command.go create mode 100644 boatstack/command_test.go create mode 100644 boatstack/operation.go create mode 100644 boatstack/operation_test.go create mode 100644 boatstack/update_publication.go create mode 100644 boatstack/update_publication_test.go create mode 100644 release-notes/2026-07-22-durable-operation-supervision.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1856027..3afc06c 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/a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9/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/3fc5f6eb68c22007a46434b4af9dacc31dcc04ed/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 364df05..257a8f2 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 53187, - "estimated_tokens": 13297, + "characters": 56257, + "estimated_tokens": 14065, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "224a7dc56b254511c0f149effcce668590f3df06c8eedfdc88798c17e75ffe0d", + "CONTRIBUTING.md": "1971ef51d571471a54b88c648cf7d8027057155efb75125647042997a31ae078", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", "assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346", - "boatstack/SKILL.md": "d28d3916dfdb96bddaf01c46118d24720269041485fabbeb71019a6a2d402129", + "boatstack/SKILL.md": "16207abad9b24a3e18eb1edad56e5148fe6236226766760ff46b41f15737516b", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", @@ -34,21 +34,23 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "e769b9aa4894260a53352865741bc52ad1e257213e270fa3a1d13cf68d07d5d3", + "boatstack/cmd/boatstack-helper/main.go": "093e9b0e28931febf1dfb150dc83a46e4b8a8411693ea56409e25cbe6a50ee9e", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", + "boatstack/command.go": "94d2117c6e390d5a644afc5cd90f7e712e3f8b1032f8c9e3b253cc134524c28a", + "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/config_documentation_test.go": "bd34ac570d08c8d1d1e4f5e86c327c02a55dd5e96bd785b895a05f28fcd3f7a2", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "f203cbfa78a79609fb4f9e66dd013c0c9d8ff8da478cc1ab7e75b425c66b3920", - "boatstack/delivery_test.go": "e669e24bb5bb3cc5d48c6dcfb1e20ad83ddaa26161785254a7aab9d3b1219e11", + "boatstack/delivery.go": "96b55c27d320c0a218b1db4ea9c61a8687dfcff8c6f9d3f1126f5b7ae7ed35bd", + "boatstack/delivery_test.go": "5ce806257307af5d2e4c74c07c77c3db119398f60953890d9b72f95cb168cae2", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "71a3e1493947537cdf271c7a2445ecd7157dee89af84ea548a06f3165cb69780", - "boatstack/export_test.go": "51b0340bec3decdb3e13b2a6cd73dd15e16e906031acb0c98cc553da54622162", + "boatstack/export.go": "4deaef656e181533230b63bfbddac0c5169048a68f150ee1f32282b4a381ce1c", + "boatstack/export_test.go": "d2f57fd5af57ac0992ad198c1982c848b8fa39699c0af9a206b72960efb3e460", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "2895ecb6f20397f2c39b3af8fe37c704a6048cc05d742efce6aae554ce659cfe", - "boatstack/hooks_test.go": "eedef3fe5d0f5b6d27c3290965cadc10f76f6b4fef5860c690d62672850caf3f", - "boatstack/init.go": "013e1fe1f4d058442558e8057e261178af53b0dfd7ea4d72517d0ad8d9d8ca23", + "boatstack/hooks.go": "ac67d7d0beea65a3eb719679d80213f911bfad7da7f16397d9831062a03839ce", + "boatstack/hooks_test.go": "c5786bc6463cf6932a6612b26cbe65d035008ecbca5c0063bea253d857c2f622", + "boatstack/init.go": "816d2a59ea8a48e078f72158c134304393804f7530c38d4e117419cb5ba24391", "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", @@ -56,38 +58,42 @@ "boatstack/migrate_test.go": "9f4bda2fb158c5e54bcc0242dace1da3c1965f9846a213c573956a35b7d1724e", "boatstack/next.go": "6bf119965704e8979921aaf514b205f85082716f34a5d1901ed63c175103227b", "boatstack/next_test.go": "641bf32cf160c426531134a4756b0c41c6a6d876d515bb151cd01fd9c1ae5056", + "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", + "boatstack/operation_test.go": "2d624eaba342b2c81b45cdf50918a65a9c002b5376a02041b24180658ee6a25a", "boatstack/plan.go": "8189ee42902bce62dcd39ce7a2e423cecb4c9e0d1dd34a5497e5a732a45c851f", "boatstack/plan_test.go": "ea96bf0047a43e8224f02e0b8761978ce48636a83a63b6296c9bd3336f51250c", "boatstack/plan_validation.go": "412f06750832fe46f01190ea5e475fc6f6ea59c8ba78131f94ec031053a405d2", "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", "boatstack/planning.go": "ef4507a9fecc900f0691372c50883f328c9232c3dfd6986fbde26fb7ef436ae3", "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", - "boatstack/pr.go": "173392fd40ff1c111ec89b1f3dabaf666347be299ac7653bfe5ea2de407fc26f", + "boatstack/pr.go": "d277d03008f0af2443d754998784a6e8d9540298294658b578565ff7d8fd354a", "boatstack/pr_test.go": "838e6c244aed84317d00e6f49de6b65bb08f6fe1e1a11fb37aad8428ae2f6ea1", "boatstack/recovery.go": "dd816b18b54a0085b8d8276a93ee98d2b1e90099059a0d85cf6e24edf6f37d5b", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", - "boatstack/references/artifacts.md": "d49b3c207546ba788a833078c422c3d04390a4997a3322451dcb8fddd4ccff08", + "boatstack/references/artifacts.md": "3f88ef39233b8e4b3554651c0d3ff6c8fade0512f69a912404976d90bf9ff304", "boatstack/references/config-schema.md": "c07c2d532ef95ea6ae538a1fffefb92ded1f8dc6a06eb3b8d463e371d1ed8416", - "boatstack/references/failure-moves.md": "3c3f654fd3a2319ca21218e921d37b31b4d926c2df5febd9c92402bffae40cd4", - "boatstack/references/host-hook-contracts.md": "36b125ac121b9ace5de30f0783e2068a47e8cb6d20a75852e5aff52f25334622", + "boatstack/references/failure-moves.md": "9daee2131050e9e0fd3d5f18385b7363e552da28fdf0dc35146f651e62071233", + "boatstack/references/host-hook-contracts.md": "7414a633dee8e8bd5d4516562f2030561f994366e6a02d0e18e0af152e80faa6", "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "a9e5a6330e1ce850b8a2a3e5af3ed9d9ea4c62d56a16638a7c9cd8ef015a5fbd", - "boatstack/release.go": "fa2ac926df89c90c5844e938a2e02d4b8dbbaefbf85bb7a1a89fc51690bea520", + "boatstack/references/workflow.md": "75c9b433a0c42275e47046ad5a3d5a1be4fdec1004dcdf91369fb6f839b8c582", + "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "fbdbf583c862c41f23d1a200f53d042842db72f19c29fe94e4288a69b0ac4a6b", "boatstack/run_test.go": "34484285fd2457b84faf6d5353117b74af73d4601cd60a96e270b7fd0a7a6a8e", "boatstack/runtime.go": "687b3543c22acbbed91c94e798c2a813bd828e087ac180ab29a21e3a429f9971", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", - "boatstack/safety.go": "6e213386fcaa6471f178cceb4154baca2d07254a2eb0274b40b6bfebe4f3b8c0", - "boatstack/safety_test.go": "c889117eee9d288da67781747886ddde01c4552abd6a6c389a9361ee4fa2c177", + "boatstack/safety.go": "56dd3760f16aa083d95d740f160770c21429f2ef2b7b2f6a940956a8ad50f774", + "boatstack/safety_test.go": "dc21b6ee72221d3913fbac11e13af56c5a38c9e7b31dba6113fb5dde02036741", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", "boatstack/update.go": "792ba4481503590e1412c0d2ddbfdd912899218a8e3ebcf323e385698b498ab0", + "boatstack/update_publication.go": "b1c967a4f6e43d7be2d8d71734bcb0563bc062a0ed152f56506c62a046ed54cd", + "boatstack/update_publication_test.go": "df07663ceec8a8055eaac6a8b1583bc9ef38c033fac191ec74c821d19238305a", "boatstack/update_test.go": "b7d989c3c74a9e4d80ad14f9f74d94b617798dbcc36359327a4233524677db22", "boatstack/visual_evidence.go": "90a68d554e10ff4fb7afa45000912cdedd4cdf93b3d279055b50844401924f01", "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", @@ -97,14 +103,14 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "f530c5dcbacf32dcb6fdab590901d8f658a4f29bd93f6264cec5d4f449c2cbd1", - "docs/evidence-engineered-coding.md": "9dcbd6ba9ba02aa263de2cb15fa8ed7722e07358e04913fd4b63aa9aeb00295e", - "docs/generated-files.md": "bb8cd6e4b7d0042c899685a916ea0170ece363388328786cb0ba8fad6df3b780", - "docs/getting-started.md": "eacc814fdffdfa3c7d8052b7cd99a79c04da5c75d88d8b44f3fb68d9afec0316", - "docs/public-claims.json": "e00c0b7cf4a5bc4d3cc672ccf7162c650aa315a5e0ed8a6bd3bf8ce8e6ad0f98", + "docs/evidence-engineered-coding.md": "8eba4e45b1969d0418306a8b6156c56e93263ab2d2b9f6cd790f67998ffc3ef7", + "docs/generated-files.md": "bb9b9efe69d97471820c225a9dcf5fda75e4896c20c9be6afb0db22a6bc9049e", + "docs/getting-started.md": "f741cfc69ac7123709b6120a1cbcc57a6e5559a6b7783bdb9f495a3cb845bd33", + "docs/public-claims.json": "49dbe4592a124ccccd4fe0ce907bb1b403521ec57905e2760372dcf274360ea8", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "b975f818360440d992d19f601040c7248c9fc23edc0c4f902e09daf056df926d", + "docs/troubleshooting.md": "065fe8580215b4964d676fd6f6fa69f12720ddf0f55a951c167360637f4dfede", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", @@ -114,7 +120,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": "e112e544a997e89821b3a49ada2d789cd47c2bbcd974f6cf5a08b1b2ed687218", + "labs/diagram-json/plan.lock.json": "57689bcf7c38c94f7bd59f3acd901b9446c1d4cc6545f0ef1a11a39dc618f92e", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -165,6 +171,7 @@ "release-notes/2026-07-22-automatic-post-publication-recovery.md": "b9137093343270adf5b8ebdf3ec860cdb254edf28ea148e5aada26d22ce1e39a", "release-notes/2026-07-22-boundary-analysis-dx.md": "60d727ab3b109fff95a82eb36ee4c6c5833760535b14f386fda349a21b4fe588", "release-notes/2026-07-22-boundary-oracle-loop.md": "698c2ed7dd0a000e6e210f521989992b8fa476376987819ba92c12feb3528f7c", + "release-notes/2026-07-22-durable-operation-supervision.md": "b3e5376da1957eddebd9d06fdb1b1ee5c7253513d733f6f29f21a47970a5cb00", "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", @@ -175,7 +182,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", + "commit": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 6a36d9c..b5641f0 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -33,9 +33,9 @@ Run the project-local helper's read-only `next-status --repo . --json` inspectio ## Run through ship -For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json`. When one saved source plan is ready, enter `auto-plan`; when no source plan exists, stop and ask the user to save the host Plan-mode file. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Before the first delivery-stage operation (`build`, `repair`, `test-gate`, `review-gate`, or `ship-gate`), run `run-preflight --repo . --json`. Planning and approval do not require a remote fetch. The preflight fetches `origin` and verifies the current named branch contains the fetched delivery base and is not behind or diverged from its upstream. A failed fetch, missing remote/base, stale base, upstream drift, or constrained branch mismatch blocks before delivery mutation. Never repair freshness by merging, rebasing, switching or creating a constrained branch, discarding changes, force-pushing, or broadening permissions. +For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When one saved source plan is ready, enter `auto-plan`; when no source plan exists, stop and ask the user to save the host Plan-mode file. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Before the first delivery-stage operation (`build`, `repair`, `test-gate`, `review-gate`, or `ship-gate`), run `run-preflight --repo . --json`. Planning and approval do not require a remote fetch. The preflight fetches `origin` and verifies the current named branch contains the fetched delivery base and is not behind or diverged from its upstream. A failed fetch, missing remote/base, stale base, upstream drift, or constrained branch mismatch blocks before delivery mutation. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. -After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice in one invocation. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Do not persist autopilot state or use conversation as workflow evidence. Completion means every slice PR is published for review, never merged or deployed. +After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice; the durable delivery attempt count does not reset across turns or hosts. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Persist execution facts and retry identity, never autonomous workflow intent; conversation is not workflow evidence. Completion means every slice PR is published for review, never merged or deployed. ## Enforce the irreversible-operation boundary @@ -220,8 +220,8 @@ Treat `boatstack-update` as infrastructure maintenance, never as feature work: 2. Fetch the configured default branch without editing product files. Require that branch to be current and clean; otherwise return **Update postponed** and change nothing. 3. Create only `chore/update-boatstack-v`. Run the installer fetched from the exact release tag in update mode with the exact version, repository path, and non-interactive preview acceptance. 4. Preserve `.boatstack-project.json`, all portable adapters, optional integration selections, and unrelated host settings. Block on generated drift, collisions, missing provenance, a failed checksum, a failed `doctor`, or any product-file change. -5. Show the version transition, release notes, integration state, changed infrastructure paths, exact diff, checksums, rollout, and rollback. Respond **Boatstack update ready** and render the one next action as: Reply `o` to open update PR. -6. Only the state-scoped `o` or compatible full reply authorizes staging the reviewed infrastructure paths, committing, normally pushing, and opening the update PR. Never merge it. If GitHub publication is unavailable, retain the prepared branch and provide one manual action. +5. Run `prepare-update-pr --repo . --version --json`; show its exact non-empty fingerprinted package with the version transition, release notes, integration state, changed infrastructure paths, checksums, rollout, and rollback. Respond **Boatstack update ready** and render the one next action as: Reply `o` to open update PR. +6. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher owns staging, the exact commit, normal push, and one PR. If its response is interrupted, inspect `operation-status` and reconcile the exact branch/PR rather than repeating GitHub mutation. Never merge it. If GitHub publication is unavailable, retain the prepared branch and provide one manual action. Natural requests such as “Update Boatstack” use this operation. `doctor` may display a cached notice but must remain offline. Do not perform release discovery during planning, approval, build, test, review, or PR preview. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 67689cf..497d58f 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -92,6 +92,78 @@ func checkUpdateCommand(arguments []string) int { return 0 } +func operationStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("operation-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose durable operation state should be inspected") + operationID := flags.String("operation-id", "", "specific operation identity; omit only when the current branch has at most one unfinished operation") + jsonOutput := flags.Bool("json", false, "emit the versioned JSON projection") + if err := flags.Parse(arguments); err != nil { + return 2 + } + status, err := boatstack.ResolveOperationStatus(*repo, *operationID) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(status) + if err != nil { + return fail(err) + } + if *jsonOutput { + fmt.Print(string(value)) + } else if status.Operation == nil { + fmt.Printf("OPERATION_STATUS=%s\nNEXT_OPERATION=%s\n", status.VerificationStatus, status.NextOperation) + } else { + fmt.Printf("OPERATION_STATUS=%s\nOPERATION_ID=%s\nSTATE=%s\nATTEMPT=%d/%d\nNEXT_OPERATION=%s\n", status.VerificationStatus, status.Operation.OperationID, status.Operation.State, status.Operation.Attempt, status.Operation.MaxAttempts, status.NextOperation) + } + if status.VerificationStatus == "AMBIGUOUS" { + return 1 + } + return 0 +} + +func prepareUpdatePRCommand(arguments []string) int { + flags := flag.NewFlagSet("prepare-update-pr", flag.ContinueOnError) + repo := flags.String("repo", ".", "updated Boatstack repository") + version := flags.String("version", "", "exact installed stable version") + jsonOutput := flags.Bool("json", false, "emit the fingerprinted preview as JSON") + if err := flags.Parse(arguments); err != nil { + return 2 + } + preview, err := boatstack.PrepareUpdatePublication(*repo, *version) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(preview) + if err != nil { + return fail(err) + } + if *jsonOutput { + fmt.Print(string(value)) + } else { + fmt.Printf("UPDATE_PREVIEW=%s\nPREVIEW_FINGERPRINT=%s\nPACKAGE_FINGERPRINT=%s\n", preview.PreviewPath, preview.Fingerprint, preview.PackageFingerprint) + } + return 0 +} + +func publishUpdatePRCommand(arguments []string) int { + flags := flag.NewFlagSet("publish-update-pr", flag.ContinueOnError) + repo := flags.String("repo", ".", "updated Boatstack repository") + preview := flags.String("preview", "", "exact machine-local update preview path") + fingerprint := flags.String("preview-fingerprint", "", "fingerprint confirmed by the human") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *preview == "" || *fingerprint == "" { + return fail(fmt.Errorf("publish-update-pr requires --preview and --preview-fingerprint")) + } + url, err := boatstack.PublishUpdatePublication(boatstack.UpdatePublishOptions{Repo: *repo, PreviewPath: *preview, ExpectedFingerprint: *fingerprint}) + if err != nil { + return fail(err) + } + fmt.Printf("PR_URL=%s\n", url) + return 0 +} + func releaseClassifyCommand(arguments []string) int { flags := flag.NewFlagSet("release-classify", flag.ContinueOnError) repo := flags.String("repo", ".", "projected Boatstack repository") @@ -824,7 +896,7 @@ func workspaceStatusCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -834,6 +906,12 @@ func run() int { return updateCommand(os.Args[2:]) case "check-update": return checkUpdateCommand(os.Args[2:]) + case "operation-status": + return operationStatusCommand(os.Args[2:]) + case "prepare-update-pr": + return prepareUpdatePRCommand(os.Args[2:]) + case "publish-update-pr": + return publishUpdatePRCommand(os.Args[2:]) case "release-classify": return releaseClassifyCommand(os.Args[2:]) case "next-patch": diff --git a/boatstack/command.go b/boatstack/command.go new file mode 100644 index 0000000..d2e8804 --- /dev/null +++ b/boatstack/command.go @@ -0,0 +1,49 @@ +package boatstack + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// commandChannels preserves the subprocess transport boundary. Stdout is the +// only authority-bearing channel; stderr is diagnostic even when the command +// exits successfully. +type commandChannels struct { + Stdout []byte + Stderr []byte +} + +func runCommandChannels(command *exec.Cmd) (commandChannels, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + return commandChannels{Stdout: stdout.Bytes(), Stderr: stderr.Bytes()}, err +} + +func commandFailure(channels commandChannels, runErr error) error { + message := strings.TrimSpace(string(channels.Stderr)) + if message == "" { + message = strings.TrimSpace(string(channels.Stdout)) + } + if message == "" && runErr != nil { + message = runErr.Error() + } + return fmt.Errorf("%s", boundedObservation(message)) +} + +// commandOutput returns only successful stdout for machine parsing. Successful +// stderr can contain warnings, progress, locale text, or host diagnostics and +// must never become a path, ref, URL, fingerprint, or workflow status. +func commandOutput(repo string, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + command.Dir = repo + channels, err := runCommandChannels(command) + if err != nil { + return "", commandFailure(channels, err) + } + return strings.TrimSpace(string(channels.Stdout)), nil +} diff --git a/boatstack/command_test.go b/boatstack/command_test.go new file mode 100644 index 0000000..83a4e67 --- /dev/null +++ b/boatstack/command_test.go @@ -0,0 +1,64 @@ +package boatstack + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCommandOutputSeparatesAuthorityFromDiagnostics(t *testing.T) { + t.Setenv("BOATSTACK_COMMAND_HELPER", "success") + output, err := commandOutput(t.TempDir(), os.Args[0], "-test.run=^TestCommandOutputHelperProcess$") + if err != nil { + t.Fatal(err) + } + if output != ".cursor/commands/boatstack-update.md" { + t.Fatalf("stderr contaminated machine output: %q", output) + } + + t.Setenv("BOATSTACK_COMMAND_HELPER", "failure") + output, err = commandOutput(t.TempDir(), os.Args[0], "-test.run=^TestCommandOutputHelperProcess$") + if err == nil || output != "" || !strings.Contains(err.Error(), "CRLF diagnostic") || strings.Contains(err.Error(), "not-authoritative") { + t.Fatalf("failed command did not prefer bounded stderr: output=%q err=%v", output, err) + } +} + +func TestCommandOutputHelperProcess(t *testing.T) { + mode := os.Getenv("BOATSTACK_COMMAND_HELPER") + if mode == "" { + return + } + fmt.Fprintln(os.Stdout, ".cursor/commands/boatstack-update.md") + if mode == "failure" { + fmt.Fprintln(os.Stdout, "not-authoritative") + fmt.Fprintln(os.Stderr, "CRLF diagnostic") + os.Exit(7) + } + fmt.Fprintln(os.Stderr, "warning: fake/path will be replaced by CRLF") + os.Exit(0) +} + +func TestProductionControllersDoNotCollapseSubprocessChannels(t *testing.T) { + err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + value, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return err + } + if strings.Contains(string(value), ".CombinedOutput()") { + return fmt.Errorf("%s collapses authority-bearing stdout and diagnostic stderr", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/boatstack/delivery.go b/boatstack/delivery.go index dbd5ff8..95e3d51 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -431,6 +431,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } published := state.ActiveIndex >= len(state.Slices) if !published { + if state.RepairAttempt >= 3 { + return ChangeObservation{}, DeliveryState{}, fmt.Errorf("persistent repair budget exhausted after %d attempts; preserve current state and require a reviewed recovery decision", state.RepairAttempt) + } state.RepairAttempt++ } id := fmt.Sprintf("CHG-%03d", state.RepairAttempt) @@ -840,6 +843,10 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { state.ActiveIndex++ if state.ActiveIndex < len(state.Slices) { state.Slices[state.ActiveIndex].Status = "BUILD" + state.RepairAttempt = 0 + state.ActiveObservationID = "" + state.ResumeStage = "" + state.Mode = "NORMAL" } return saveDeliveryState(repo, state) } diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index 5c37909..12deb3e 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -270,6 +270,28 @@ func TestRepairObservationPersistsAndSupersedesAffectedGates(t *testing.T) { } } +func TestRepairBudgetPersistsAcrossInvocations(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + state.RepairAttempt = 3 + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if _, _, err := RecordChangeObservation(ChangeObservationOptions{ + Repo: repo, Feature: feature, Message: "another asynchronous failure arrived", + SourceStage: "ci", Classification: "verification_repair", + }); err == nil || !strings.Contains(err.Error(), "persistent repair budget exhausted") { + t.Fatalf("new invocation reset the durable repair budget: %v", err) + } + current, err := LoadDeliveryState(repo, feature) + if err != nil || current.RepairAttempt != 3 { + t.Fatalf("exhausted repair state changed: %+v %v", current, err) + } +} + func TestRequirementAmendmentBlocksGates(t *testing.T) { repo, feature := activateTwoSliceDelivery(t) _, state, err := RecordChangeObservation(ChangeObservationOptions{ diff --git a/boatstack/export.go b/boatstack/export.go index 0e8e348..9836c2d 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -266,7 +266,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte operations := map[string]string{ "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.", + "boatstack-run": "First run the read-only next-status --repo . --json and operation-status --repo . --json. If an operation is executing, wait and report it instead of launching it again; if reconciliation is required, verify its exact postcondition before retrying. If 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. The delivery state's durable repair_attempt is the budget; stop after three complete automated repair-and-gate cycles even across new turns, host restarts, or async notifications. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence. Durable operation receipts store execution facts and retry budgets, never autonomous workflow intent. Report the feature, active slice, stages completed, completion or pause reason, durable repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "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 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.", @@ -274,7 +274,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "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.", "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant or unresolved, show the exact fingerprinted local PNGs and public-repository warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Use a signed-in host browser to upload or update that comment when available and record the observed PR and comment URLs with record-pr-visual-publication; otherwise expose the local paths for manual attachment. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and fix forward from visual_pending after attachment failure. Add security/privacy, migration, or operations sections only when relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", - "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", + "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. After installation use the newly installed helper prepare-update-pr --repo . --version --json. It atomically writes a non-empty machine-local preview and fingerprints the exact diff. Show its version transition, release notes and link, integration state, title, body, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes publish-update-pr --repo . --preview --preview-fingerprint . The deterministic publisher stages only the previewed paths, reuses an existing update commit, pushes normally, reconciles the exact branch and PR after an interrupted response, and opens at most one reviewer-ready PR. Never stage, commit, push, or open the update PR through free-form terminal calls. If GitHub auth is unavailable, preserve the branch and give one manual publication action. If operation-status reports EXECUTING, wait; if it reports RECONCILE_REQUIRED, reconcile instead of repeating publication. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", diff --git a/boatstack/export_test.go b/boatstack/export_test.go index c74a492..4501baf 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -295,7 +295,7 @@ func TestExportAndDriftCheck(t *testing.T) { } } update := string(bundle.Files[".cursor/commands/boatstack-update.md"]) - for _, expected := range []string{"check-update", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "Reply `o` to open update PR.", "full reply open update PR for compatibility", "Never merge"} { + for _, expected := range []string{"check-update", "prepare-update-pr", "publish-update-pr", "operation-status", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "Reply `o` to open update PR.", "full reply open update PR for compatibility", "Never stage, commit, push, or open the update PR through free-form terminal calls", "Never merge"} { if !strings.Contains(update, expected) { t.Fatalf("update adapter is missing %q", expected) } diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 754c23b..c8dc38c 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -68,7 +68,11 @@ func runInstalledHookDiagnostic(ctx context.Context, repo, host string, input [] } command.Dir = repo command.Stdin = bytes.NewReader(append(input, '\n')) - return command.CombinedOutput() + channels, err := runCommandChannels(command) + if err != nil { + return channels.Stdout, commandFailure(channels, err) + } + return channels.Stdout, nil } // DiagnoseHook runs the installed guard with a canonical, read-only event. It @@ -92,7 +96,8 @@ func DiagnoseHook(repoPath, hostName string) (HookDiagnostic, error) { return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic timed out", host) } if runErr != nil { - return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic failed: %s", host, strings.TrimSpace(string(output))) + detail := boundedObservation(strings.TrimSpace(string(output) + " " + runErr.Error())) + return HookDiagnostic{}, fmt.Errorf("%s hook diagnostic failed: %s", host, detail) } if err := validateCanonicalHookOutput(host, output); err != nil { return HookDiagnostic{}, err @@ -231,7 +236,7 @@ func desiredHostHookForEvent(host, event string) map[string]any { "command": hookCommand(host), "commandWindows": hookCommandWindows(host), "failClosed": true, "timeout": 10, } - if event == "preToolUse" { + if event == "preToolUse" || event == "postToolUse" { entry["matcher"] = "Write|Edit|ApplyPatch|Create|Delete|Move|Rename" } return entry @@ -266,12 +271,15 @@ func desiredHostHookForEvent(host, event string) map[string]any { func hookEvents(host string) []string { if host == "cursor" { - return []string{"preToolUse", "beforeShellExecution", "beforeMCPExecution"} + return []string{"preToolUse", "postToolUse", "postToolUseFailure", "beforeShellExecution", "afterShellExecution", "beforeMCPExecution", "afterMCPExecution"} } if host == "gemini" { - return []string{"BeforeTool"} + return []string{"BeforeTool", "AfterTool"} + } + if host == "claude" { + return []string{"PreToolUse", "PostToolUse", "PostToolUseFailure"} } - return []string{"PreToolUse"} + return []string{"PreToolUse", "PostToolUse"} } func desiredHostHook(host string) map[string]any { diff --git a/boatstack/hooks_test.go b/boatstack/hooks_test.go index 6814cab..42fce01 100644 --- a/boatstack/hooks_test.go +++ b/boatstack/hooks_test.go @@ -107,7 +107,7 @@ func TestHostHookValidationRejectsUnsupportedBoatstackFields(t *testing.T) { } func TestHostHookValidationRejectsWrongEventAndCursorVersion(t *testing.T) { - codex := map[string]any{"hooks": map[string]any{"PostToolUse": []any{desiredHostHook("codex")}}} + codex := map[string]any{"hooks": map[string]any{"SessionEnd": []any{desiredHostHook("codex")}}} if err := validateHostHookConfig("codex", codex); err == nil || !strings.Contains(err.Error(), "unsupported event") { t.Fatalf("expected wrong Codex event failure, got %v", err) } diff --git a/boatstack/init.go b/boatstack/init.go index 92ae256..5ade1e4 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -587,11 +587,11 @@ 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, 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.") + fmt.Fprintln(options.Output, " Cursor: reload the window and confirm beforeShellExecution and beforeMCPExecution are paired with their after events, plus synchronous pre/post native-tool hooks; the hooks are defense in depth.") + fmt.Fprintln(options.Output, " Claude Code: reload, then use /hooks to confirm Boatstack PreToolUse, PostToolUse, and PostToolUseFailure hooks are active (Bash is required).") + fmt.Fprintln(options.Output, " Codex: trust this exact linked-worktree path, use /hooks to review and trust the Boatstack PreToolUse and PostToolUse hooks, 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, " Gemini CLI: reload and confirm the Boatstack BeforeTool and AfterTool hooks are active.") } fmt.Fprintln(options.Output, "Boatstack start command by host:") fmt.Fprintln(options.Output, " Claude Code: /auto-plan") @@ -660,6 +660,37 @@ func InstallExecutionInterceptors(repo string, adapters []string) error { } func RunUpdate(options InitOptions) error { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return err + } + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + packageFingerprint := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256)) + receipt, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: "install-update", Scope: OperationScope{Worktree: filepath.Base(repo), HeadBranch: branch}, + Target: "boatstack-install:" + Version, PackageFingerprint: packageFingerprint, + AuthorizationFingerprint: SHA256Bytes([]byte("update-request\x00" + branch + "\x00" + packageFingerprint)), + RetryClass: "ATOMIC_LOCAL", MaxAttempts: 2, + ExpectedPostcondition: "the generated runtime, adapters, hooks, and preserved integration state match the pinned release", + }) + if err != nil { + return err + } + begin, err := BeginOperation(repo, receipt.OperationID, SHA256Bytes([]byte("install-update\x00"+packageFingerprint)), "boatstack-helper update") + if err != nil { + if begin.Receipt.State == OperationSucceeded { + return nil + } + return err + } + if begin.Receipt.State == OperationSucceeded { + return nil + } options.Update = true - return RunInit(options) + if err := RunInit(options); err != nil { + _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "RETRYABLE", "the atomic update transaction rolled back", "") + return err + } + _, err = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "post-install doctor and generated projections passed", Version) + return err } diff --git a/boatstack/operation.go b/boatstack/operation.go new file mode 100644 index 0000000..f363760 --- /dev/null +++ b/boatstack/operation.go @@ -0,0 +1,667 @@ +package boatstack + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +const ( + operationSchemaVersion = 1 + operationLeaseDuration = 15 * time.Minute + operationRetention = 7 * 24 * time.Hour +) + +type OperationState string + +const ( + OperationPrepared OperationState = "PREPARED" + OperationAuthorized OperationState = "AUTHORIZED" + OperationExecuting OperationState = "EXECUTING" + OperationReconcileRequired OperationState = "RECONCILE_REQUIRED" + OperationRetryable OperationState = "RETRYABLE" + OperationSucceeded OperationState = "SUCCEEDED" + OperationFailedFinal OperationState = "FAILED_FINAL" +) + +type OperationScope struct { + Feature string `json:"feature,omitempty"` + Slice string `json:"slice,omitempty"` + Worktree string `json:"worktree,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` +} + +type OperationLease struct { + TokenSHA256 string `json:"token_sha256"` + AttemptKey string `json:"attempt_key"` + Tool string `json:"tool,omitempty"` + Target string `json:"target"` + ExpiresAt string `json:"expires_at"` +} + +type OperationObservation struct { + Status string `json:"status,omitempty"` + Detail string `json:"detail,omitempty"` + Evidence string `json:"evidence,omitempty"` + At string `json:"at,omitempty"` +} + +// OperationReceipt is deliberately secret-free. It stores fingerprints and +// bounded observations, never complete commands, tool arguments, or responses. +type OperationReceipt struct { + SchemaVersion int `json:"schema_version"` + OperationID string `json:"operation_id"` + Kind string `json:"kind"` + Scope OperationScope `json:"scope"` + Target string `json:"target"` + PackageFingerprint string `json:"package_fingerprint"` + AuthorizationFingerprint string `json:"authorization_fingerprint,omitempty"` + State OperationState `json:"state"` + RetryClass string `json:"retry_class"` + Attempt int `json:"attempt"` + MaxAttempts int `json:"max_attempts"` + ExpectedPostcondition string `json:"expected_postcondition"` + Lease *OperationLease `json:"lease,omitempty"` + Observation OperationObservation `json:"observation,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type OperationPrepareOptions struct { + Repo string + Kind string + Scope OperationScope + Target string + PackageFingerprint string + AuthorizationFingerprint string + RetryClass string + MaxAttempts int + ExpectedPostcondition string +} + +type OperationBeginResult struct { + Receipt OperationReceipt + LeaseToken string +} + +type OperationStatusResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + Operation *OperationReceipt `json:"operation,omitempty"` + Blocker string `json:"blocker,omitempty"` + ReconciliationRequired bool `json:"reconciliation_required"` + NextOperation string `json:"next_operation"` +} + +var operationNow = time.Now + +var ErrOperationInFlight = errors.New("identical operation is already executing") + +func operationTimestamp() string { + return operationNow().UTC().Truncate(time.Second).Format(time.RFC3339) +} + +func operationDirectory(repo string) (string, error) { + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + return filepath.Join(common, "boatstack", "operations", "v1"), nil +} + +func operationPath(repo, operationID string) (string, error) { + id, err := safeCacheSegment(operationID, "operation id") + if err != nil { + return "", err + } + directory, err := operationDirectory(repo) + if err != nil { + return "", err + } + path := filepath.Join(directory, id+".json") + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + if err := rejectSymlinkComponents(common, path); err != nil { + return "", err + } + return path, nil +} + +func operationID(kind, target, fingerprint string) string { + return SHA256Bytes([]byte(strings.TrimSpace(kind) + "\x00" + strings.TrimSpace(target) + "\x00" + strings.TrimSpace(fingerprint)))[:24] +} + +func validOperationState(state OperationState) bool { + switch state { + case OperationPrepared, OperationAuthorized, OperationExecuting, OperationReconcileRequired, OperationRetryable, OperationSucceeded, OperationFailedFinal: + return true + default: + return false + } +} + +func validRetryClass(value string) bool { + switch value { + case "READ_ONLY", "ATOMIC_LOCAL", "IDEMPOTENT_EXTERNAL", "RECONCILE_FIRST": + return true + default: + return false + } +} + +func validateOperation(receipt OperationReceipt) error { + if receipt.SchemaVersion != operationSchemaVersion || receipt.OperationID == "" || receipt.Kind == "" || receipt.Target == "" || receipt.PackageFingerprint == "" { + return fmt.Errorf("operation receipt identity is invalid") + } + if !validOperationState(receipt.State) || !validRetryClass(receipt.RetryClass) || receipt.MaxAttempts < 1 || receipt.Attempt < 0 || receipt.Attempt > receipt.MaxAttempts { + return fmt.Errorf("operation receipt state is invalid") + } + if receipt.State == OperationExecuting && receipt.Lease == nil { + return fmt.Errorf("executing operation has no lease") + } + return nil +} + +func loadOperation(repo, id string) (OperationReceipt, error) { + path, err := operationPath(repo, id) + if err != nil { + return OperationReceipt{}, err + } + value, err := os.ReadFile(path) + if err != nil { + return OperationReceipt{}, err + } + var receipt OperationReceipt + if err := DecodeJSON("load operation receipt", path, value, &receipt); err != nil { + return OperationReceipt{}, err + } + if err := validateOperation(receipt); err != nil { + return OperationReceipt{}, err + } + return receipt, nil +} + +func saveOperation(repo string, receipt OperationReceipt) error { + if err := validateOperation(receipt); err != nil { + return err + } + path, err := operationPath(repo, receipt.OperationID) + if err != nil { + return err + } + value, err := MarshalJSON(receipt) + if err != nil { + return err + } + return atomicWriteMode(path, value, 0o600) +} + +func withOperationLock(repo, id string, apply func() error) error { + path, err := operationPath(repo, id) + if err != nil { + return err + } + lock := strings.TrimSuffix(path, ".json") + ".lock" + common, err := gitCommonDir(repo) + if err != nil { + return err + } + if err := rejectSymlinkComponents(common, lock); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(lock), 0o700); err != nil { + return err + } + for attempt := 0; attempt < 100; attempt++ { + file, openErr := os.OpenFile(lock, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if openErr == nil { + _, _ = fmt.Fprintf(file, "%d %s\n", os.Getpid(), operationTimestamp()) + _ = file.Close() + defer os.Remove(lock) + return apply() + } + if !os.IsExist(openErr) { + return openErr + } + if info, statErr := os.Stat(lock); statErr == nil && operationNow().Sub(info.ModTime()) > time.Minute { + _ = os.Remove(lock) + continue + } + time.Sleep(10 * time.Millisecond) + } + return fmt.Errorf("operation %s is busy", id) +} + +func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return OperationReceipt{}, err + } + // Retention is best-effort and never prevents a new supervised operation. + _ = compactOperations(repo) + kind := strings.TrimSpace(options.Kind) + target := strings.TrimSpace(options.Target) + fingerprint := strings.TrimSpace(options.PackageFingerprint) + if kind == "" || target == "" || fingerprint == "" || strings.TrimSpace(options.ExpectedPostcondition) == "" { + return OperationReceipt{}, fmt.Errorf("operation requires kind, target, package fingerprint, and expected postcondition") + } + retryClass := strings.ToUpper(strings.TrimSpace(options.RetryClass)) + if !validRetryClass(retryClass) { + return OperationReceipt{}, fmt.Errorf("unsupported operation retry class %q", options.RetryClass) + } + maximum := options.MaxAttempts + if maximum == 0 { + maximum = 3 + } + if maximum < 1 || maximum > 10 { + return OperationReceipt{}, fmt.Errorf("operation max attempts must be between 1 and 10") + } + id := operationID(kind, target, fingerprint) + var result OperationReceipt + err = withOperationLock(repo, id, func() error { + existing, loadErr := loadOperation(repo, id) + if loadErr == nil { + if existing.Kind != kind || existing.Target != target || existing.PackageFingerprint != fingerprint || + existing.RetryClass != retryClass || existing.MaxAttempts != maximum || + existing.ExpectedPostcondition != strings.TrimSpace(options.ExpectedPostcondition) || existing.Scope != options.Scope { + return fmt.Errorf("existing operation identity does not match the prepared package") + } + authorization := strings.TrimSpace(options.AuthorizationFingerprint) + if authorization != "" && existing.AuthorizationFingerprint != "" && existing.AuthorizationFingerprint != authorization { + return fmt.Errorf("operation authorization fingerprint changed after preparation") + } + if authorization != "" && existing.State == OperationPrepared { + existing.AuthorizationFingerprint = authorization + existing.State = OperationAuthorized + existing.UpdatedAt = operationTimestamp() + if err := saveOperation(repo, existing); err != nil { + return err + } + } + result = existing + return nil + } + if !os.IsNotExist(loadErr) { + return loadErr + } + now := operationTimestamp() + state := OperationPrepared + if strings.TrimSpace(options.AuthorizationFingerprint) != "" { + state = OperationAuthorized + } + result = OperationReceipt{ + SchemaVersion: operationSchemaVersion, OperationID: id, Kind: kind, Scope: options.Scope, + Target: target, PackageFingerprint: fingerprint, AuthorizationFingerprint: strings.TrimSpace(options.AuthorizationFingerprint), + State: state, RetryClass: retryClass, MaxAttempts: maximum, + ExpectedPostcondition: strings.TrimSpace(options.ExpectedPostcondition), CreatedAt: now, UpdatedAt: now, + } + return saveOperation(repo, result) + }) + return result, err +} + +func AuthorizeOperation(repoPath, id, packageFingerprint, authorizationFingerprint string) (OperationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return OperationReceipt{}, err + } + var result OperationReceipt + err = withOperationLock(repo, id, func() error { + receipt, loadErr := loadOperation(repo, id) + if loadErr != nil { + return loadErr + } + if receipt.PackageFingerprint != strings.TrimSpace(packageFingerprint) || strings.TrimSpace(authorizationFingerprint) == "" { + return fmt.Errorf("operation authorization does not match the prepared package") + } + if receipt.AuthorizationFingerprint != "" && receipt.AuthorizationFingerprint != strings.TrimSpace(authorizationFingerprint) { + return fmt.Errorf("operation authorization fingerprint changed after preparation") + } + if receipt.State != OperationPrepared && receipt.State != OperationAuthorized { + return fmt.Errorf("operation %s cannot be authorized from %s", id, receipt.State) + } + receipt.AuthorizationFingerprint = strings.TrimSpace(authorizationFingerprint) + receipt.State = OperationAuthorized + receipt.UpdatedAt = operationTimestamp() + result = receipt + return saveOperation(repo, receipt) + }) + return result, err +} + +func randomLeaseToken() (string, error) { + value := make([]byte, 32) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +func BeginOperation(repoPath, id, attemptKey, tool string) (OperationBeginResult, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return OperationBeginResult{}, err + } + attemptKey = strings.TrimSpace(attemptKey) + if attemptKey == "" { + return OperationBeginResult{}, fmt.Errorf("operation attempt key is required") + } + var result OperationBeginResult + err = withOperationLock(repo, id, func() error { + receipt, loadErr := loadOperation(repo, id) + if loadErr != nil { + return loadErr + } + if receipt.State == OperationSucceeded { + result.Receipt = receipt + return nil + } + if receipt.State == OperationExecuting { + expires, parseErr := time.Parse(time.RFC3339, receipt.Lease.ExpiresAt) + if parseErr != nil || !operationNow().Before(expires) { + receipt.State = OperationReconcileRequired + receipt.Lease = nil + receipt.Observation = OperationObservation{Status: "UNKNOWN", Detail: "execution lease expired before completion was observed", At: operationTimestamp()} + receipt.UpdatedAt = operationTimestamp() + if err := saveOperation(repo, receipt); err != nil { + return err + } + result.Receipt = receipt + return fmt.Errorf("operation completion is unknown; reconcile before retry") + } + result.Receipt = receipt + return ErrOperationInFlight + } + if receipt.State != OperationAuthorized && receipt.State != OperationRetryable { + return fmt.Errorf("operation %s cannot begin from %s", id, receipt.State) + } + if receipt.AuthorizationFingerprint == "" { + return fmt.Errorf("operation %s has no fingerprinted authorization", id) + } + if receipt.Attempt >= receipt.MaxAttempts { + receipt.State = OperationFailedFinal + receipt.Observation = OperationObservation{Status: "FAILED", Detail: "persistent retry budget exhausted", At: operationTimestamp()} + receipt.UpdatedAt = operationTimestamp() + _ = saveOperation(repo, receipt) + result.Receipt = receipt + return fmt.Errorf("operation retry budget is exhausted") + } + token, tokenErr := randomLeaseToken() + if tokenErr != nil { + return tokenErr + } + receipt.Attempt++ + receipt.State = OperationExecuting + receipt.Lease = &OperationLease{ + TokenSHA256: SHA256Bytes([]byte(token)), AttemptKey: attemptKey, Tool: strings.TrimSpace(tool), Target: receipt.Target, + ExpiresAt: operationNow().UTC().Add(operationLeaseDuration).Truncate(time.Second).Format(time.RFC3339), + } + receipt.Observation = OperationObservation{} + receipt.UpdatedAt = operationTimestamp() + if err := saveOperation(repo, receipt); err != nil { + return err + } + result = OperationBeginResult{Receipt: receipt, LeaseToken: token} + return nil + }) + return result, err +} + +func completeOperation(repoPath, id, leaseToken, attemptKey, outcome, detail, evidence string, trustedAttempt bool) (OperationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return OperationReceipt{}, err + } + var result OperationReceipt + err = withOperationLock(repo, id, func() error { + receipt, loadErr := loadOperation(repo, id) + if loadErr != nil { + return loadErr + } + if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal { + result = receipt + return nil + } + if receipt.State != OperationExecuting || receipt.Lease == nil { + return fmt.Errorf("operation %s has no executing attempt to complete", id) + } + if trustedAttempt { + if strings.TrimSpace(attemptKey) == "" || receipt.Lease.AttemptKey != strings.TrimSpace(attemptKey) { + return fmt.Errorf("operation completion does not match the active attempt") + } + } else if SHA256Bytes([]byte(strings.TrimSpace(leaseToken))) != receipt.Lease.TokenSHA256 { + return fmt.Errorf("operation lease is invalid or replayed") + } + outcome = strings.ToUpper(strings.TrimSpace(outcome)) + switch outcome { + case "SUCCEEDED": + receipt.State = OperationSucceeded + case "RETRYABLE": + if receipt.RetryClass == "RECONCILE_FIRST" { + return fmt.Errorf("reconcile-first operation cannot be marked retryable without reconciliation") + } + if receipt.Attempt >= receipt.MaxAttempts { + receipt.State = OperationFailedFinal + } else { + receipt.State = OperationRetryable + } + case "UNKNOWN": + receipt.State = OperationReconcileRequired + case "FAILED_FINAL": + receipt.State = OperationFailedFinal + default: + return fmt.Errorf("unsupported operation outcome %q", outcome) + } + receipt.Lease = nil + receipt.Observation = OperationObservation{Status: outcome, Detail: boundedObservation(detail), Evidence: boundedObservation(evidence), At: operationTimestamp()} + receipt.UpdatedAt = operationTimestamp() + result = receipt + return saveOperation(repo, receipt) + }) + return result, err +} + +func boundedObservation(value string) string { + value = strings.TrimSpace(value) + secretAssignment := regexp.MustCompile(`(?i)\b(token|password|secret|authorization|api[_-]?key)\s*[:=]\s*[^\s,;]+`) + value = secretAssignment.ReplaceAllString(value, "$1=") + bearer := regexp.MustCompile(`(?i)\bbearer\s+[^\s,;]+`) + value = bearer.ReplaceAllString(value, "Bearer ") + if len(value) > 512 { + return value[:512] + } + return value +} + +func CompleteOperation(repo, id, leaseToken, outcome, detail, evidence string) (OperationReceipt, error) { + return completeOperation(repo, id, leaseToken, "", outcome, detail, evidence, false) +} + +func CompleteOperationAttempt(repo, id, attemptKey, outcome, detail, evidence string) (OperationReceipt, error) { + return completeOperation(repo, id, "", attemptKey, outcome, detail, evidence, true) +} + +func RecordOperationReconciliation(repoPath, id, result, detail, evidence string) (OperationReceipt, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return OperationReceipt{}, err + } + var output OperationReceipt + err = withOperationLock(repo, id, func() error { + receipt, loadErr := loadOperation(repo, id) + if loadErr != nil { + return loadErr + } + if receipt.State != OperationReconcileRequired { + return fmt.Errorf("operation %s does not require reconciliation", id) + } + switch strings.ToUpper(strings.TrimSpace(result)) { + case "OBSERVED_SUCCEEDED": + receipt.State = OperationSucceeded + case "OBSERVED_ABSENT", "OBSERVED_PARTIAL": + if receipt.Attempt >= receipt.MaxAttempts { + receipt.State = OperationFailedFinal + } else { + receipt.State = OperationRetryable + } + case "STILL_UNKNOWN": + receipt.State = OperationReconcileRequired + default: + return fmt.Errorf("unsupported reconciliation result %q", result) + } + receipt.Observation = OperationObservation{Status: strings.ToUpper(strings.TrimSpace(result)), Detail: boundedObservation(detail), Evidence: boundedObservation(evidence), At: operationTimestamp()} + receipt.UpdatedAt = operationTimestamp() + output = receipt + return saveOperation(repo, receipt) + }) + return output, err +} + +func operationReceipts(repo string) ([]OperationReceipt, error) { + directory, err := operationDirectory(repo) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + values := []OperationReceipt{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + receipt, loadErr := loadOperation(repo, strings.TrimSuffix(entry.Name(), ".json")) + if loadErr != nil { + return nil, loadErr + } + values = append(values, receipt) + } + sort.Slice(values, func(i, j int) bool { return values[i].OperationID < values[j].OperationID }) + return values, nil +} + +func refreshExpiredOperation(repo, id string) (OperationReceipt, error) { + var result OperationReceipt + err := withOperationLock(repo, id, func() error { + receipt, err := loadOperation(repo, id) + if err != nil { + return err + } + if receipt.State == OperationExecuting && receipt.Lease != nil { + expires, parseErr := time.Parse(time.RFC3339, receipt.Lease.ExpiresAt) + if parseErr != nil || !operationNow().Before(expires) { + receipt.State = OperationReconcileRequired + receipt.Lease = nil + receipt.Observation = OperationObservation{Status: "UNKNOWN", Detail: "execution lease expired before completion was observed", At: operationTimestamp()} + receipt.UpdatedAt = operationTimestamp() + if err := saveOperation(repo, receipt); err != nil { + return err + } + } + } + result = receipt + return nil + }) + return result, err +} + +func ResolveOperationStatus(repoPath, id string) (OperationStatusResult, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return OperationStatusResult{}, err + } + if strings.TrimSpace(id) != "" { + receipt, loadErr := refreshExpiredOperation(repo, strings.TrimSpace(id)) + if loadErr != nil { + return OperationStatusResult{}, loadErr + } + return operationStatusFor(receipt), nil + } + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + receipts, err := operationReceipts(repo) + if err != nil { + return OperationStatusResult{}, err + } + active := []OperationReceipt{} + for _, receipt := range receipts { + if receipt.State == OperationExecuting { + if refreshed, refreshErr := refreshExpiredOperation(repo, receipt.OperationID); refreshErr == nil { + receipt = refreshed + } + } + if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal { + continue + } + if receipt.Scope.HeadBranch == "" || receipt.Scope.HeadBranch == branch { + active = append(active, receipt) + } + } + if len(active) == 0 { + return OperationStatusResult{SchemaVersion: operationSchemaVersion, VerificationStatus: "VERIFIED", NextOperation: "none"}, nil + } + if len(active) > 1 { + return OperationStatusResult{SchemaVersion: operationSchemaVersion, VerificationStatus: "AMBIGUOUS", Blocker: "more than one unfinished operation matches the current branch", NextOperation: "specify_operation"}, nil + } + return operationStatusFor(active[0]), nil +} + +func operationStatusFor(receipt OperationReceipt) OperationStatusResult { + next := "none" + blocker := "" + switch receipt.State { + case OperationPrepared: + next = "authorize" + case OperationAuthorized, OperationRetryable: + next = "execute" + case OperationExecuting: + next = "wait" + blocker = "an authorized attempt is already executing" + case OperationReconcileRequired: + next = "reconcile" + blocker = "completion was not observed" + case OperationFailedFinal: + next = "manual_recovery" + blocker = receipt.Observation.Detail + } + copy := receipt + return OperationStatusResult{ + SchemaVersion: operationSchemaVersion, VerificationStatus: "VERIFIED", Operation: ©, + Blocker: blocker, ReconciliationRequired: receipt.State == OperationReconcileRequired, NextOperation: next, + } +} + +func compactOperations(repo string) error { + receipts, err := operationReceipts(repo) + if err != nil { + return err + } + cutoff := operationNow().Add(-operationRetention) + for _, receipt := range receipts { + if receipt.State != OperationSucceeded && receipt.State != OperationFailedFinal { + continue + } + updated, parseErr := time.Parse(time.RFC3339, receipt.UpdatedAt) + if parseErr != nil || updated.After(cutoff) || receipt.Observation.Detail == "" { + continue + } + receipt.Observation.Detail = "terminal receipt compacted" + receipt.Observation.Evidence = "" + if err := saveOperation(repo, receipt); err != nil { + return err + } + } + return nil +} diff --git a/boatstack/operation_test.go b/boatstack/operation_test.go new file mode 100644 index 0000000..0ed3401 --- /dev/null +++ b/boatstack/operation_test.go @@ -0,0 +1,313 @@ +package boatstack + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func operationTestRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + return repo +} + +func activeOperationTestRepo(t *testing.T) string { + t.Helper() + repo := safetyTestRepo(t) + feature := "durable-tool" + directory := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + lockPath := filepath.Join(directory, "plan.lock.json") + if err := os.WriteFile(lockPath, []byte("lock\n"), 0o644); err != nil { + t.Fatal(err) + } + lockHash, err := SHA256File(lockPath) + if err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "delivery", Title: "Delivery", Status: "BUILD", BaseBranch: "main", HeadBranch: "main"}}, + }); err != nil { + t.Fatal(err) + } + return repo +} + +func TestHostHooksCreateAndCompleteOneDurableAttempt(t *testing.T) { + fixtures := map[string]struct{ pre, post string }{ + "cursor": { + pre: `{"hook_event_name":"preToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`, + post: `{"hook_event_name":"postToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_result":"ok"}`, + }, + "claude": { + pre: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`, + post: `{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`, + }, + "codex": { + pre: `{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`, + post: `{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`, + }, + "gemini": { + pre: `{"hook_event_name":"BeforeTool","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"}}`, + post: `{"hook_event_name":"AfterTool","tool_name":"Write","tool_input":{"file_path":"feature.go","content":"package feature"},"tool_response":"ok"}`, + }, + } + for host, fixture := range fixtures { + t.Run(host, func(t *testing.T) { + repo := activeOperationTestRepo(t) + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)}); denied { + t.Fatalf("first supervised tool call was denied: %s", output) + } + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.post)}); denied { + t.Fatalf("completion event was denied: %s", output) + } + status, err := ResolveOperationStatus(repo, "") + if err != nil || status.Operation != nil { + t.Fatalf("completed operation remained active: %+v %v", status, err) + } + output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)}) + if !denied || !strings.Contains(string(output), "already") { + t.Fatalf("late duplicate was not suppressed: %s", output) + } + }) + } +} + +func TestAsyncCompletionCannotInitiateAnOperation(t *testing.T) { + repo := activeOperationTestRepo(t) + post := []byte(`{"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"never-started.go","content":"x"},"tool_response":"ok"}`) + if output, denied := HookDecision(SafetyHookOptions{Host: "codex", Repo: repo, Input: post}); denied { + t.Fatalf("unmatched completion should be consumed without authority: %s", output) + } + status, err := ResolveOperationStatus(repo, "") + if err != nil || status.Operation != nil { + t.Fatalf("async completion created workflow authority: %+v %v", status, err) + } +} + +func TestHostFailureEventsRequireReconciliation(t *testing.T) { + fixtures := map[string]struct{ pre, post string }{ + "cursor": { + pre: `{"hook_event_name":"preToolUse","tool_call_id":"c-1","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`, + post: `{"hook_event_name":"postToolUseFailure","tool_call_id":"c-1","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"error":"write interrupted"}`, + }, + "claude": { + pre: `{"hook_event_name":"PreToolUse","tool_use_id":"c-2","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`, + post: `{"hook_event_name":"PostToolUseFailure","tool_use_id":"c-2","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"error":"write interrupted"}`, + }, + "codex": { + pre: `{"hook_event_name":"PreToolUse","tool_call_id":"c-3","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`, + post: `{"hook_event_name":"PostToolUse","tool_call_id":"c-3","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"tool_response":{"error":"write interrupted"}}`, + }, + "gemini": { + pre: `{"hook_event_name":"BeforeTool","call_id":"c-4","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"}}`, + post: `{"hook_event_name":"AfterTool","call_id":"c-4","tool_name":"Write","tool_input":{"file_path":"failed.go","content":"x"},"tool_response":{"error":"write interrupted"}}`, + }, + } + for host, fixture := range fixtures { + t.Run(host, func(t *testing.T) { + repo := activeOperationTestRepo(t) + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.pre)}); denied { + t.Fatalf("pre event denied: %s", output) + } + if output, denied := HookDecision(SafetyHookOptions{Host: host, Repo: repo, Input: []byte(fixture.post)}); denied { + t.Fatalf("post-failure observation denied: %s", output) + } + status, err := ResolveOperationStatus(repo, "") + if err != nil || status.Operation == nil || status.Operation.State != OperationReconcileRequired || !status.ReconciliationRequired { + t.Fatalf("failure was not preserved as unknown: %+v %v", status, err) + } + }) + } +} + +func TestSafetyFindingOperationFieldsRemainSecretFree(t *testing.T) { + finding := SafetyFinding{Category: "operation-in-flight", OperationID: "abc", OperationState: "EXECUTING", AttemptNumber: 2, ReconciliationRequired: false} + value, err := MarshalJSON(finding) + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{`"operation_id": "abc"`, `"operation_state": "EXECUTING"`, `"attempt_number": 2`} { + if !strings.Contains(string(value), expected) { + t.Fatalf("missing safety field %s: %s", expected, value) + } + } + if strings.Contains(string(value), "token") || strings.Contains(string(value), "command") { + t.Fatalf("safety finding leaked execution detail: %s", value) + } +} + +func TestOperationObservationsRedactObviousSecrets(t *testing.T) { + value := boundedObservation("request failed authorization=BearerValue token:abc123 password=hunter2 Bearer xyz") + for _, secret := range []string{"BearerValue", "abc123", "hunter2", " xyz"} { + if strings.Contains(value, secret) { + t.Fatalf("operation observation retained %q: %s", secret, value) + } + } +} + +func preparedOperation(t *testing.T, repo, fingerprint, retryClass string, attempts int) OperationReceipt { + t.Helper() + receipt, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: fingerprint, + AuthorizationFingerprint: "approved-" + fingerprint, RetryClass: retryClass, MaxAttempts: attempts, + ExpectedPostcondition: "artifact hash equals " + fingerprint, + }) + if err != nil { + t.Fatal(err) + } + return receipt +} + +func TestOperationLifecycleAndReplayProtection(t *testing.T) { + repo := operationTestRepo(t) + receipt := preparedOperation(t, repo, "package-a", "ATOMIC_LOCAL", 2) + if receipt.State != OperationAuthorized || receipt.Attempt != 0 { + t.Fatalf("unexpected prepared receipt: %+v", receipt) + } + begin, err := BeginOperation(repo, receipt.OperationID, "host-call-1", "Write") + if err != nil || begin.Receipt.State != OperationExecuting || begin.Receipt.Attempt != 1 || begin.LeaseToken == "" { + t.Fatalf("unexpected begin: %+v %v", begin, err) + } + if _, err := BeginOperation(repo, receipt.OperationID, "host-call-1", "Write"); !errors.Is(err, ErrOperationInFlight) { + t.Fatalf("identical active operation relaunched: %v", err) + } + if _, err := CompleteOperation(repo, receipt.OperationID, "wrong", "SUCCEEDED", "", ""); err == nil { + t.Fatal("invalid lease completed the operation") + } + completed, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "postcondition observed", "sha256:package-a") + if err != nil || completed.State != OperationSucceeded || completed.Lease != nil { + t.Fatalf("unexpected completion: %+v %v", completed, err) + } + resumed, err := BeginOperation(repo, receipt.OperationID, "late-notification", "Write") + if err != nil || resumed.Receipt.State != OperationSucceeded || resumed.LeaseToken != "" { + t.Fatalf("terminal identity did not suppress a late duplicate: %+v %v", resumed, err) + } +} + +func TestOperationRejectsChangedPackageAndAuthorization(t *testing.T) { + repo := operationTestRepo(t) + receipt := preparedOperation(t, repo, "package-auth", "ATOMIC_LOCAL", 2) + if _, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: "test-write", Target: "artifact.json", PackageFingerprint: "package-auth", + AuthorizationFingerprint: "different-approval", RetryClass: "ATOMIC_LOCAL", MaxAttempts: 2, + ExpectedPostcondition: "artifact hash equals package-auth", + }); err == nil || !strings.Contains(err.Error(), "authorization fingerprint changed") { + t.Fatalf("changed authorization was not rejected: %v", err) + } + if _, err := AuthorizeOperation(repo, receipt.OperationID, "different-package", "approved-package-auth"); err == nil { + t.Fatal("changed package fingerprint was not rejected") + } +} + +func TestUnknownCompletionRequiresReconciliationBeforeRetry(t *testing.T) { + repo := operationTestRepo(t) + receipt := preparedOperation(t, repo, "package-b", "RECONCILE_FIRST", 3) + begin, err := BeginOperation(repo, receipt.OperationID, "call-b", "mcp__github__create_pull_request") + if err != nil { + t.Fatal(err) + } + unknown, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "transport ended before a response", "") + if err != nil || unknown.State != OperationReconcileRequired { + t.Fatalf("unknown completion did not require reconciliation: %+v %v", unknown, err) + } + if _, err := BeginOperation(repo, receipt.OperationID, "call-b-retry", "mcp__github__create_pull_request"); err == nil { + t.Fatal("reconcile-first operation retried blindly") + } + retryable, err := RecordOperationReconciliation(repo, receipt.OperationID, "OBSERVED_ABSENT", "exact PR was not found", "head:abc") + if err != nil || retryable.State != OperationRetryable { + t.Fatalf("absence did not permit bounded retry: %+v %v", retryable, err) + } + second, err := BeginOperation(repo, receipt.OperationID, "call-b-retry", "mcp__github__create_pull_request") + if err != nil || second.Receipt.Attempt != 2 { + t.Fatalf("reconciled operation did not retry: %+v %v", second, err) + } +} + +func TestExpiredLeaseBecomesUnknownAndBudgetPersists(t *testing.T) { + repo := operationTestRepo(t) + now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) + previous := operationNow + operationNow = func() time.Time { return now } + t.Cleanup(func() { operationNow = previous }) + receipt := preparedOperation(t, repo, "package-c", "ATOMIC_LOCAL", 1) + if _, err := BeginOperation(repo, receipt.OperationID, "call-c", "Write"); err != nil { + t.Fatal(err) + } + now = now.Add(operationLeaseDuration + time.Second) + if _, err := BeginOperation(repo, receipt.OperationID, "call-c", "Write"); err == nil { + t.Fatal("expired attempt did not block for reconciliation") + } + status, err := ResolveOperationStatus(repo, receipt.OperationID) + if err != nil || status.Operation == nil || status.Operation.State != OperationReconcileRequired || !status.ReconciliationRequired { + t.Fatalf("unexpected expired status: %+v %v", status, err) + } + terminal, err := RecordOperationReconciliation(repo, receipt.OperationID, "OBSERVED_ABSENT", "destination hash unchanged", "") + if err != nil || terminal.State != OperationFailedFinal { + t.Fatalf("persisted budget was not exhausted: %+v %v", terminal, err) + } +} + +func TestOperationReceiptsAreSharedAcrossLinkedWorktreesAndSerialized(t *testing.T) { + repo := operationTestRepo(t) + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, repo, "worktree", "add", "-b", "linked-test", linked) + receipt := preparedOperation(t, repo, "package-d", "ATOMIC_LOCAL", 2) + status, err := ResolveOperationStatus(linked, receipt.OperationID) + if err != nil || status.Operation == nil || status.Operation.OperationID != receipt.OperationID { + t.Fatalf("linked worktree did not observe shared operation: %+v %v", status, err) + } + + var wait sync.WaitGroup + errorsSeen := make(chan error, 2) + for _, root := range []string{repo, linked} { + wait.Add(1) + go func(path string) { + defer wait.Done() + _, beginErr := BeginOperation(path, receipt.OperationID, "same-attempt", "Write") + errorsSeen <- beginErr + }(root) + } + wait.Wait() + close(errorsSeen) + successes, inFlight := 0, 0 + for beginErr := range errorsSeen { + switch { + case beginErr == nil: + successes++ + case errors.Is(beginErr, ErrOperationInFlight): + inFlight++ + default: + t.Fatalf("unexpected concurrent begin error: %v", beginErr) + } + } + if successes != 1 || inFlight != 1 { + t.Fatalf("operation lock admitted %d executions and %d in-flight reports", successes, inFlight) + } +} + +func TestOperationStatusDoesNotChooseAmbiguousWorkByRecency(t *testing.T) { + repo := operationTestRepo(t) + preparedOperation(t, repo, "one", "ATOMIC_LOCAL", 1) + preparedOperation(t, repo, "two", "ATOMIC_LOCAL", 1) + status, err := ResolveOperationStatus(repo, "") + if err != nil || status.VerificationStatus != "AMBIGUOUS" || status.NextOperation != "specify_operation" { + t.Fatalf("unexpected ambiguity result: %+v %v", status, err) + } +} diff --git a/boatstack/pr.go b/boatstack/pr.go index 1a80f0a..6119b95 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -3,6 +3,7 @@ package boatstack import ( "bytes" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -217,20 +218,6 @@ func publishPRVisualEvidence(repo, prURL string, context PRContext, publisher PR return err } -func commandOutput(repo string, name string, arguments ...string) (string, error) { - command := exec.Command(name, arguments...) - command.Dir = repo - value, err := command.CombinedOutput() - if err != nil { - message := strings.TrimSpace(string(value)) - if message == "" { - message = err.Error() - } - return "", fmt.Errorf("%s", message) - } - return strings.TrimSpace(string(value)), nil -} - func gitCommand(repo string, arguments ...string) (string, error) { return commandOutput(repo, "git", append([]string{"-C", repo}, arguments...)...) } @@ -1036,61 +1023,123 @@ func PublishPR(options PRPublishOptions) (string, error) { if err != nil { return "", err } - if options.Action == "open" && exists { + operationTarget := "github-pr:" + options.Action + ":" + context.HeadBranch + operationFingerprint := preview.Fingerprint + operationID := operationID("publish-pr", operationTarget, operationFingerprint) + prior, priorErr := loadOperation(repo, operationID) + partialResume := priorErr == nil && (prior.State == OperationReconcileRequired || prior.State == OperationRetryable) + if options.Action == "open" && exists && !partialResume { return "", fmt.Errorf("a PR already exists for %s; regenerate the preview for update", context.HeadBranch) } if options.Action == "update" && !exists { return "", fmt.Errorf("no PR exists for %s; regenerate the preview for opening", context.HeadBranch) } + receipt, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: "publish-pr", + Scope: OperationScope{Feature: context.Feature, Slice: context.SliceID, Worktree: filepath.Base(repo), HeadBranch: context.HeadBranch}, + Target: operationTarget, PackageFingerprint: operationFingerprint, AuthorizationFingerprint: options.ExpectedFingerprint, + RetryClass: "RECONCILE_FIRST", MaxAttempts: 3, + ExpectedPostcondition: "origin contains the exact head commit and one pull request reflects the fingerprinted title, body, and visual-evidence package", + }) + if err != nil { + return "", err + } + if receipt.State == OperationSucceeded { + if receipt.Observation.Evidence != "" { + return receipt.Observation.Evidence, nil + } + if exists { + return existingURL, nil + } + return "", fmt.Errorf("published operation is terminal but its PR URL is unavailable") + } + if receipt.State == OperationReconcileRequired { + result := "OBSERVED_ABSENT" + detail := "no pull request exists for the exact head branch" + evidence := context.HeadBranch + if exists { + result = "OBSERVED_PARTIAL" + detail = "the pull request exists; resume remaining idempotent publication steps" + evidence = existingURL + } + receipt, err = RecordOperationReconciliation(repo, receipt.OperationID, result, detail, evidence) + if err != nil { + return "", err + } + } + attemptKey := SHA256Bytes([]byte("publish-pr\x00" + options.Action + "\x00" + preview.Fingerprint)) + begin, err := BeginOperation(repo, receipt.OperationID, attemptKey, "boatstack-helper publish-pr") + if err != nil { + if errors.Is(err, ErrOperationInFlight) { + return "", fmt.Errorf("the identical PR publication is already executing; inspect operation-status instead of repeating it") + } + return "", err + } + if begin.Receipt.State == OperationSucceeded { + return begin.Receipt.Observation.Evidence, nil + } + completeUnknown := func(cause error, observedURL string) (string, error) { + _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "publication ended without a verifiable complete postcondition", observedURL) + return "", cause + } if _, err := gitCommand(repo, "push", "--set-upstream", "origin", context.HeadBranch); err != nil { - return "", fmt.Errorf("cannot push %s without rewriting history: %w", context.HeadBranch, err) + return completeUnknown(fmt.Errorf("cannot push %s without rewriting history: %w", context.HeadBranch, err), existingURL) } temporary, err := os.CreateTemp("", "boatstack-pr-body-*.md") if err != nil { - return "", err + return completeUnknown(err, existingURL) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if _, err := temporary.WriteString(preview.Body + "\n"); err != nil { temporary.Close() - return "", err + return completeUnknown(err, existingURL) } if err := temporary.Close(); err != nil { - return "", err + return completeUnknown(err, existingURL) } if options.Action == "open" { - url, err := commandOutput(repo, "gh", "pr", "create", "--base", context.BaseBranch, "--head", context.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath) - if err != nil { - return "", err + url := existingURL + if !exists { + url, err = commandOutput(repo, "gh", "pr", "create", "--base", context.BaseBranch, "--head", context.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath) + if err != nil { + return completeUnknown(err, "") + } } url = strings.TrimSpace(url) if err := publishPRVisualEvidence(repo, url, context, options.VisualPublisher); err != nil { - return "", err + return completeUnknown(err, url) } if context.Mode == "managed" { if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, url); err != nil { - return "", fmt.Errorf("PR opened but delivery state could not advance: %w", err) + return completeUnknown(fmt.Errorf("PR opened but delivery state could not advance: %w", err), url) } if err := extractSystemicBoundaries(repo, context.Feature); err != nil { fmt.Fprintf(os.Stderr, "WARNING: could not extract systemic boundaries: %v\n", err) } } + if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "pull request publication postcondition observed", url); err != nil { + return "", err + } return url, nil } if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil { - return "", err + return completeUnknown(err, existingURL) } if err := publishPRVisualEvidence(repo, existingURL, context, options.VisualPublisher); err != nil { - return "", err + return completeUnknown(err, existingURL) } if context.Mode == "managed" { if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, existingURL); err != nil { - return "", fmt.Errorf("PR updated but delivery state could not advance: %w", err) + return completeUnknown(fmt.Errorf("PR updated but delivery state could not advance: %w", err), existingURL) } if err := extractSystemicBoundaries(repo, context.Feature); err != nil { fmt.Fprintf(os.Stderr, "WARNING: could not extract systemic boundaries: %v\n", err) } } + if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "pull request update postcondition observed", existingURL); err != nil { + return "", err + } return existingURL, nil } diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 6549b52..9c9c094 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -17,6 +17,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | `changes.md` | Append-only, reviewable post-build observations with exact user message, expected/actual behavior, classification, evidence, and resolution | Controlled `record-change` transition | | Repair state | Ignored delivery mode, resume stage, active observation, attempt count, and superseded receipt references | Controlled repair and gate transitions | | Recovery status | Read-only active/published delivery, PR lifecycle, branch/SHA identity, ambiguity, and safe next transition | Before responding to CI, review, publication denial, or ordinary correction language | +| Operation receipt | Ignored Git-common identity, fingerprinted authority, lease, durable attempt budget, expected postcondition, and secret-free completion observation | Before and after each managed mutation or external side effect | | Gate receipt | Machine-local test or review transition bound to one delivery slice, base/head branches, commit, product diff, and evidence hash | A slice passes test or review | | Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes | | Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent | @@ -91,7 +92,9 @@ ledger while the publisher rechecks the matching receipts. ## Safety boundary -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. +The generated host hook fragments and launchers are committed installation infrastructure. Their policy is immutable in project configuration. Cursor pre/post native, shell, and MCP events; Claude and Codex `PreToolUse`/`PostToolUse`; and Gemini `BeforeTool`/`AfterTool` project into one classifier and completion observer. 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. + +Operation receipts live under Git-common `boatstack/operations/v1`, never in Git history. They distinguish prepared, executing, unknown, retryable, and terminal work across turns and linked worktrees. Receipts contain hashes and bounded observations rather than commands, tool payloads, responses, credentials, or autonomous workflow intent. Terminal identities remain long enough to consume delayed duplicate events; old detail is compacted. ## PR visual evidence boundary diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index 4156d6e..acfaf2d 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -20,6 +20,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Irreversible recovery escalation | A failed external operation causes authority/target broadening or an invented reset | Immutable pre-execution deny; preserve state; read-only diagnosis; transactional retry or fix forward | False denial of legitimate isolated development operations | | Worktree bootstrap deadlock | A linked worktree inherits fail-closed hooks but not the ignored runtime required to evaluate them | Versioned Git-common runtime; atomic first-use hydration; provenance check | Cross-version execution or weakened failure behavior | | Post-publication correction routing | CI, review, or a denied push targets work already marked published | Resolve branch and recorded PR identity; append the observation; draft an independently approved corrective child | Treating PR creation as completion or asking the user to bypass the guard | +| Unobserved side-effect completion | The same visible state could mean not started, executing, succeeded with a lost response, or failed | Durable operation receipt; exact lease; observe completion; reconcile the expected postcondition before retry | Conversation-scoped retry loops, duplicate PRs, or phantom success | ## Lessons encoded from the benchmark campaign @@ -32,6 +33,7 @@ Select a move only after locating the failure below its surface symptom. “Time - **Model changes relocate the bottleneck.** The same harness exposed different binding modes on Gemini and Qwen. Route moves by measured failure population, not by a universal “best loop.” - **Tool failure must not create recovery authority.** The sanitized database incident moved from a partial schema apply failure to an invented reset path. The irreversible-operation guard is `PROPOSED`, not promoted: evaluate its deny corpus, safe corpus, latency, and workflow regressions against the unguarded baseline. - **Fail-closed controls need an available evaluator.** A linked worktree copied the safety hook but not its ignored helper, so the guard also denied its own repair command. Share only the verified runtime within the Git clone and hydrate local ignored state before judging the original event. +- **A retry needs a new observation.** Identical in-flight calls wait. Unknown non-idempotent calls enter `RECONCILE_REQUIRED`; Git, GitHub, filesystem, browser, and MCP boundaries must observe their exact postcondition before another attempt consumes the persistent budget. ## Move proposal schema diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 67255de..199a80f 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -6,10 +6,10 @@ claim. | Host | Configuration and event | Blocking result | Activation boundary | | --- | --- | --- | --- | -| 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. | +| Cursor | `.cursor/hooks.json`; synchronous `preToolUse`, `postToolUse`/`postToolUseFailure`, `beforeShellExecution`/`afterShellExecution`, and `beforeMCPExecution`/`afterMCPExecution` | JSON `permission: "deny"`; generated entries set `failClosed: true` | Reload and host enablement are operator-visible. Native Write/Edit tools, shell, and MCP mutations cross the guard and completion observer. | +| Claude Code | `.claude/settings.json`; `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` | 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` and `PostToolUse` | 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` and `AfterTool` | JSON `decision: "deny"` with a secret-free reason | The generated sequential hooks supervise requests and observe results through the same repository guard. Reload after installation. | Sources: @@ -39,3 +39,10 @@ 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. + +Managed mutations add one single-use operation lease before the host tool runs. +Post-tool events may complete only the matching kind, target, and argument +fingerprint. A delayed or duplicated completion cannot initiate work. Missing or +uncertain completion becomes `RECONCILE_REQUIRED`, and safety output may expose +only operation identity, state, attempt number, and whether reconciliation is +required. diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 63595a6..21c8fc7 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -366,7 +366,7 @@ Store the exact preview at `.product-loop/features//pr.md`. Its non-ren PR schema v3 always records `pr_visual_evidence_policy`, `pr_visual_evidence_status`, `pr_visual_evidence_count`, and `pr_visual_evidence_fingerprint`. Relevant or unresolved PRs contain a structured **Visual evidence** section. Show the exact local images and public-repository privacy warning before confirmation. The state-scoped `o` or `u` authorizes the fingerprinted PR package: title, body, and one Boatstack-owned visual-evidence comment. A host browser may upload or update that comment; otherwise expose the exact local PNGs for manual attachment. If the PR mutation succeeds but attachment fails, preserve the PR, record `visual_pending`, and fix forward. Under `require`, do not mark managed delivery published until the attachment is observed. -Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action. When no PR exists, render: Reply `o` to open PR. When one exists, render: Reply `u` to update PR. Only the corresponding state-scoped shortcut or compatible full reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then perform a normal push and the selected GitHub action. Any drift blocks publication and requires a new preview; never force-push. +Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action. When no PR exists, render: Reply `o` to open PR. When one exists, render: Reply `u` to update PR. Only the corresponding state-scoped shortcut or compatible full reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then let the deterministic publisher perform a normal push and the selected GitHub action. It records the fingerprinted package before execution. A lost response enters reconciliation against the exact remote branch and PR; it never opens another PR blindly. Any package drift blocks publication and requires a new preview; never force-push. For managed work, publication also requires current test and review receipts for the active delivery slice. Successful publication marks only that slice `PUBLISHED` and @@ -381,9 +381,28 @@ After successful publication only, the publisher may use the ignored 24-hour rel `boatstack-update` is an infrastructure operation, not part of a feature plan. It first forces release discovery and proves the current installation is healthy. If the repository is not on its clean, current default branch, it changes nothing and returns **Update postponed**. -For an available version, create `chore/update-boatstack-v`, run the installer pinned to that release in update mode, preserve the repository configuration, adapters, integrations, and unrelated host settings, then run `doctor`. Show the release notes and link, exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Product paths or generated-state drift are blocking. +For an available version, create `chore/update-boatstack-v`, run the installer pinned to that release in update mode, preserve the repository configuration, adapters, integrations, and unrelated host settings, then run `doctor`. The update transaction itself is a durable atomic-local operation. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show the release notes and link, exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Product paths or generated-state drift are blocking. -Use **Boatstack update ready** and exactly one action: Reply `o` to open update PR. Only the state-scoped `o` or compatible full reply authorizes staging the reported infrastructure paths, committing, pushing normally, and opening the update PR. The PR body records old/new versions, release provenance, changed generated files, doctor result, integration state, rollout, and revert instructions. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically. +Use **Boatstack update ready** and exactly one action: Reply `o` to open update PR. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher stages only the approved paths, reuses or creates the exact update commit, pushes normally, and reconciles the head branch before opening at most one PR. The PR body records release provenance, changed generated files, verification, rollout, and revert instructions. If a response is lost after GitHub accepted the request, the next invocation observes and returns the existing PR. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically. + +## Durable operation boundary + +During an active managed delivery, every mutation-capable host call receives a +single-use lease bound to its tool, target, argument fingerprint, plan authority, +and persistent attempt number. Post-tool events complete that attempt. Identical +active work reports **wait**; an already successful fingerprint is not relaunched. +Unknown completion reports **reconcile** and checks the expected Git, GitHub, +filesystem, browser, or MCP postcondition before any retry. + +`operation-status --repo . --json` is read-only. An omitted operation ID resolves +only when at most one unfinished operation matches the current branch; ambiguity +is explicit and never resolved by recency. Receipts are ignored Git-common state +shared by linked worktrees. They store hashes and bounded facts, not commands, +secrets, user content, or autonomous execution intent. + +`boatstack-run` consults this state before advancing. Its three-cycle repair budget +is the delivery state's durable `repair_attempt`, not a counter reset by a new +conversation, process, host, or async notification. ## Existing and ad-hoc PRs diff --git a/boatstack/release.go b/boatstack/release.go index be0b751..5c34ced 100644 --- a/boatstack/release.go +++ b/boatstack/release.go @@ -73,11 +73,11 @@ func ClassifyReleaseDiff(repo, base, head string) (ReleaseClassification, error) return ReleaseClassification{}, fmt.Errorf("release classification requires base and head revisions") } command := exec.Command("git", "-C", repo, "diff", "--name-only", "--no-renames", base, head) - output, err := command.CombinedOutput() + channels, err := runCommandChannels(command) if err != nil { - return ReleaseClassification{}, fmt.Errorf("release diff failed: %s", strings.TrimSpace(string(output))) + return ReleaseClassification{}, fmt.Errorf("release diff failed: %w", commandFailure(channels, err)) } - return ClassifyReleasePaths(strings.Split(strings.TrimSpace(string(output)), "\n")), nil + return ClassifyReleasePaths(strings.Split(strings.TrimSpace(string(channels.Stdout)), "\n")), nil } // NextPatchVersion returns the next stable patch version. Minor and major diff --git a/boatstack/safety.go b/boatstack/safety.go index 573ede2..4aecbc8 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -15,16 +15,20 @@ import ( // SafetyFinding is intentionally small and secret-free. The guard reports the // class and a stable explanation, never the full command or tool arguments. type SafetyFinding struct { - Category string `json:"category"` - Reason string `json:"reason"` - Source string `json:"source,omitempty"` - BlockingFeature string `json:"blocking_feature,omitempty"` - BlockingSlice string `json:"blocking_slice,omitempty"` - 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"` + Category string `json:"category"` + Reason string `json:"reason"` + Source string `json:"source,omitempty"` + BlockingFeature string `json:"blocking_feature,omitempty"` + BlockingSlice string `json:"blocking_slice,omitempty"` + 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"` + OperationID string `json:"operation_id,omitempty"` + OperationState string `json:"operation_state,omitempty"` + AttemptNumber int `json:"attempt_number,omitempty"` + ReconciliationRequired bool `json:"reconciliation_required,omitempty"` } type SafetyReport struct { @@ -90,7 +94,7 @@ func controlledPhaseTransition(command, stage string) bool { } 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, + "recovery-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, "doctor": true, "version": true, } if readOnlyHelpers[fields[1]] { @@ -467,6 +471,196 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { return dedupeFindings(findings) } +func mutationCapableTool(name string, input any) bool { + if strings.EqualFold(name, "Bash") || strings.EqualFold(name, "Shell") || strings.EqualFold(name, "beforeShellExecution") || strings.EqualFold(name, "run_shell_command") { + object, ok := input.(map[string]any) + return !ok || !isPureReadOnlyCommand(stringValue(object["command"])) + } + lower := strings.ToLower(name) + return mutationToolPattern.MatchString(lower) || (strings.HasPrefix(lower, "mcp__") && !externalReadOnlyToolPattern.MatchString(lower)) +} + +func supervisedToolIdentity(name string, input any) (string, string) { + value, _ := json.Marshal(input) + fingerprint := SHA256Bytes(append([]byte(strings.ToLower(strings.TrimSpace(name))+"\x00"), value...)) + return "tool:" + strings.ToLower(strings.TrimSpace(name)), fingerprint +} + +func activeManagedOperationScope(repo string) (OperationScope, string, bool) { + active, err := ActiveManagedDeliveries(repo) + if err != nil || len(active) == 0 { + return OperationScope{}, "", false + } + branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + for _, feature := range active { + state, loadErr := LoadDeliveryState(repo, feature) + if loadErr != nil || !stateMatchesBranch(state, branch) || state.ActiveIndex >= len(state.Slices) { + continue + } + slice := state.Slices[state.ActiveIndex] + return OperationScope{Feature: feature, Slice: slice.ID, Worktree: filepath.Base(repo), HeadBranch: branch}, state.PlanLockHash, true + } + return OperationScope{}, "", false +} + +func operationRetryClassForTool(name string) string { + lower := strings.ToLower(name) + if strings.HasPrefix(lower, "mcp__") || strings.Contains(lower, "upload") || strings.Contains(lower, "browser") { + return "RECONCILE_FIRST" + } + if strings.Contains(lower, "write") || strings.Contains(lower, "edit") || strings.Contains(lower, "patch") || strings.Contains(lower, "create") { + return "ATOMIC_LOCAL" + } + return "IDEMPOTENT_EXTERNAL" +} + +func hookAttemptKey(host, fingerprint string, eventValue []byte) string { + var event map[string]any + if json.Unmarshal(eventValue, &event) == nil { + for _, key := range []string{"tool_call_id", "tool_use_id", "call_id"} { + if identity := strings.TrimSpace(stringValue(event[key])); identity != "" { + return SHA256Bytes([]byte(strings.ToLower(strings.TrimSpace(host)) + "\x00" + identity + "\x00" + fingerprint)) + } + } + } + return SHA256Bytes([]byte(strings.ToLower(strings.TrimSpace(host)) + "\x00" + fingerprint)) +} + +func superviseToolAttempt(repo, host, name string, input any, eventValue []byte) *SafetyFinding { + if !mutationCapableTool(name, input) { + return nil + } + scope, authority, managed := activeManagedOperationScope(repo) + if !managed { + return nil + } + kind, fingerprint := supervisedToolIdentity(name, input) + target := attemptedRepositoryPath(repo, input) + if target == "" { + target = kind + } + receipt, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: kind, Scope: scope, Target: target, PackageFingerprint: fingerprint, + AuthorizationFingerprint: authority, RetryClass: operationRetryClassForTool(name), MaxAttempts: 3, + ExpectedPostcondition: "the supervised tool reports completion and its target can be reconciled", + }) + if err != nil { + return &SafetyFinding{Category: "operation-state-invalid", Reason: "the managed operation receipt could not be prepared", Source: "operation-controller", NextOperation: "operation-status"} + } + if receipt.State == OperationSucceeded { + return &SafetyFinding{Category: "operation-already-succeeded", Reason: "the identical fingerprinted operation already succeeded", Source: "operation-controller", OperationID: receipt.OperationID, OperationState: string(receipt.State), AttemptNumber: receipt.Attempt, NextOperation: "none"} + } + attemptKey := hookAttemptKey(host, fingerprint, eventValue) + begin, beginErr := BeginOperation(repo, receipt.OperationID, attemptKey, name) + if beginErr == nil { + return nil + } + finding := &SafetyFinding{ + Category: "operation-state-invalid", Reason: beginErr.Error(), Source: "operation-controller", + OperationID: receipt.OperationID, OperationState: string(begin.Receipt.State), AttemptNumber: begin.Receipt.Attempt, NextOperation: "operation-status", + } + switch { + case errors.Is(beginErr, ErrOperationInFlight): + finding.Category = "operation-in-flight" + finding.Reason = "the identical authorized operation is already executing" + finding.NextOperation = "wait" + case begin.Receipt.State == OperationReconcileRequired: + finding.Category = "operation-reconciliation-required" + finding.Reason = "the previous attempt ended without an observable completion" + finding.ReconciliationRequired = true + finding.NextOperation = "reconcile" + case begin.Receipt.State == OperationFailedFinal: + finding.Category = "operation-retry-exhausted" + finding.Reason = "the persistent operation retry budget is exhausted" + finding.NextOperation = "manual_recovery" + } + return finding +} + +func postToolEvent(host string, value []byte) (string, any, string, bool, bool) { + var event map[string]any + if json.Unmarshal(value, &event) != nil { + return "", nil, "", false, false + } + eventName := stringValue(event["hook_event_name"]) + postNames := map[string]bool{"postToolUse": true, "postToolUseFailure": true, "afterShellExecution": true, "afterMCPExecution": true, "PostToolUse": true, "PostToolUseFailure": true, "AfterTool": true} + if !postNames[eventName] { + return "", nil, "", false, false + } + name := stringValue(event["tool_name"]) + input := event["tool_input"] + if eventName == "afterShellExecution" { + name = "Bash" + input = map[string]any{"command": stringValue(event["command"])} + } + if eventName == "afterMCPExecution" { + var err error + input, err = cursorMCPInput(input) + if err != nil { + return "", nil, "UNKNOWN", true, true + } + } + hasResult := event["tool_response"] != nil || event["tool_result"] != nil || event["tool_output"] != nil || event["result"] != nil || event["output"] != nil || event["error"] != nil || event["tool_error"] != nil || event["exit_code"] != nil || event["exitCode"] != nil + if strings.Contains(strings.ToLower(eventName), "failure") { + hasResult = event["error"] != nil || event["tool_error"] != nil + } + if !hasResult { + return "", nil, "UNKNOWN", true, true + } + outcome := "SUCCEEDED" + failed := event["error"] != nil || event["tool_error"] != nil || event["is_error"] == true + for _, key := range []string{"exit_code", "exitCode"} { + if code, ok := event[key].(float64); ok && code != 0 { + failed = true + } + } + if response, ok := event["tool_response"].(map[string]any); ok { + if response["error"] != nil || response["is_error"] == true || response["success"] == false { + failed = true + } + for _, key := range []string{"exit_code", "exitCode"} { + if code, ok := response[key].(float64); ok && code != 0 { + failed = true + } + } + } + if failed { + outcome = "UNKNOWN" + } + if strings.TrimSpace(name) == "" || input == nil { + return "", nil, "UNKNOWN", true, true + } + return name, input, outcome, true, false +} + +func completeSupervisedToolEvent(repo, host string, value []byte) (bool, bool) { + name, input, outcome, handled, malformed := postToolEvent(host, value) + if !handled { + return false, false + } + if malformed { + return true, true + } + if name == "" || input == nil || !mutationCapableTool(name, input) { + return true, false + } + kind, fingerprint := supervisedToolIdentity(name, input) + target := attemptedRepositoryPath(repo, input) + if target == "" { + target = kind + } + id := operationID(kind, target, fingerprint) + receipt, err := loadOperation(repo, id) + if err != nil || receipt.State != OperationExecuting || receipt.Lease == nil { + return true, false + } + attemptKey := hookAttemptKey(host, fingerprint, value) + if _, err := CompleteOperationAttempt(repo, id, attemptKey, outcome, "host completion event observed", ""); err != nil && outcome != "UNKNOWN" { + _, _ = CompleteOperationAttempt(repo, id, attemptKey, "UNKNOWN", "completion event could not be correlated", "") + } + return true, false +} + func dedupeFindings(values []SafetyFinding) []SafetyFinding { seen := map[string]bool{} result := []SafetyFinding{} @@ -739,6 +933,24 @@ func denialMessage(host string, finding SafetyFinding) string { } return "Boatstack denied the publication bypass because " + target + " still owns publication authority." + relation + context + " Resolve the reported change through the managed recovery path; do not repeat this push or PR mutation manually." } + if strings.HasPrefix(finding.Category, "operation-") { + context := "" + if finding.OperationID != "" { + context = fmt.Sprintf(" operation=%s state=%s attempt=%d", finding.OperationID, finding.OperationState, finding.AttemptNumber) + } + switch finding.Category { + case "operation-in-flight": + return "Boatstack is already supervising this exact operation." + context + ". Wait for its completion event; do not launch it again." + case "operation-already-succeeded": + return "Boatstack already observed this exact operation succeed." + context + ". Continue from the resulting repository state instead of repeating it." + case "operation-reconciliation-required": + return "Boatstack cannot yet distinguish success from an interrupted response." + context + ". Reconcile the expected postcondition with operation-status before any retry." + case "operation-retry-exhausted": + return "Boatstack exhausted the persistent retry budget for this operation." + context + ". Preserve current state and use the reported manual recovery; do not repeat the tool call." + default: + return "Boatstack could not verify the durable operation state." + context + ". Inspect operation-status before retrying." + } + } return "Boatstack denied an irreversible operation (" + finding.Category + "). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow." } @@ -756,6 +968,15 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { value, _ := contract.deny(finding) return value, true } + if handled, malformed := completeSupervisedToolEvent(repo, host, options.Input); handled { + if malformed { + finding := SafetyFinding{Category: "malformed-tool-input", Reason: "invalid-post-event", Source: "hook"} + value, _ := contract.deny(finding) + return value, true + } + value, _ := contract.allow() + return value, false + } name, input, err := contract.decode(options.Input) if err != nil { reason := "invalid-event" @@ -769,6 +990,10 @@ func HookDecision(options SafetyHookOptions) ([]byte, bool) { } findings := ClassifyTool(repo, name, input) if len(findings) == 0 { + if finding := superviseToolAttempt(repo, host, name, input, options.Input); finding != nil { + value, _ := contract.deny(*finding) + return value, true + } value, _ := contract.allow() return value, false } diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index e1fb5f5..bb593fb 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -217,7 +217,7 @@ func TestHostContractsNormalizeCanonicalInputs(t *testing.T) { {"codex allow", "codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, false, ""}, {"codex deny", "codex", `{"hook_event_name":"PreToolUse","tool_name":"mcp__cloud__delete_database","tool_input":{"database":"primary"}}`, true, `"permissionDecision":"deny"`}, {"codex lowercase PreToolUse deny", "codex", `{"hook_event_name":"preToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`}, - {"wrong event deny", "codex", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`}, + {"malformed post deny", "codex", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"git status --short"}}`, true, `"permissionDecision":"deny"`}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { @@ -245,7 +245,7 @@ func TestMalformedHostPayloadsDenyWithoutLeakingInput(t *testing.T) { {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_input":{}}`, "missing-tool-name"}, {"cursor", `{"hook_event_name":"beforeMCPExecution","tool_name":"mcp__cloud__delete_database","tool_input":"secret-not-json"}`, "invalid-tool-input-json"}, {"cursor", `{"hook_event_name":"unknown","command":"secret-command"}`, "unsupported-event"}, - {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`, "unsupported-event"}, + {"claude", `{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"secret-command"}}`, "invalid-post-event"}, {"codex", `{"hook_event_name":"PreToolUse","tool_name":"Bash"}`, "missing-tool-input"}, } { output, denied := HookDecision(SafetyHookOptions{Host: test.host, Repo: repo, Input: []byte(test.input)}) diff --git a/boatstack/update_publication.go b/boatstack/update_publication.go new file mode 100644 index 0000000..abe976f --- /dev/null +++ b/boatstack/update_publication.go @@ -0,0 +1,349 @@ +package boatstack + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const updatePublicationSchemaVersion = 1 + +type UpdatePublicationPreview struct { + SchemaVersion int `json:"schema_version"` + Version string `json:"version"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + StartingHeadCommit string `json:"starting_head_commit"` + ChangedPaths []string `json:"changed_paths"` + PackageFingerprint string `json:"package_fingerprint"` + Title string `json:"title"` + Body string `json:"body"` + PreviewPath string `json:"preview_path"` + Fingerprint string `json:"fingerprint"` +} + +type UpdatePublishOptions struct { + Repo string + PreviewPath string + ExpectedFingerprint string +} + +func updatePreviewDirectory(repo, version string) (string, error) { + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + segment, err := safeCacheSegment(version, "update version") + if err != nil { + return "", err + } + directory := filepath.Join(common, "boatstack", "updates", segment) + if err := rejectSymlinkComponents(common, directory); err != nil { + return "", err + } + return directory, nil +} + +func updatePreviewPath(repo, version string) (string, error) { + directory, err := updatePreviewDirectory(repo, version) + if err != nil { + return "", err + } + return filepath.Join(directory, "pr-preview.json"), nil +} + +func updateChangedPathsAgainst(repo, base string) ([]string, error) { + value, err := gitCommand(repo, "diff", "--name-only", "--diff-filter=ACDMR", base) + if err != nil { + return nil, err + } + untracked, err := gitCommand(repo, "ls-files", "--others", "--exclude-standard") + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, line := range strings.Split(value+"\n"+untracked, "\n") { + path := filepath.ToSlash(strings.TrimSpace(line)) + if path != "" { + seen[path] = true + } + } + return sortedKeys(seen), nil +} + +func installedGeneratedPaths(repo string) map[string]bool { + result := map[string]bool{} + for path := range previousFiles(repo) { + result[filepath.ToSlash(path)] = true + } + return result +} + +func updateOwnedPaths(repo string, config ProjectConfig) map[string]bool { + owned := installedGeneratedPaths(repo) + owned[".boatstack-project.json"] = true + for _, path := range HostHookPaths(config.Adapters) { + owned[filepath.ToSlash(path)] = true + } + for _, adapter := range config.Adapters { + switch adapter { + case "cursor": + owned[".cursorrules"] = true + case "claude": + owned["CLAUDE.md"] = true + case "gemini": + owned["GEMINI.md"] = true + } + } + return owned +} + +func validateUpdatePublicationPaths(repo, base string, config ProjectConfig, paths []string) error { + owned := updateOwnedPaths(repo, config) + unexpected := []string{} + for _, path := range paths { + if owned[path] || strings.HasPrefix(path, ".product-loop/") { + continue + } + // A removed generated file is absent from the incoming generated lock. + // Accept it only when its base content carries Boatstack's marker. + baseValue, err := gitCommand(repo, "show", base+":"+path) + if err == nil && strings.Contains(baseValue, Marker) { + continue + } + unexpected = append(unexpected, path) + } + if len(unexpected) > 0 { + sort.Strings(unexpected) + return fmt.Errorf("update publication contains non-Boatstack paths: %s", strings.Join(unexpected, ", ")) + } + return nil +} + +func updatePackageFingerprint(repo, base string, paths []string) (string, error) { + parts := []string{"base=" + base} + for _, relative := range paths { + path, err := resolveRepositoryRelativePath(repo, relative) + if err != nil { + return "", err + } + if info, statErr := os.Lstat(path); statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("update path is not a safe regular file: %s", relative) + } + hash, hashErr := SHA256File(path) + if hashErr != nil { + return "", hashErr + } + parts = append(parts, relative+"=file:"+hash) + } else if os.IsNotExist(statErr) { + parts = append(parts, relative+"=deleted") + } else { + return "", statErr + } + } + return SHA256Bytes([]byte(strings.Join(parts, "\n"))), nil +} + +func updatePreviewFingerprint(preview UpdatePublicationPreview) (string, error) { + copy := preview + copy.Fingerprint = "" + // Publication may deterministically commit the already approved package. + // The content fingerprint, not the pre-commit HEAD, is the authority. + copy.StartingHeadCommit = "" + value, err := MarshalJSON(copy) + if err != nil { + return "", err + } + return SHA256Bytes(value), nil +} + +func PrepareUpdatePublication(repoPath, requestedVersion string) (UpdatePublicationPreview, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return UpdatePublicationPreview{}, err + } + version, err := normalizedVersion(requestedVersion) + if err != nil { + return UpdatePublicationPreview{}, err + } + config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if err != nil { + return UpdatePublicationPreview{}, err + } + baseBranch := strings.TrimSpace(config.Project.DefaultBranch) + baseRef := "origin/" + baseBranch + if _, err := gitCommand(repo, "rev-parse", "--verify", baseRef+"^{commit}"); err != nil { + return UpdatePublicationPreview{}, fmt.Errorf("update preview requires fetched %s", baseRef) + } + headBranch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) + if headBranch != "chore/update-boatstack-"+version { + return UpdatePublicationPreview{}, fmt.Errorf("update preview requires branch chore/update-boatstack-%s; current branch is %s", version, headBranch) + } + paths, err := updateChangedPathsAgainst(repo, baseRef) + if err != nil { + return UpdatePublicationPreview{}, err + } + if len(paths) == 0 { + return UpdatePublicationPreview{}, fmt.Errorf("Boatstack update produced no reviewable infrastructure diff") + } + if err := validateUpdatePublicationPaths(repo, baseRef, config, paths); err != nil { + return UpdatePublicationPreview{}, err + } + packageFingerprint, err := updatePackageFingerprint(repo, baseRef, paths) + if err != nil { + return UpdatePublicationPreview{}, err + } + previewPath, err := updatePreviewPath(repo, version) + if err != nil { + return UpdatePublicationPreview{}, err + } + preview := UpdatePublicationPreview{ + SchemaVersion: updatePublicationSchemaVersion, Version: version, BaseBranch: baseBranch, HeadBranch: headBranch, + StartingHeadCommit: gitOutput(repo, "rev-parse", "HEAD"), ChangedPaths: paths, PackageFingerprint: packageFingerprint, + Title: "Update Boatstack to " + version, + Body: "## Why this change\n\nUpdate the repository-owned Boatstack infrastructure to " + version + ".\n\n## What changed\n\nOnly the fingerprinted Boatstack-generated files, host hooks, runtime provenance, and preserved integration state in this update package.\n\n## Verification\n\n- Boatstack doctor passed after installation.\n- Generated-file and hook projections are validated by the update transaction.\n\n## Rollback\n\nRevert this infrastructure-only commit and rerun the previously pinned installer.\n", + PreviewPath: previewPath, + } + preview.Fingerprint, err = updatePreviewFingerprint(preview) + if err != nil { + return UpdatePublicationPreview{}, err + } + value, err := MarshalJSON(preview) + if err != nil { + return UpdatePublicationPreview{}, err + } + if len(value) == 0 { + return UpdatePublicationPreview{}, fmt.Errorf("refusing to write an empty update preview") + } + if err := atomicWriteMode(previewPath, value, 0o600); err != nil { + return UpdatePublicationPreview{}, err + } + return preview, nil +} + +func LoadUpdatePublicationPreview(path string) (UpdatePublicationPreview, error) { + value, err := os.ReadFile(path) + if err != nil { + return UpdatePublicationPreview{}, err + } + if len(strings.TrimSpace(string(value))) == 0 { + return UpdatePublicationPreview{}, fmt.Errorf("update preview is empty") + } + var preview UpdatePublicationPreview + if err := DecodeJSON("load update publication preview", path, value, &preview); err != nil { + return UpdatePublicationPreview{}, err + } + if preview.SchemaVersion != updatePublicationSchemaVersion || preview.Fingerprint == "" || preview.PackageFingerprint == "" || len(preview.ChangedPaths) == 0 { + return UpdatePublicationPreview{}, fmt.Errorf("update preview identity is invalid") + } + expected, err := updatePreviewFingerprint(preview) + if err != nil || expected != preview.Fingerprint { + return UpdatePublicationPreview{}, fmt.Errorf("update preview fingerprint is invalid") + } + return preview, nil +} + +func PublishUpdatePublication(options UpdatePublishOptions) (string, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return "", err + } + preview, err := LoadUpdatePublicationPreview(options.PreviewPath) + if err != nil { + return "", err + } + if options.ExpectedFingerprint == "" || options.ExpectedFingerprint != preview.Fingerprint { + return "", fmt.Errorf("update publication fingerprint does not match the exact preview confirmed by the human") + } + current, err := PrepareUpdatePublication(repo, preview.Version) + if err != nil { + return "", err + } + if current.Fingerprint != preview.Fingerprint || current.PackageFingerprint != preview.PackageFingerprint { + return "", fmt.Errorf("update package changed after confirmation; regenerate the preview") + } + if err := ghAvailable(repo); err != nil { + return "", err + } + existingURL, exists, err := existingPRURL(repo) + if err != nil { + return "", err + } + target := "github-update-pr:" + preview.HeadBranch + receipt, err := PrepareOperation(OperationPrepareOptions{ + Repo: repo, Kind: "publish-update-pr", Scope: OperationScope{Worktree: filepath.Base(repo), HeadBranch: preview.HeadBranch}, + Target: target, PackageFingerprint: preview.PackageFingerprint, AuthorizationFingerprint: preview.Fingerprint, + RetryClass: "RECONCILE_FIRST", MaxAttempts: 3, + ExpectedPostcondition: "origin contains the exact update commit and one pull request contains the fingerprinted update body", + }) + if err != nil { + return "", err + } + if receipt.State == OperationSucceeded { + return receipt.Observation.Evidence, nil + } + if receipt.State == OperationReconcileRequired { + result, detail, evidence := "OBSERVED_ABSENT", "no pull request exists for the update branch", preview.HeadBranch + if exists { + result, detail, evidence = "OBSERVED_PARTIAL", "the update PR exists; resume exact remaining postconditions", existingURL + } + if receipt, err = RecordOperationReconciliation(repo, receipt.OperationID, result, detail, evidence); err != nil { + return "", err + } + } + attemptKey := SHA256Bytes([]byte("publish-update-pr\x00" + preview.Fingerprint)) + begin, err := BeginOperation(repo, receipt.OperationID, attemptKey, "boatstack-helper publish-update-pr") + if err != nil { + if errors.Is(err, ErrOperationInFlight) { + return "", fmt.Errorf("the identical update publication is already executing; inspect operation-status instead of repeating it") + } + return "", err + } + unknown := func(cause error, url string) (string, error) { + _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "update publication ended without a verifiable complete postcondition", url) + return "", cause + } + if strings.TrimSpace(gitOutput(repo, "status", "--porcelain")) != "" { + arguments := append([]string{"add", "--"}, preview.ChangedPaths...) + if _, err := gitCommand(repo, arguments...); err != nil { + return unknown(err, existingURL) + } + if _, err := gitCommand(repo, "commit", "-m", "chore: update Boatstack to "+preview.Version); err != nil { + return unknown(err, existingURL) + } + } + if _, err := gitCommand(repo, "push", "--set-upstream", "origin", preview.HeadBranch); err != nil { + return unknown(err, existingURL) + } + temporary, err := os.CreateTemp("", "boatstack-update-pr-*.md") + if err != nil { + return unknown(err, existingURL) + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err := temporary.WriteString(preview.Body); err != nil { + temporary.Close() + return unknown(err, existingURL) + } + if err := temporary.Close(); err != nil { + return unknown(err, existingURL) + } + url := existingURL + if !exists { + url, err = commandOutput(repo, "gh", "pr", "create", "--base", preview.BaseBranch, "--head", preview.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath) + if err != nil { + return unknown(err, "") + } + } else if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil { + return unknown(err, existingURL) + } + url = strings.TrimSpace(url) + if _, err := CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "SUCCEEDED", "update branch and pull request observed", url); err != nil { + return "", err + } + return url, nil +} diff --git a/boatstack/update_publication_test.go b/boatstack/update_publication_test.go new file mode 100644 index 0000000..fd89105 --- /dev/null +++ b/boatstack/update_publication_test.go @@ -0,0 +1,188 @@ +package boatstack + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func updatePublicationTestRepo(t *testing.T, version string) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") + config := testConfig() + config.Project.DefaultBranch = "main" + config.Adapters = []string{"cursor"} + configValue, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + generatedPath := ".cursor/commands/boatstack-update.md" + generatedValue := []byte("\nold\n") + for path, value := range map[string][]byte{ + ".product-loop/project.json": configValue, + generatedPath: generatedValue, + "README.md": []byte("fixture\n"), + } { + absolute := filepath.Join(repo, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(absolute, value, 0o644); err != nil { + t.Fatal(err) + } + } + lock, err := MarshalJSON(map[string]any{"files": map[string]string{generatedPath: SHA256Bytes(generatedValue)}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), lock, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := execCommand("git", "init", "--bare", remote); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + runGit(t, repo, "remote", "add", "origin", remote) + runGit(t, repo, "push", "-u", "origin", "main") + runGit(t, repo, "switch", "-c", "chore/update-boatstack-"+version) + if err := os.WriteFile(filepath.Join(repo, filepath.FromSlash(generatedPath)), []byte("\nnew\n"), 0o644); err != nil { + t.Fatal(err) + } + return repo +} + +func execCommand(name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + value, err := command.CombinedOutput() + return string(value), err +} + +func TestPrepareUpdatePublicationIsAtomicAndRejectsProductPaths(t *testing.T) { + repo := updatePublicationTestRepo(t, "v9.8.7") + statusBefore := gitOutput(repo, "status", "--porcelain=v1", "--untracked-files=all") + preview, err := PrepareUpdatePublication(repo, "v9.8.7") + if err != nil { + t.Fatal(err) + } + if preview.Fingerprint == "" || preview.PackageFingerprint == "" || len(preview.ChangedPaths) != 1 || preview.ChangedPaths[0] != ".cursor/commands/boatstack-update.md" { + t.Fatalf("unexpected preview: %+v", preview) + } + value, err := os.ReadFile(preview.PreviewPath) + common, commonErr := gitCommonDir(repo) + relativeToCommon, relativeErr := filepath.Rel(common, preview.PreviewPath) + outsideCommon := relativeErr != nil || relativeToCommon == ".." || strings.HasPrefix(relativeToCommon, ".."+string(filepath.Separator)) + statusAfter := gitOutput(repo, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil || commonErr != nil || len(value) == 0 || outsideCommon || statusAfter != statusBefore { + t.Fatalf("preview was not a complete Git-common artifact: path=%s common=%s relative=%s size=%d read_err=%v common_err=%v rel_err=%v worktree_changed=%t", preview.PreviewPath, common, relativeToCommon, len(value), err, commonErr, relativeErr, statusAfter != statusBefore) + } + if err := os.WriteFile(filepath.Join(repo, "product.go"), []byte("package product\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := PrepareUpdatePublication(repo, "v9.8.7"); err == nil || !strings.Contains(err.Error(), "non-Boatstack paths") { + t.Fatalf("product path entered update package: %v", err) + } +} + +func TestPublishUpdatePublicationOwnsCommitPushAndSinglePR(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gh fixture uses a POSIX shell") + } + repo := updatePublicationTestRepo(t, "v9.8.7") + preview, err := PrepareUpdatePublication(repo, "v9.8.7") + if err != nil { + t.Fatal(err) + } + fakeDir := t.TempDir() + marker := filepath.Join(fakeDir, "created") + script := filepath.Join(fakeDir, "gh") + scriptBody := `#!/bin/sh +if [ "$1" = "auth" ]; then exit 0; fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + if [ -f "$BOATSTACK_UPDATE_CREATED" ]; then echo "https://github.com/example/repo/pull/42"; exit 0; fi + echo "no pull requests found for branch" >&2; exit 1 +fi +if [ "$1" = "pr" ] && [ "$2" = "create" ]; then + touch "$BOATSTACK_UPDATE_CREATED" + echo "https://github.com/example/repo/pull/42" + exit 0 +fi +exit 1 +` + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BOATSTACK_UPDATE_CREATED", marker) + url, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint}) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/example/repo/pull/42" || !fileExists(marker) { + t.Fatalf("unexpected publication: %s", url) + } + if status := strings.TrimSpace(gitOutput(repo, "status", "--porcelain")); status != "" { + t.Fatalf("publisher left dirty state: %s", status) + } + if subject := gitOutput(repo, "log", "-1", "--pretty=%s"); subject != "chore: update Boatstack to v9.8.7" { + t.Fatalf("publisher did not own the exact commit: %s", subject) + } + secondURL, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint}) + if err != nil || secondURL != url { + t.Fatalf("terminal receipt did not suppress duplicate publication: %s %v", secondURL, err) + } +} + +func TestInterruptedUpdatePublicationReconcilesExistingPRWithoutDuplicate(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gh fixture uses a POSIX shell") + } + repo := updatePublicationTestRepo(t, "v9.8.6") + preview, err := PrepareUpdatePublication(repo, "v9.8.6") + if err != nil { + t.Fatal(err) + } + fakeDir := t.TempDir() + marker := filepath.Join(fakeDir, "created") + count := filepath.Join(fakeDir, "count") + script := filepath.Join(fakeDir, "gh") + scriptBody := `#!/bin/sh +if [ "$1" = "auth" ]; then exit 0; fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + if [ -f "$BOATSTACK_UPDATE_CREATED" ]; then echo "https://github.com/example/repo/pull/43"; exit 0; fi + echo "no pull requests found for branch" >&2; exit 1 +fi +if [ "$1" = "pr" ] && [ "$2" = "create" ]; then + touch "$BOATSTACK_UPDATE_CREATED" + echo x >> "$BOATSTACK_UPDATE_COUNT" + echo "connection closed after request" >&2 + exit 1 +fi +if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then exit 0; fi +exit 1 +` + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BOATSTACK_UPDATE_CREATED", marker) + t.Setenv("BOATSTACK_UPDATE_COUNT", count) + if _, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint}); err == nil { + t.Fatal("interrupted GitHub response unexpectedly reported success") + } + url, err := PublishUpdatePublication(UpdatePublishOptions{Repo: repo, PreviewPath: preview.PreviewPath, ExpectedFingerprint: preview.Fingerprint}) + if err != nil || url != "https://github.com/example/repo/pull/43" { + t.Fatalf("reconciliation did not recover observed PR: %s %v", url, err) + } + value, err := os.ReadFile(count) + if err != nil || strings.Count(string(value), "x") != 1 { + t.Fatalf("publication was duplicated: %q %v", value, err) + } +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 600049f..8b20199 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 **13297 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 **14065 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 [`a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9`](https://github.com/operatorstack/intelligence-flow/tree/a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9/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 [`3fc5f6eb68c22007a46434b4af9dacc31dcc04ed`](https://github.com/operatorstack/intelligence-flow/tree/3fc5f6eb68c22007a46434b4af9dacc31dcc04ed/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/generated-files.md b/docs/generated-files.md index bbce3fd..1a5b473 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -54,7 +54,7 @@ One verified runtime is cached under the clone's Git common directory and keyed Independent clones do not share a Git common directory. Committed adapters survive a clone, but the ignored helper and repository-family cache do not; run the installer once in the new clone. -For an update, run `/boatstack-update` from a clean, current default branch. Boatstack creates `chore/update-boatstack-v`, verifies the tagged release and checksum, preserves integrations, and shows the exact generated diff before asking for `open update PR`. Release-check state in `.product-loop/bin/update-state.json` and the platform helper remain ignored; the adapters, generated lock, hook fragments, and merged host settings belong in the update PR. +For an update, run `/boatstack-update` from a clean, current default branch. Boatstack creates `chore/update-boatstack-v`, verifies the tagged release and checksum, preserves integrations, and stores a fingerprinted non-empty update-PR preview under Git-common Boatstack state before asking for `o`. `publish-update-pr` owns the exact commit, normal push, and single-PR reconciliation. Release-check state in `.product-loop/bin/update-state.json`, operation receipts under Git-common `boatstack/operations/v1`, the update preview, and the platform helper remain ignored; the adapters, generated lock, hook fragments, and merged host settings belong in the update PR. An update refuses feature branches, dirty worktrees, stale default branches, changed generated files, and user-owned collisions. It never merges its own PR. diff --git a/docs/getting-started.md b/docs/getting-started.md index 29a4251..63e68a8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,7 +18,7 @@ Ask Boatstack for the next verified stage without changing anything: Boatstack reads repository-owned plans, approvals, delivery state, and gate receipts, then returns exactly one next action. Chat, terminal, worktree, and running-process observations may add context but never establish a workflow stage. If no managed work remains, Boatstack reports **Feature complete** and **No action required**. -For a small verified feature, `/boatstack-run` in Cursor or Claude Code and `$boatstack run` in Codex drive every declared delivery slice through PR publication. The command fetches `origin` before mutation and pauses for required plan approval, product decisions, and the exact PR open/update confirmation. Merge and deploy remain separate. +For a small verified feature, `/boatstack-run` in Cursor or Claude Code and `$boatstack run` in Codex drive every declared delivery slice through PR publication. The command fetches `origin` before mutation and pauses for required plan approval, product decisions, and the exact PR open/update confirmation. Managed side effects use durable attempt receipts, so a lost response or new chat resumes or reconciles the same work instead of repeating it. Merge and deploy remain separate. ## 1. Install it separately @@ -50,7 +50,7 @@ Review and commit the paths printed by the installer. Merge this infrastructure The installer keeps a versioned, verified runtime under Git's common directory. A linked worktree still starts without the ignored `.product-loop/bin/` directory, but its first guarded Cursor, Codex, or Claude call restores that local runtime automatically before evaluating the original command. This performs no download and changes no tracked files. -Host activation is separate from runtime installation. Codex requires the exact linked-worktree project path and hook definition to be reviewed and trusted through `/hooks`; start a new task after trusting it. Claude Code requires Bash and exposes the active `PreToolUse` hook through `/hooks`. Cursor requires a window reload and enabled `beforeShellExecution` and `beforeMCPExecution` hooks. +Host activation is separate from runtime installation. Codex requires the exact linked-worktree project path and pre/post tool hook definitions to be reviewed and trusted through `/hooks`; start a new task after trusting them. Claude Code requires Bash and exposes `PreToolUse`, `PostToolUse`, and failure observation through `/hooks`. Cursor requires a window reload and enabled before/after native, shell, and MCP hooks. Different Boatstack versions use separate cached runtimes, so an older worktree is not silently run with a newer helper. A separate clone has a different Git common directory and still needs one installer run. diff --git a/docs/public-claims.json b/docs/public-claims.json index 66daefb..affa7f1 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", + "source_commit": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" }, { "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:a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9" + "last_verified_version": "source:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index faa2429..72453dd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -127,6 +127,16 @@ Release discovery uses a short, unauthenticated request to GitHub and a 24-hour Boatstack found an installed generated file that no longer matches its previous lock. Review the named path and move durable project-owned content into `.boatstack-project.json` or repository documentation. Do not overwrite the drift merely to make the update pass. +## A tool call repeats or publication appears stuck + +Run `.product-loop/bin/boatstack-helper operation-status --repo . --json`. `EXECUTING` means the exact call already has a live lease, so wait instead of launching it again. `RECONCILE_REQUIRED` means Boatstack did not observe completion; verify the reported Git, GitHub, file, browser, or MCP postcondition before retrying. A successful operation whose response was lost is recovered from that observation. Do not reset the task, repeat a denied push, or open another PR. + +Operation receipts are shared by linked worktrees and retry budgets survive new chats and host restarts. If more than one unfinished operation matches, rerun status with the reported operation ID rather than choosing the newest. The receipts contain fingerprints and secret-free observations; no command payload or credential should be added to them. + +## An update PR response was interrupted + +Keep the update branch and rerun the Boatstack update publication step with the same displayed preview fingerprint. The deterministic publisher queries the exact head branch first and returns the existing PR when GitHub accepted the earlier request. If the update diff changed, regenerate and review the preview; never bypass it with a direct push or `gh pr create`. + ## The PR preview is stale A new commit, changed evidence, changed approval artifact, or base-branch update invalidated the preview. Ask Boatstack to regenerate it. Do not copy the old body forward. diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index aa46b84..27c859f 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": "a063adb7e10c577e95906f6cc1bb2dd7d3b99ed9", + "source_commit": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", "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-durable-operation-supervision.md b/release-notes/2026-07-22-durable-operation-supervision.md new file mode 100644 index 0000000..1a32576 --- /dev/null +++ b/release-notes/2026-07-22-durable-operation-supervision.md @@ -0,0 +1,12 @@ +### Resume side effects without duplicate retries + +Boatstack now supervises managed mutations and publication attempts with durable, +fingerprinted operation receipts shared across linked worktrees. Host pre/post tool +events create and complete single-use leases; interrupted or unknown external +effects require reconciliation before a bounded retry. PR and Boatstack-update +publication recover an already-created pull request instead of opening a duplicate, +and `boatstack-run` uses the delivery's persistent three-cycle repair budget across +chats, host restarts, and async notifications. Human plan and publication approvals +remain required under their existing defaults. Machine-parsed subprocess results now +use stdout only; bounded stderr diagnostics can never become paths, refs, URLs, or +workflow authority.