From f92b37cfcd38dfaea3387d9122e723a9e1156405 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 26 Jul 2026 14:52:06 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ e960b4ccd592 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 62 +-- boatstack/activation.go | 158 +++++++ boatstack/attach.go | 229 ++++++++++ boatstack/capture.go | 2 +- .../coverage_conformance_test.go | 7 + boatstack/cmd/boatstack-helper/main.go | 154 ++++++- boatstack/context.go | 75 ++++ boatstack/delivery.go | 12 +- boatstack/detached.go | 305 ++++++++++++++ boatstack/detached_test.go | 390 ++++++++++++++++++ boatstack/flow_control.go | 2 +- boatstack/flow_trace.go | 7 +- boatstack/init.go | 4 +- boatstack/installation_repair.go | 2 +- boatstack/next.go | 4 +- boatstack/operation.go | 6 +- boatstack/paths.go | 230 +++++++++++ boatstack/plan.go | 2 +- boatstack/plan_validation.go | 2 +- boatstack/planning.go | 4 +- boatstack/pr.go | 6 +- boatstack/provision.go | 6 +- boatstack/recovery.go | 4 +- boatstack/run.go | 5 +- boatstack/runtime_cache.go | 16 +- boatstack/safety.go | 32 +- boatstack/update_publication.go | 2 +- boatstack/visual_publisher.go | 2 +- boatstack/workspace.go | 12 +- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- .../2026-07-26-detached-context-and-guard.md | 31 ++ .../2026-07-26-detached-supervision.md | 35 ++ 35 files changed, 1726 insertions(+), 112 deletions(-) create mode 100644 boatstack/activation.go create mode 100644 boatstack/attach.go create mode 100644 boatstack/context.go create mode 100644 boatstack/detached.go create mode 100644 boatstack/detached_test.go create mode 100644 boatstack/paths.go create mode 100644 release-notes/2026-07-26-detached-context-and-guard.md create mode 100644 release-notes/2026-07-26-detached-supervision.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b48e658..9cc8021 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/64d586468baf5a7b45a2debbfc747b0d9ec9a233/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/e960b4ccd5929bac729d10ea9a800bad3dc572fd/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 5256e34..b03d6f3 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "2c757dbb0038f032fe541d105efc60c0d34378aec0bf2dd87ac42c738495ce34", + "CONTRIBUTING.md": "c99b0fec066ec2218915911b89f37fe5382ca66f6a987d858b137be3273a9e62", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -20,6 +20,7 @@ "boatstack/AGENTS.md": "bc76221e1fe90a91afbacd7c6bc9b41a70e6c10fc128c275a6a0b9bc094d9506", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", "boatstack/SKILL.md": "b393fe00f23f701e1310d7c1006f339935d3082c7adb9b35c038a3ad1bcc459e", + "boatstack/activation.go": "8d03042a0105ad4b727f6f492e051618c27f05e8e48248b0b3eaae4c925f79a3", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", @@ -34,23 +35,25 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", + "boatstack/attach.go": "f4df8697c804cfa5249830a529a6aa36acbf043bf24bdf62327920af48c84ee5", "boatstack/capability.go": "9d9a75086e88bb1d9d4170821436c686002fe3a125e6ee7d23eea5779aac5cfe", "boatstack/capability_test.go": "e8322903a843970d7f0317d2629466532cb05c3ffcb44b9423adf4219faa8021", - "boatstack/capture.go": "e32dd7096ccc4844d37c9ec0aea8284adee58bc84cd5b6264516b6661b348bfc", + "boatstack/capture.go": "6a279bab615a012de0b2c43aeae9b95d122365068dc54c8923c14c6a4c719449", "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "d7802c45129b3a14921120a0806d598a26ef052442539d789579eb13da16b7d6", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "c634d99797e20c71416f5d5a3f43d011441e9e0b65b8f1c0bed3e75a0b9a1832", "boatstack/cmd/boatstack-helper/flow.go": "5ab24541d3f85c2f442730d3122d18eb6676600bc11fde4805e803a25a8300c7", - "boatstack/cmd/boatstack-helper/main.go": "b3983742991ed193b6e13d9a84192bf4bfdc7536a8bd6b022255ca5ba4124982", + "boatstack/cmd/boatstack-helper/main.go": "541201edfe27ce70c3f8aadc0c8ffaf264b1c2b5cca0490dad59da390a22715f", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/compiled_artifact_resolution_test.go": "0748d67643263e698211eb04d46464e1dd3db15d94537f5fd5092b5aa689745b", "boatstack/config_documentation_test.go": "aaecea04ee178ecf7872fff23a64014a3968dce1e7624e61a9453567e99969a9", + "boatstack/context.go": "02510af176d2d040c0080086f06d1235e76a1d47fef5176f96f740ad18d27660", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "86150374b14982b1e589714d6ef6230348ef57b6824d4e1552b72289c044af4b", + "boatstack/delivery.go": "8a4dd6b7dd553e9544879b0489ef51231f2c16ae49dfb98b526bb07fec2b6e96", "boatstack/delivery_boundary_conformance_test.go": "c374eddf49b4597db78c0621f65f87199de9a26f1872ed88d9d790edf21fe3f2", "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", @@ -59,13 +62,15 @@ "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", "boatstack/denial.go": "656dc5e71a11daba25e7a23599f2e1aafa7a7439589af943cdccc72154a45816", "boatstack/denial_test.go": "c480c0b2a489838b22b4d5ec20cc30ef1859cc0820a75974bc56a46c31f90041", + "boatstack/detached.go": "b0ea2a1f31bf2a2a83f6089a3065a9b47221194b64de48af9120046e6d70dee8", + "boatstack/detached_test.go": "4665a210acd0f5a5c480eb14ca83052c707244a278a9cbc2349bd107d010eeb1", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "1a01d19ac6e8418febf93f9ebf1a466a46da4b09eea7bafe6ccfb6cfb0f73a6d", "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "58e721c4704d260511eaf05b190b8fd97914931e3b74ccd896c88fc2564564a3", + "boatstack/flow_control.go": "23b539c0d2dfcabb4606b94abeee1b25754cd9627c3c39f08ddc3c49fed81615", "boatstack/flow_control_test.go": "d52e095f2e0f18067abe03e3b5f7c98bc30f8b1c8f5230103797c599aec95013", "boatstack/flow_drive.go": "a501ceda390dfd3605e22cf7ecfa15f9d50240b3fac6ebb2bb2d80c615d0a9fc", "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", @@ -76,7 +81,7 @@ "boatstack/flow_report_test.go": "eae00f2b8ead4f1ec20e1f1bc47db4c53a36048bb84eca9bea4f1a2105bdcdde", "boatstack/flow_tasks.go": "690db05d345dabdb24965015c94198aa3f93d2d9691599ae8e6a2ac3aafb9d44", "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", - "boatstack/flow_trace.go": "95b5a99f5f557a27a3eca7152de9ec18459f2a88d9749924432463031536a96c", + "boatstack/flow_trace.go": "1a69f9dd53db313235c74f7ae4f821a6aed023f780aea57210c721ea8f11f2fc", "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", @@ -84,10 +89,10 @@ "boatstack/hooks_hydrate_test.go": "7beeb26b2b1398741e8a28963a9686e974047016cc736f233024004add1afc32", "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", "boatstack/hydrate_runtime_test.go": "dbd5eae2ba85701e4af0430ba3a0d70ea98e028b66992bd4fc05f3f582398627", - "boatstack/init.go": "1b2721ad64dcfba3e954b97bb46218238873f46ec29fc4dac327aa99980f9cf6", + "boatstack/init.go": "7caef2bb2e40cd75a9e940b04deef242812f5f971332b86b00739662435bcae3", "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", - "boatstack/installation_repair.go": "b887f27023aff46636b0e38d16e18e3ab9a7f5c94079b2c152aecee4edc0eae5", + "boatstack/installation_repair.go": "c9698e134094873642a60300342b3d1c2be30dc86b15dc73aa859605e83d2019", "boatstack/installation_repair_test.go": "ae5a5ea1110836bd78cf20ade863a4d32cfd63d282559f92786f57b31869bd14", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/internal/deliverycontrol/advise.go": "4f3a53a785a34f6c34858236a57d4114091141a463b51e5aaefae3336557ae5a", @@ -115,25 +120,26 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "f11e1b5c93a362438663a663e126ea75fd1042453c907fc335fee543bf76c43d", + "boatstack/next.go": "47e01558a04922a9743ac63ee934945906e17efde54ba3b163721d5f8fbc71e8", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", - "boatstack/operation.go": "35142d24e166bc400229d233757b6ab185f94b01a3c11be49da33b7f123189a2", + "boatstack/operation.go": "073113e1e7b6349417e70b704bd1a342b460604cbd97a7fab06b1a6494604112", "boatstack/operation_test.go": "59d3dc37319aa4d334c0cacbe886e2f757842e6a28dee8781448e528fecbde11", - "boatstack/plan.go": "7209de3a97b134cd6e5224cf6e798b5af47b0a4163ae2f81a8ec0c1ff84031d6", + "boatstack/paths.go": "bc14901f9497bfa87f64eab80ff30cc7c3a31781e3fdb60826df2dc21eb2253b", + "boatstack/plan.go": "90b48262863e7733b669c4916bf713c9567681d5c080503050f10f348d3ce69d", "boatstack/plan_test.go": "53477515165a910910b9175bfa33574548cf0d0f3be48e175ec3a775a12d30bb", - "boatstack/plan_validation.go": "412f06750832fe46f01190ea5e475fc6f6ea59c8ba78131f94ec031053a405d2", + "boatstack/plan_validation.go": "99e40806fd579ff72de53f391cd6124acc9ff18707ecf9676c5e507b738d87d0", "boatstack/plan_validation_test.go": "6cbde4ac719baef6b73aa569515d6a9daadcbf14b33f76fa78159826954e20fa", - "boatstack/planning.go": "6dfeb802a7cb615f159d87c86f338eab0897bda836417b07f8307695373c78ff", + "boatstack/planning.go": "d33b661f448d5c009454f012f16e3dd3daa6756d26b855c93d3066b1aaf923c8", "boatstack/planning_test.go": "c105a9c78c342be06614bf54d0bc1b661b0f7af64d63b79e43bd1fcc2769edd5", - "boatstack/pr.go": "eae93f7a6e423f67336545d37181ec2ea350991a2d75985f1cf2cf4b7bc8382d", + "boatstack/pr.go": "b6df3e000dd6d34ecb6575385e44b2f6ee84a8f35ad5696e299177a7cddd7629", "boatstack/pr_test.go": "7f82954d94c1ceae848a581dda25e58af92251d78a5a94ed2d672bedf5a0349e", "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", - "boatstack/provision.go": "4882d49681f99b11ba9d182ca13772131b7f9a11a6c2b560800654ca14f5111e", + "boatstack/provision.go": "eb7333a73331b011adc93f59a2d97415d850c2e588f0e2bbb5116984e2ef927d", "boatstack/provision_test.go": "214e9edb991a66d5bbb696a7c1b63876d2f799f2cab4e3f40785f4e8f1eac57b", "boatstack/publication_ignored_repro_test.go": "b6f3aeb8ba22949ff9af7ac5afe8fb828385d9708d5d5893ef41f33a3de873e1", "boatstack/published_slice_routing_test.go": "ea7e7351018bc13dcd31c4b96f50f8bc230e8a1dbf7806fba32a12ae58923e7e", - "boatstack/recovery.go": "e45b3b3c2cda85c2b887fae32ee46ad205f1f3f707e6dc7b46f68ef6746ab5aa", + "boatstack/recovery.go": "43a87bf4453f5533d1e3ed97f63bc3f8f1cbe95dee27e3480a63c1b6f72a8870", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", "boatstack/reexec.go": "fed55416479d7bd3e0c3637057ffe8eb58a032f93fc358f76df906ab7acc677b", "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", @@ -148,13 +154,13 @@ "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", - "boatstack/run.go": "74967ad5b3ed3847baffec1231aae69a81a70f9cce3a9412fa05bcfdc4eca6d1", + "boatstack/run.go": "3127e8c054b80e41413011e423ba9463fe06a7e6c9e1e71750ab98587871d89d", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", "boatstack/runtime.go": "11f741d45994c336e927121ede2481c94d5c33ed9c3beecf89f760450b304da3", - "boatstack/runtime_cache.go": "89834409b426dce292fd810a091de1433fefbfba9249d8c1e31ccc40f0d5dbd6", + "boatstack/runtime_cache.go": "1c293b190dcd1dece83681f383d55c8acf44c68fb10b2f4312175396bb89926a", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "f8a6b5426893b021c8dd5079432e541150136c65584feb3404afa6f7ff4aa52f", + "boatstack/safety.go": "15ce84911ad4b24e054f4e56ba783613c74d21c2cb136d63585f43dd95a94dbf", "boatstack/safety_test.go": "2741610de4b8a47d66b1a5f18a028ab63417826789636587745b4e71dd2d59c3", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", @@ -164,14 +170,14 @@ "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", "boatstack/update.go": "042c7e8141423e2cdb71c92f93cf73cc81d916116d76ab9928c56cf18bc6827a", - "boatstack/update_publication.go": "7cba2e993700190bcc7c3c1cb1a5fe4df84b2053f35bb4fa5e4b0673f397bca2", + "boatstack/update_publication.go": "5c1ac8445345c6546d165b9321a736453b313ced96a0059465b8ad637498bac6", "boatstack/update_publication_test.go": "c5f32578db53be65e35452d5e8b4520884354e4e80a370dcec19860ea644d091", "boatstack/update_test.go": "bf5f19f8499db6dd7356917d867b113b790548ab89bfdea59e2adf4999a82a6a", "boatstack/visual_evidence.go": "4d69f98adb6087d4830e6703273ae6e03b28ec8b3c496a6e432fd5e0e54d8a2f", "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", - "boatstack/visual_publisher.go": "330511d000e712fa37c52af17d59c8ec9cb41f49c773a517f856651e66a60d25", + "boatstack/visual_publisher.go": "ec5e95228b48e4ec20731975606048f5e732c4e09a7fd175770d4b15e447cc88", "boatstack/visual_publisher_test.go": "979f600edae00c77569ae753b80530e8cbf2e3b342995efcd992d351ff382eaf", - "boatstack/workspace.go": "c21f312a8b631b5cf65de397e24e1b9c7197ebcb73e5c26804e36f136f84fdbb", + "boatstack/workspace.go": "0d3bb9aedbbeac8ff57a6aa33dcc3671d1055e2caa6949b439d57b0b283c431f", "boatstack/workspace_reap_test.go": "a08f41ee1b2d158efe878082b5fcd65928e723afce4dd19e3a5a8a26b8f10874", "boatstack/workspace_sync.go": "0cc2f03fd1aa57c66b3d603d5ef30aa820f14ab60771a795cf10c46f3c9a71b8", "boatstack/workspace_sync_test.go": "a5fd532d23a6675c96fc5eb29a149c812717f21237a4050ae5f41afb34601273", @@ -180,10 +186,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "b9851a7c40f39f4f799f8576292ae3c15cdfbf935ca1baa28ab24ea8e556665c", + "docs/evidence-engineered-coding.md": "c407f22311678c5b36610dfded112fba22de91b247691b3ea272ec6a731ff678", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "9cee0f39f2389c4a1f6bda2fc4186f143ed8065e99a58623c185eed6326362a3", + "docs/public-claims.json": "f0dfed02023c588febdc55e60c8da47941a01f4c71a849367c01eaa3f3a7f217", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -197,7 +203,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": "db3338f91bb12226693d021033cd9228dc4747a917d5c00341fc4d48e3a77ae8", + "labs/diagram-json/plan.lock.json": "d47f40e8610d59c458848849d0bdedf05cc069ee12d797351542125148457c45", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -302,6 +308,8 @@ "release-notes/2026-07-25-runtime-simplified-technical-english.md": "917fbfb51ae56e5c6e0d9c705b84da492ef3b8f8782ea4b62635814259f11bb4", "release-notes/2026-07-25-update-publish-guard-unblock.md": "adf06ee02b8d3c995525bb9673c2f1fea66a147df8751d65885ced83da0e96e2", "release-notes/2026-07-26-calm-denials.md": "9e4a5fc23b02500cf2f124cb462a8d863ed953a899c9485a81e4971dd89c9576", + "release-notes/2026-07-26-detached-context-and-guard.md": "ed58fc69752bd9b48403e3bdd8e3459fa84a663bc3caa1e8246be3abc3ac6d6c", + "release-notes/2026-07-26-detached-supervision.md": "e8062f5f39e5ad86e9104f20b7b6b1581a95215aff17acbe916e0715e2424b11", "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", "release-notes/2026-07-26-guard-hydrate-double-check.md": "83a5591aba6bf30c9f4008ba8d26bf1994ef3fd61145f46fcdd1678912b9990b", "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a", @@ -310,7 +318,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", + "commit": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/activation.go b/boatstack/activation.go new file mode 100644 index 0000000..5fd4060 --- /dev/null +++ b/boatstack/activation.go @@ -0,0 +1,158 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" +) + +// Detached activation. A detached repository has no in-repo host hook, so the +// developer installs one user-level (developer-scoped) hook per coding agent. That +// hook runs Boatstack's ambient guard, which enforces policy only on attached +// repositories and no-ops everywhere else (RepositoryIsManaged / AmbientHookDecision). +// +// activation deliberately does NOT silently rewrite a developer's global host +// configuration. It emits the exact per-host config location and the precise +// Boatstack-owned snippet to add, so activation is transparent and never clobbers +// existing global hooks. The snippet is host-neutral in intent: every supported +// agent gets the same ambient guard, shaped for that agent's hook schema. + +// HostActivation is the activation instruction for one coding agent. +type HostActivation struct { + Host string `json:"host"` + ConfigPath string `json:"config_path"` + Snippet string `json:"snippet"` + Instruction string `json:"instruction"` +} + +// ActivationPlan is the set of per-host activation instructions for a repository. +type ActivationPlan struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Attached bool `json:"attached"` + RepoRoot string `json:"repo_root"` + HelperPath string `json:"helper_path"` + Hosts []HostActivation `json:"hosts"` + Reason string `json:"reason"` +} + +// userHostConfigPath returns the developer-level (not repo-level) config path a +// host reads across all projects. BOATSTACK_USER_CONFIG_ROOT overrides the base for +// tests and for a launcher that keeps host state external. +func userHostConfigPath(host string) (string, error) { + base := os.Getenv("BOATSTACK_USER_CONFIG_ROOT") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = home + } + switch host { + case "cursor": + return filepath.Join(base, ".cursor", "hooks.json"), nil + case "claude": + return filepath.Join(base, ".claude", "settings.json"), nil + case "codex": + return filepath.Join(base, ".codex", "hooks.json"), nil + case "gemini": + return filepath.Join(base, ".gemini", "settings.json"), nil + default: + return "", fmt.Errorf("unsupported host %q", host) + } +} + +// ambientHookCommand builds the shell command a user-level hook runs: the absolute +// helper binary invoking the ambient guard for the current repository. claude +// exposes the project directory as ${CLAUDE_PROJECT_DIR}; the others resolve it +// from Git at hook time. +func ambientHookCommand(host, helper string) string { + if host == "claude" { + return fmt.Sprintf(`%q ambient-safety-hook --host claude --repo "${CLAUDE_PROJECT_DIR}"`, helper) + } + return fmt.Sprintf(`%q ambient-safety-hook --host %s --repo "$(git rev-parse --show-toplevel)"`, helper, host) +} + +// ambientHostFragment shapes the ambient guard into a host's hook schema, reusing +// the embedded entry shape and overriding only the command so the guard runs from +// the external helper rather than an in-repo guard script. +func ambientHostFragment(host, helper string) ([]byte, error) { + command := ambientHookCommand(host, helper) + events := map[string]any{} + for _, event := range hookEvents(host) { + entry := desiredHostHookForEvent(host, event) + overrideHookCommand(entry, command) + events[event] = entry + } + return GeneratedJSON(map[string]any{"schema_version": 1, "host": host, "scope": "user", "events": events}) +} + +// overrideHookCommand replaces the command in a desired-hook entry (both the flat +// cursor form and the nested hooks[] form) with the ambient command. +func overrideHookCommand(entry map[string]any, command string) { + if _, ok := entry["command"]; ok { + entry["command"] = command + delete(entry, "commandWindows") + } + if nested, ok := entry["hooks"].([]any); ok { + for _, item := range nested { + if hook, ok := item.(map[string]any); ok { + hook["command"] = command + delete(hook, "commandWindows") + } + } + } +} + +// DetachedActivationPlan returns the per-host activation instructions for a +// repository. It is read-only. It requires the repository to be attached in +// detached mode (an unattached repository has nothing to activate). +func DetachedActivationPlan(repoPath string, hosts []string) (ActivationPlan, error) { + root, err := ResolveRepository(repoPath) + if err != nil { + return ActivationPlan{}, err + } + plan := ActivationPlan{SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), RepoRoot: root} + ctx, ok, verifyErr := detachedContextFor(root) + if verifyErr != nil { + plan.Mode = string(SupervisionDetached) + plan.Attached = true + plan.Reason = verifyErr.Error() + return plan, nil + } + if !ok { + plan.Reason = "This repository is not attached in detached mode. Run `boatstack-helper attach --repo . --mode detached` first." + return plan, nil + } + plan.Mode = string(SupervisionDetached) + plan.Attached = true + _ = ctx + + helper, err := os.Executable() + if err != nil || helper == "" { + helper = "boatstack-helper" + } + plan.HelperPath = helper + + if len(hosts) == 0 { + hosts = []string{"cursor", "claude", "codex", "gemini"} + } + for _, host := range hosts { + configPath, pathErr := userHostConfigPath(host) + if pathErr != nil { + continue + } + snippet, fragErr := ambientHostFragment(host, helper) + if fragErr != nil { + return ActivationPlan{}, fragErr + } + plan.Hosts = append(plan.Hosts, HostActivation{ + Host: host, + ConfigPath: configPath, + Snippet: string(snippet), + Instruction: fmt.Sprintf("Merge the Boatstack ambient guard for %s into %s (developer-level, applies to every repository; it enforces Boatstack only on attached repositories).", host, configPath), + }) + } + plan.Reason = "Add the developer-level ambient guard for each coding agent you use. It no-ops on repositories you have not attached." + return plan, nil +} diff --git a/boatstack/attach.go b/boatstack/attach.go new file mode 100644 index 0000000..a8fca0a --- /dev/null +++ b/boatstack/attach.go @@ -0,0 +1,229 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" +) + +// Attach, detach, and status operations for Detached Supervision. Attaching a +// repository writes Boatstack's controller state to an external control root and a +// binding that identifies the repository; it never writes into the target working +// tree or its Git directory. Detaching removes the attachment (and, unless asked +// to preserve it, the external state). Status reports the binding and verifies it. + +// AttachOptions requests a detached attachment. StateRoot, when set, overrides the +// external control-state root for this process (the CLI wires --state-root to it). +type AttachOptions struct { + Repo string + Force bool +} + +// AttachResult is the deterministic outcome of an attach request. +type AttachResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED + Mode string `json:"mode,omitempty"` + RepoID string `json:"repo_id,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + ControlRoot string `json:"control_root,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + Reason string `json:"reason"` +} + +func blockedAttach(reason string) AttachResult { + return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} +} + +// AttachDetached attaches repo in detached mode. It leaves the repository working +// tree and Git directory byte-for-byte unchanged; all controller state is written +// under the external control root. +func AttachDetached(opts AttachOptions) (AttachResult, error) { + root, err := ResolveRepository(opts.Repo) + if err != nil { + return blockedAttach(err.Error()), nil + } + stateRoot, err := detachedStateRoot() + if err != nil { + return blockedAttach(err.Error()), nil + } + identity, err := repoIdentity(root) + if err != nil { + return blockedAttach("Boatstack could not compute a repository identity: " + err.Error()), nil + } + + registry, err := loadRegistry(stateRoot) + if err != nil { + return blockedAttach("Boatstack could not read the attachment registry: " + err.Error()), nil + } + if existing, ok := registry.Repositories[root]; ok && !opts.Force { + return blockedAttach(fmt.Sprintf("This repository is already attached (repo_id %s). Detach first, or re-run with --force.", existing)), nil + } + + ctx := detachedContextFromIdentity(stateRoot, identity) + + // Synthesize configuration from the repository (test command, default branch, + // context) exactly as embedded init does. + config := defaultConfig(root, detectTestCommand(root)) + rawConfig, err := MarshalJSON(config) + if err != nil { + return blockedAttach(err.Error()), nil + } + + // Generate the controller bundle and write it under the external control root. + // The bundle layout mirrors embedded (.product-loop/** plus host adapter dirs), + // only relocated outside the repository. + bundle, err := BuildExportBundle(ctx.SourceConfigPath(), config, rawConfig, "boatstack") + if err != nil { + return blockedAttach("Boatstack could not build the controller bundle: " + err.Error()), nil + } + if err := os.MkdirAll(ctx.controlRoot, 0o755); err != nil { + return blockedAttach(err.Error()), nil + } + if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil { + return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil + } + if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { + return blockedAttach(err.Error()), nil + } + + // Write the binding and index it in the registry. + binding := DetachedBinding{ + SchemaVersion: detachedSchemaVersion, + Mode: string(SupervisionDetached), + RepoID: identity.RepoID, + CanonicalRepoPath: identity.CanonicalRepoPath, + GitCommonIdentity: identity.GitCommonIdentity, + InitialCommit: identity.InitialCommit, + NormalizedOrigin: identity.NormalizedOrigin, + CreatedByVersion: Version, + CreatedAt: nowRFC3339(), + } + bindingRaw, err := MarshalJSON(binding) + if err != nil { + return blockedAttach(err.Error()), nil + } + if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil { + return blockedAttach(err.Error()), nil + } + if err := os.WriteFile(bindingPath(stateRoot, identity.RepoID), bindingRaw, 0o644); err != nil { + return blockedAttach(err.Error()), nil + } + registry.Repositories[root] = identity.RepoID + if err := saveRegistry(stateRoot, registry); err != nil { + return blockedAttach(err.Error()), nil + } + invalidateWorkspaceCache() + + return AttachResult{ + SchemaVersion: detachedSchemaVersion, + VerificationStatus: "VERIFIED", + Mode: string(SupervisionDetached), + RepoID: identity.RepoID, + RepoRoot: root, + ControlRoot: ctx.controlRoot, + WorktreeID: identity.WorktreeID, + Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.", + }, nil +} + +// DetachOptions requests removal of a detached attachment. +type DetachOptions struct { + Repo string + PreserveState bool +} + +// DetachResult is the deterministic outcome of a detach request. +type DetachResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | BLOCKED + RepoID string `json:"repo_id,omitempty"` + StateRemoved bool `json:"state_removed"` + Reason string `json:"reason"` +} + +// DetachDetached removes a repository's detached attachment. It always removes the +// registry entry; it removes the external controller state only when PreserveState +// is false. It never touches the repository itself. +func DetachDetached(opts DetachOptions) (DetachResult, error) { + root, err := ResolveRepository(opts.Repo) + if err != nil { + return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil + } + stateRoot, err := detachedStateRoot() + if err != nil { + return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil + } + registry, err := loadRegistry(stateRoot) + if err != nil { + return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: err.Error()}, nil + } + repoID, ok := registry.Repositories[root] + if !ok { + return DetachResult{ + SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", + Reason: "This repository is not attached in detached mode; nothing to detach.", + }, nil + } + delete(registry.Repositories, root) + if err := saveRegistry(stateRoot, registry); err != nil { + return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil + } + stateRemoved := false + if !opts.PreserveState { + if err := os.RemoveAll(repositoryControlRoot(stateRoot, repoID)); err != nil { + return DetachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", RepoID: repoID, Reason: err.Error()}, nil + } + stateRemoved = true + } + invalidateWorkspaceCache() + reason := "Detached Boatstack. The external controller state was removed." + if opts.PreserveState { + reason = "Detached Boatstack. The external controller state was preserved." + } + return DetachResult{ + SchemaVersion: detachedSchemaVersion, VerificationStatus: "VERIFIED", + RepoID: repoID, StateRemoved: stateRemoved, Reason: reason, + }, nil +} + +// DetachedStatusResult reports whether a repository is attached in detached mode +// and whether its binding verifies. +type DetachedStatusResult struct { + SchemaVersion int `json:"schema_version"` + Attached bool `json:"attached"` + Verified bool `json:"verified"` + Mode string `json:"mode"` + RepoID string `json:"repo_id,omitempty"` + RepoRoot string `json:"repo_root,omitempty"` + ControlRoot string `json:"control_root,omitempty"` + WorktreeID string `json:"worktree_id,omitempty"` + Reason string `json:"reason"` +} + +// DetachedStatus reports the detached attachment state for a repository. It is +// read-only. +func DetachedStatus(repoPath string) (DetachedStatusResult, error) { + root, err := ResolveRepository(repoPath) + if err != nil { + return DetachedStatusResult{SchemaVersion: detachedSchemaVersion, Reason: err.Error()}, nil + } + ctx, ok, verifyErr := detachedContextFor(root) + if !ok { + return DetachedStatusResult{ + SchemaVersion: detachedSchemaVersion, Attached: false, Mode: string(SupervisionEmbedded), + RepoRoot: root, Reason: "This repository is not attached in detached mode.", + }, nil + } + if verifyErr != nil { + return DetachedStatusResult{ + SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), + RepoRoot: root, Reason: verifyErr.Error(), + }, nil + } + return DetachedStatusResult{ + SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached), + RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, + Reason: "This repository is attached in detached mode and its binding verifies.", + }, nil +} diff --git a/boatstack/capture.go b/boatstack/capture.go index 4fb5148..8572036 100644 --- a/boatstack/capture.go +++ b/boatstack/capture.go @@ -92,7 +92,7 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, if feature == "" { return PRVisualEvidenceManifest{}, fmt.Errorf("capture requires a managed --feature") } - config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return PRVisualEvidenceManifest{}, fmt.Errorf("capture requires a valid Boatstack project configuration: %w", err) } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index b80e728..ac5a792 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -65,8 +65,15 @@ var nonDeliveryVerbs = map[string]bool{ "record-pr-visual-publication": true, // PR construction / verification helpers reached around the ship gate. "check-pr": true, + // Detached Supervision lifecycle (control-plane ownership, not delivery moves). + "attach": true, + "detach": true, + "detached-status": true, + "context": true, + "activate": true, // Safety hooks and workspace management (guard/scaffold, not delivery moves). "safety-hook": true, + "ambient-safety-hook": true, "bootstrap-safety-hook": true, "workspace-cut": true, "workspace-cleanup": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 740918e..7111d0a 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "os" - "path/filepath" "sort" "strings" "time" @@ -78,6 +77,119 @@ func updateCommand(arguments []string) int { return 0 } +func emitJSON(value any) int { + raw, err := boatstack.MarshalJSON(value) + if err != nil { + return fail(err) + } + fmt.Print(string(raw)) + return 0 +} + +func applyStateRoot(override string) { + if strings.TrimSpace(override) != "" { + _ = os.Setenv("BOATSTACK_STATE_ROOT", override) + } +} + +func attachCommand(arguments []string) int { + flags := flag.NewFlagSet("attach", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to attach") + mode := flags.String("mode", "detached", "supervision mode; only \"detached\" is supported by attach") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + force := flags.Bool("force", false, "re-attach even if the repository is already attached") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *mode != "detached" { + return fail(fmt.Errorf("attach supports only --mode detached")) + } + applyStateRoot(*stateRoot) + result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, Force: *force}) + if err != nil { + return fail(err) + } + code := emitJSON(result) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return code +} + +func detachCommand(arguments []string) int { + flags := flag.NewFlagSet("detach", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to detach") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + preserve := flags.Bool("preserve-state", false, "keep the external controller state instead of removing it") + if err := flags.Parse(arguments); err != nil { + return 2 + } + applyStateRoot(*stateRoot) + result, err := boatstack.DetachDetached(boatstack.DetachOptions{Repo: *repo, PreserveState: *preserve}) + if err != nil { + return fail(err) + } + code := emitJSON(result) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return code +} + +func detachedStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("detached-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to inspect") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + _ = flags.Bool("json", true, "emit the versioned JSON projection (always JSON)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + applyStateRoot(*stateRoot) + result, err := boatstack.DetachedStatus(*repo) + if err != nil { + return fail(err) + } + return emitJSON(result) +} + +func activateCommand(arguments []string) int { + flags := flag.NewFlagSet("activate", flag.ContinueOnError) + repo := flags.String("repo", ".", "attached repository to produce activation instructions for") + host := flags.String("host", "", "limit to one coding agent (cursor|claude|codex|gemini); default all") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + applyStateRoot(*stateRoot) + var hosts []string + if strings.TrimSpace(*host) != "" { + hosts = []string{*host} + } + result, err := boatstack.DetachedActivationPlan(*repo, hosts) + if err != nil { + return fail(err) + } + return emitJSON(result) +} + +func contextCommand(arguments []string) int { + flags := flag.NewFlagSet("context", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to project context for") + operation := flags.String("operation", "", "the operation about to run (advisory)") + host := flags.String("host", "", "the coding-agent host (advisory)") + stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + _ = flags.Bool("json", true, "emit the versioned JSON projection (always JSON)") + if err := flags.Parse(arguments); err != nil { + return 2 + } + applyStateRoot(*stateRoot) + result, err := boatstack.ProjectOperatorContext(*repo, *operation, *host) + if err != nil { + return fail(err) + } + return emitJSON(result) +} + func repairStatusCommand(arguments []string) int { flags := flag.NewFlagSet("repair-status", flag.ContinueOnError) repo := flags.String("repo", ".", "repository installation to inspect") @@ -86,7 +198,7 @@ func repairStatusCommand(arguments []string) int { if err := flags.Parse(arguments); err != nil { return 2 } - config, _, err := boatstack.LoadConfig(filepath.Join(*repo, ".boatstack-project.json")) + config, _, err := boatstack.LoadConfig(boatstack.WorkspaceFor(*repo).SourceConfigPath()) if err != nil { return fail(err) } @@ -925,6 +1037,28 @@ func safetyHookCommand(arguments []string) int { return 0 } +// ambientSafetyHookCommand is the guard entry for a developer-level (user-scoped) +// hook that runs for every repository. It enforces Boatstack only on managed +// repositories and no-ops everywhere else, so detached activation can install one +// user-level hook without controlling unattached repositories. +func ambientSafetyHookCommand(arguments []string) int { + flags := flag.NewFlagSet("ambient-safety-hook", flag.ContinueOnError) + host := flags.String("host", "", "cursor, claude, codex, or gemini") + repo := flags.String("repo", ".", "repository the coding agent is operating in") + if err := flags.Parse(arguments); err != nil { + return 2 + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + input = nil + } + value, _ := boatstack.AmbientHookDecision(boatstack.SafetyHookOptions{Host: *host, Repo: *repo, Input: input}) + if err := emitHookOutput(os.Stdout, *host, value); err != nil { + return failSafetyHook(fmt.Errorf("cannot emit hook decision: %w", err)) + } + return 0 +} + func bootstrapSafetyHookCommand(arguments []string) int { flags := flag.NewFlagSet("bootstrap-safety-hook", flag.ContinueOnError) host := flags.String("host", "", "cursor, claude, or codex") @@ -1000,7 +1134,7 @@ func migrateConfigCommand(arguments []string) int { if err := flags.Parse(arguments); err != nil { return 2 } - configPath := filepath.Join(*repo, ".boatstack-project.json") + configPath := boatstack.WorkspaceFor(*repo).SourceConfigPath() raw, err := os.ReadFile(configPath) if err != nil { report := MigrateConfigReport{ @@ -1259,10 +1393,20 @@ func workspaceSyncCommand(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] { + case "attach": + return attachCommand(os.Args[2:]) + case "detach": + return detachCommand(os.Args[2:]) + case "detached-status": + return detachedStatusCommand(os.Args[2:]) + case "context": + return contextCommand(os.Args[2:]) + case "activate": + return activateCommand(os.Args[2:]) case "init": return initCommand(os.Args[2:]) case "update": @@ -1339,6 +1483,8 @@ func run() int { return renderDenialCommand(os.Args[2:]) case "safety-hook": return safetyHookCommand(os.Args[2:]) + case "ambient-safety-hook": + return ambientSafetyHookCommand(os.Args[2:]) case "bootstrap-safety-hook": return bootstrapSafetyHookCommand(os.Args[2:]) case "hydrate-runtime": diff --git a/boatstack/context.go b/boatstack/context.go new file mode 100644 index 0000000..87522ef --- /dev/null +++ b/boatstack/context.go @@ -0,0 +1,75 @@ +package boatstack + +// Context projection for Detached Supervision. In embedded mode the coding agent +// reads Boatstack's generated references and project.json from the repository; in +// detached mode those files are external, so the agent needs a bounded, read-only +// projection of the current supervisory position for the operation it is running. +// This reuses the authoritative resolver (ResolveNext) and the deterministic +// next-move oracle (NextControl) rather than reconstructing the state machine. + +// OperatorContext is the bounded, read-only view a host needs to run one operation +// under Boatstack supervision, in either ownership mode. +type OperatorContext struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Attached bool `json:"attached"` + RepoRoot string `json:"repo_root"` + ControlRoot string `json:"control_root,omitempty"` + Operation string `json:"operation,omitempty"` + Host string `json:"host,omitempty"` + Feature string `json:"feature,omitempty"` + VerificationStatus string `json:"verification_status"` + ObservedStage string `json:"observed_stage,omitempty"` + ActiveSlice string `json:"active_slice,omitempty"` + NextOperation string `json:"next_operation,omitempty"` + RecommendedCommand string `json:"recommended_command,omitempty"` + RemainingFlowCost int `json:"remaining_flow_cost,omitempty"` + Reason string `json:"reason"` +} + +// ProjectOperatorContext returns the bounded supervisory context for a repository. +// It is read-only. For an attached-but-unverifiable detached repository it reports +// BLOCKED with a bounded recovery action rather than a normal position. +func ProjectOperatorContext(repoPath, operation, host string) (OperatorContext, error) { + root, err := ResolveRepository(repoPath) + if err != nil { + return OperatorContext{}, err + } + out := OperatorContext{ + SchemaVersion: detachedSchemaVersion, Mode: string(SupervisionEmbedded), + RepoRoot: root, Operation: operation, Host: host, + } + + if ctx, ok, verifyErr := detachedContextFor(root); verifyErr != nil { + out.Mode = string(SupervisionDetached) + out.Attached = true + out.VerificationStatus = "BLOCKED" + out.Reason = verifyErr.Error() + " Run `boatstack-helper detached-status --repo .` and reattach." + return out, nil + } else if ok { + out.Mode = string(SupervisionDetached) + out.Attached = true + out.ControlRoot = ctx.controlRoot + } + + next, err := NextControl(root, "") + if err != nil { + return out, nil + } + status, statusErr := ResolveNext(root, "") + if statusErr == nil { + out.Feature = status.Feature + out.VerificationStatus = status.VerificationStatus + out.ObservedStage = status.ObservedStage + out.ActiveSlice = status.ActiveSlice + } + out.NextOperation = next.RecommendedOp + out.RemainingFlowCost = next.RemainingCost + if out.Reason == "" { + out.Reason = next.Reason + } + if next.Prescribed != nil { + out.RecommendedCommand = next.Prescribed.CommandLine() + } + return out, nil +} diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 73cefd3..6edf347 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -280,11 +280,7 @@ func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { } func deliveryStateDirectory(repo string) (string, error) { - gitDir, err := worktreeGitDir(repo) - if err != nil { - return "", err - } - return filepath.Join(gitDir, "boatstack", "deliveries"), nil + return WorkspaceFor(repo).DeliveryDir() } func deliveryStatePath(repo, feature string) (string, error) { @@ -953,7 +949,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if status != "PASS" && status != "PASS_WITH_GAPS" { return DeliveryGateReceipt{}, fmt.Errorf("a delivery gate receipt may record only PASS or PASS_WITH_GAPS") } - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires a valid Boatstack project configuration: %w", configErr) } @@ -1111,7 +1107,7 @@ func CheckDeliveryReadyForShip(repo, feature, sliceID, base, head, diffHash stri return DeliveryState{}, DeliverySlice{}, nil, err } sources := []PRSource{} - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("ship readiness requires a valid Boatstack project configuration: %w", configErr) } @@ -1292,7 +1288,7 @@ func IgnoreDelivery(repo, feature string) (bool, error) { if err != nil { return false, err } - configPath := filepath.Join(resolved, ".product-loop", "project.json") + configPath := WorkspaceFor(resolved).ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return false, err diff --git a/boatstack/detached.go b/boatstack/detached.go new file mode 100644 index 0000000..3a724bf --- /dev/null +++ b/boatstack/detached.go @@ -0,0 +1,305 @@ +package boatstack + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Detached Supervision keeps Boatstack's controller-owned state outside the target +// repository. This file resolves the external control root, computes a stable +// repository identity, and manages the attachment registry and per-repository +// binding that let WorkspaceFor return a detached layout for an attached repo. + +const ( + // stateRootEnv overrides the external control-state root. Tests inject a temp + // directory through it so they never read or write a real home directory. + stateRootEnv = "BOATSTACK_STATE_ROOT" + // detachedSchemaVersion versions the registry and binding records. + detachedSchemaVersion = 1 + // repoIDLength is the hex width of a repository identity key. + repoIDLength = 16 + // worktreeIDLength is the hex width of a per-worktree identity key. + worktreeIDLength = 12 +) + +// detachedStateRoot returns the base directory for all detached controller state, +// always ending in a "boatstack" segment. Resolution order: the test/override env +// var, then the OS-appropriate user state directory, then a home-dir fallback. +func detachedStateRoot() (string, error) { + if override := strings.TrimSpace(os.Getenv(stateRootEnv)); override != "" { + return filepath.Join(override, "boatstack"), nil + } + switch runtime.GOOS { + case "windows": + if base := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); base != "" { + return filepath.Join(base, "boatstack"), nil + } + case "darwin": + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, "Library", "Application Support", "boatstack"), nil + } + default: // linux and other unix + if base := strings.TrimSpace(os.Getenv("XDG_STATE_HOME")); base != "" { + return filepath.Join(base, "boatstack"), nil + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".local", "state", "boatstack"), nil + } + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot resolve a user state directory for detached Boatstack: %w", err) + } + return filepath.Join(home, ".boatstack"), nil +} + +// RepoIdentity is the independently verifiable identity of a repository, used to +// bind an external control root to exactly one repo and to fail closed when the +// bound repo's identity no longer matches. +type RepoIdentity struct { + RepoID string + CanonicalRepoPath string + GitCommonIdentity string + InitialCommit string + NormalizedOrigin string + WorktreeID string +} + +// normalizeOrigin reduces an origin URL to a host/owner/repo key that is stable +// across https/ssh/git protocols and a trailing .git, so the same remote yields +// the same repo identity regardless of clone URL form. +func normalizeOrigin(url string) string { + value := strings.TrimSpace(url) + if value == "" { + return "" + } + value = strings.TrimSuffix(value, ".git") + for _, prefix := range []string{"https://", "http://", "ssh://", "git+ssh://"} { + value = strings.TrimPrefix(value, prefix) + } + value = strings.TrimPrefix(value, "git@") + // user@host:owner/repo → host/owner/repo + if at := strings.Index(value, "@"); at >= 0 && at < strings.IndexAny(value+"/", "/") { + value = value[at+1:] + } + value = strings.ReplaceAll(value, ":", "/") + return strings.ToLower(strings.Trim(value, "/")) +} + +func firstLine(value string) string { + value = strings.TrimSpace(value) + if idx := strings.IndexAny(value, "\r\n"); idx >= 0 { + return strings.TrimSpace(value[:idx]) + } + return value +} + +// repoIdentity derives a stable identity for the repository containing repo. The +// repo_id prefers remote-and-history identity (normalized origin + initial commit) +// so a moved or renamed checkout keeps the same binding; it falls back to the Git +// common directory only when neither is available (a local-only, history-less +// repo). The worktree_id isolates per-worktree mutable state. +func repoIdentity(repo string) (RepoIdentity, error) { + root, err := ResolveRepository(repo) + if err != nil { + return RepoIdentity{}, err + } + common, err := gitCommonDir(root) + if err != nil { + return RepoIdentity{}, err + } + worktreeDir, err := worktreeGitDir(root) + if err != nil { + return RepoIdentity{}, err + } + initial := firstLine(gitOutput(root, "rev-list", "--max-parents=0", "HEAD")) + origin := normalizeOrigin(gitOutput(root, "remote", "get-url", "origin")) + + seed := origin + if initial != "" { + seed += "@" + initial + } + if seed == "" { + // Local-only, history-less repo: fall back to the clone-wide common dir so + // the identity is at least stable for this clone. + seed = "gitdir:" + common + } + identity := RepoIdentity{ + RepoID: SHA256Bytes([]byte(seed))[:repoIDLength], + CanonicalRepoPath: root, + GitCommonIdentity: common, + InitialCommit: initial, + NormalizedOrigin: origin, + WorktreeID: SHA256Bytes([]byte(worktreeDir))[:worktreeIDLength], + } + return identity, nil +} + +// DetachedBinding is the per-repository record stored under the external control +// root. It carries enough independently verifiable identity that a control root +// can never be applied to the wrong repository. +type DetachedBinding struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + RepoID string `json:"repo_id"` + CanonicalRepoPath string `json:"canonical_repo_path"` + GitCommonIdentity string `json:"git_common_identity"` + InitialCommit string `json:"initial_commit"` + NormalizedOrigin string `json:"normalized_origin"` + CreatedByVersion string `json:"created_by_version"` + CreatedAt string `json:"created_at"` +} + +// detachedRegistry is the fast index from a canonical repository path to its +// repo_id. The per-repository binding.json is authoritative; the registry only +// speeds the common "is this path attached?" lookup. +type detachedRegistry struct { + SchemaVersion int `json:"schema_version"` + Repositories map[string]string `json:"repositories"` +} + +func registryPath(stateRoot string) string { return filepath.Join(stateRoot, "registry.json") } + +func repositoryControlRoot(stateRoot, repoID string) string { + return filepath.Join(stateRoot, "repositories", repoID) +} + +func bindingPath(stateRoot, repoID string) string { + return filepath.Join(repositoryControlRoot(stateRoot, repoID), "binding.json") +} + +func loadRegistry(stateRoot string) (detachedRegistry, error) { + registry := detachedRegistry{SchemaVersion: detachedSchemaVersion, Repositories: map[string]string{}} + raw, err := os.ReadFile(registryPath(stateRoot)) + if err != nil { + if os.IsNotExist(err) { + return registry, nil + } + return registry, err + } + if err := DecodeJSON("load detached registry", registryPath(stateRoot), raw, ®istry); err != nil { + return detachedRegistry{}, err + } + if registry.Repositories == nil { + registry.Repositories = map[string]string{} + } + return registry, nil +} + +func saveRegistry(stateRoot string, registry detachedRegistry) error { + registry.SchemaVersion = detachedSchemaVersion + raw, err := MarshalJSON(registry) + if err != nil { + return err + } + if err := os.MkdirAll(stateRoot, 0o755); err != nil { + return err + } + return os.WriteFile(registryPath(stateRoot), raw, 0o644) +} + +func loadBinding(stateRoot, repoID string) (DetachedBinding, error) { + var binding DetachedBinding + raw, err := os.ReadFile(bindingPath(stateRoot, repoID)) + if err != nil { + return DetachedBinding{}, err + } + if err := DecodeJSON("load detached binding", bindingPath(stateRoot, repoID), raw, &binding); err != nil { + return DetachedBinding{}, err + } + return binding, nil +} + +// bindingMatchesIdentity reports whether a stored binding still describes the +// repository now on disk. It compares the strong identity components; the +// canonical path may legitimately change (a moved checkout) so it is not required +// to match as long as origin+history do. +func bindingMatchesIdentity(binding DetachedBinding, identity RepoIdentity) bool { + if binding.RepoID != identity.RepoID { + return false + } + if binding.NormalizedOrigin != identity.NormalizedOrigin { + return false + } + if binding.InitialCommit != identity.InitialCommit { + return false + } + return true +} + +// detachedContextFor returns the detached WorkspaceContext for repo when the +// repository is attached and its binding verifies. ok is false for an unattached +// repository (the caller should use the embedded layout). err is non-nil only for +// an attached-but-unverifiable repository — the fail-closed case. +func detachedContextFor(repo string) (ctx WorkspaceContext, ok bool, err error) { + stateRoot, rootErr := detachedStateRoot() + if rootErr != nil { + return WorkspaceContext{}, false, nil // no external root resolvable → treat as embedded + } + registry, regErr := loadRegistry(stateRoot) + if regErr != nil { + return WorkspaceContext{}, false, regErr + } + if len(registry.Repositories) == 0 { + return WorkspaceContext{}, false, nil // nothing attached anywhere → embedded, no git calls + } + repoID, listed := registry.Repositories[repo] + if !listed { + // The caller may have passed a non-canonical path; resolve the root once + // (only reached when a detached registry actually has entries). + root, resolveErr := ResolveRepository(repo) + if resolveErr != nil { + return WorkspaceContext{}, false, nil + } + if repoID, listed = registry.Repositories[root]; !listed { + return WorkspaceContext{}, false, nil + } + repo = root + } + identity, idErr := repoIdentity(repo) + if idErr != nil { + return WorkspaceContext{}, true, fmt.Errorf("detached binding cannot be verified: %w", idErr) + } + binding, bindErr := loadBinding(stateRoot, repoID) + if bindErr != nil { + return WorkspaceContext{}, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr) + } + if !bindingMatchesIdentity(binding, identity) { + return WorkspaceContext{}, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding") + } + return detachedContextFromIdentity(stateRoot, identity), true, nil +} + +// detachedContextFromIdentity builds the detached WorkspaceContext for a resolved +// identity under a state root. Runtimes are shared across all detached repos +// (stateRoot), per-repository generated/config state lives under the repo control +// root, and mutable per-worktree state lives under a worktree subdirectory. +func detachedContextFromIdentity(stateRoot string, identity RepoIdentity) WorkspaceContext { + control := repositoryControlRoot(stateRoot, identity.RepoID) + return WorkspaceContext{ + Mode: SupervisionDetached, + RepoRoot: identity.CanonicalRepoPath, + RepoID: identity.RepoID, + WorktreeID: identity.WorktreeID, + controlRoot: control, + sharedControlRoot: stateRoot, + worktreeControlRoot: filepath.Join(control, "worktrees", identity.WorktreeID), + } +} + +func nowRFC3339() string { return operationNow().UTC().Truncate(time.Second).Format(time.RFC3339) } + +// RepositoryIsManaged reports whether Boatstack supervises a repository — either +// through a detached attachment (verified or not: an attached-but-broken repo is +// still managed and must fail closed) or an embedded in-repo install. It is the +// gate a developer-level guard uses to leave unmanaged repositories uncontrolled. +func RepositoryIsManaged(repo string) bool { + if _, ok, _ := detachedContextFor(repo); ok { + return true + } + return fileExists(filepath.Join(repo, productLoopDirName, "project.json")) +} diff --git a/boatstack/detached_test.go b/boatstack/detached_test.go new file mode 100644 index 0000000..139f015 --- /dev/null +++ b/boatstack/detached_test.go @@ -0,0 +1,390 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// Conformance tests for Detached Supervision. Each names the control law it proves. +// +// control-law: detached-control-state-never-enters-the-plant +// control-law: detached-state-controls-only-its-bound-repository +// control-law: unattached-repositories-are-not-controlled +// control-law: attached-but-unverifiable-fails-closed +// control-law: supervisor-semantics-are-mode-invariant + +// detachedTestRepo builds a minimal real git repository with one commit and an +// origin remote (so identity derivation is stable), and points the external state +// root at a temp dir so no real home directory is touched. +func detachedTestRepo(t *testing.T, origin string) string { + t.Helper() + invalidateWorkspaceCache() + t.Setenv(stateRootEnv, t.TempDir()) + 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 origin != "" { + runGit(t, repo, "remote", "add", "origin", origin) + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# app\n"), 0o644); err != nil { + t.Fatal(err) + } + // A go.mod gives detectTestCommand a concrete command so config synthesis is + // valid without an interactive prompt. + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module app\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "initial") + return repo +} + +func gitPorcelain(t *testing.T, repo string) string { + t.Helper() + out, err := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "--untracked-files=all").CombinedOutput() + if err != nil { + t.Fatalf("git status: %v: %s", err, out) + } + return strings.TrimSpace(string(out)) +} + +// control-law: detached-control-state-never-enters-the-plant +func TestAttachLeavesRepositoryUnchanged(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + before := gitPorcelain(t, repo) + + result, err := AttachDetached(AttachOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach did not verify: %+v", result) + } + if after := gitPorcelain(t, repo); after != before { + t.Fatalf("attach changed the working tree: before=%q after=%q", before, after) + } + for _, forbidden := range []string{".product-loop", ".boatstack-project.json", ".claude", ".cursor", ".codex", ".gemini", ".agents"} { + if _, err := os.Stat(filepath.Join(repo, forbidden)); !os.IsNotExist(err) { + t.Fatalf("attach created %s inside the repository", forbidden) + } + } + // Controller state exists under the external control root. + if _, err := os.Stat(WorkspaceFor(repo).ProjectConfigPath()); err != nil { + t.Fatalf("external project.json missing: %v", err) + } + if !strings.Contains(result.ControlRoot, "boatstack") || strings.HasPrefix(result.ControlRoot, repo) { + t.Fatalf("control root should be external, got %q", result.ControlRoot) + } +} + +// control-law: unattached-repositories-are-not-controlled +func TestUnattachedRepositoryResolvesEmbedded(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + ctx := WorkspaceFor(repo) + if ctx.Mode != SupervisionEmbedded { + t.Fatalf("unattached repo must resolve embedded, got %s", ctx.Mode) + } + if ctx.ProjectConfigPath() != filepath.Join(repo, ".product-loop", "project.json") { + t.Fatalf("embedded config path drifted: %s", ctx.ProjectConfigPath()) + } +} + +// control-law: detached-control-state-never-enters-the-plant +func TestAttachRoutesWorkspaceToExternalControlRoot(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + ctx := WorkspaceFor(repo) + if ctx.Mode != SupervisionDetached { + t.Fatalf("attached repo must resolve detached, got %s", ctx.Mode) + } + for _, p := range []string{ctx.ProjectConfigPath()} { + if strings.HasPrefix(p, repo+string(filepath.Separator)) { + t.Fatalf("detached path points inside the repo: %s", p) + } + } + delivery, err := ctx.DeliveryDir() + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(delivery, repo+string(filepath.Separator)) { + t.Fatalf("detached delivery dir points inside the repo: %s", delivery) + } +} + +// control-law: detached-state-controls-only-its-bound-repository +func TestDetachedStateIsPerRepositoryAndPerWorktree(t *testing.T) { + repoA := detachedTestRepo(t, "https://github.com/acme/app.git") + // A second repo shares the same state root (set by the first helper call is + // overwritten; set it explicitly to the same dir for both). + stateRoot := os.Getenv(stateRootEnv) + repoB := t.TempDir() + runGit(t, repoB, "init", "-b", "main") + runGit(t, repoB, "config", "user.name", "T") + runGit(t, repoB, "config", "user.email", "t@example.invalid") + runGit(t, repoB, "remote", "add", "origin", "https://github.com/acme/other.git") + if err := os.WriteFile(filepath.Join(repoB, "README.md"), []byte("b\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repoB, "add", ".") + runGit(t, repoB, "commit", "-m", "b") + + if _, err := AttachDetached(AttachOptions{Repo: repoA}); err != nil { + t.Fatal(err) + } + _ = stateRoot + // repoB is not attached → embedded, unaffected by repoA's attachment. + if WorkspaceFor(repoB).Mode != SupervisionEmbedded { + t.Fatal("attaching repoA must not control repoB") + } + // The two repositories have distinct identities. + idA, _ := repoIdentity(repoA) + idB, _ := repoIdentity(repoB) + if idA.RepoID == idB.RepoID { + t.Fatal("distinct repositories must have distinct repo_ids") + } +} + +// control-law: attached-but-unverifiable-fails-closed +func TestAttachedButCorruptBindingFailsClosed(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + stateRoot := os.Getenv(stateRootEnv) + boatstackRoot := filepath.Join(stateRoot, "boatstack") + identity, _ := repoIdentity(repo) + // Corrupt the binding so identity can no longer be verified. + if err := os.WriteFile(bindingPath(boatstackRoot, identity.RepoID), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if _, _, err := detachedContextFor(repo); err == nil { + t.Fatal("a corrupt binding for an attached repo must fail closed") + } + status, _ := DetachedStatus(repo) + if !status.Attached || status.Verified { + t.Fatalf("status must report attached-but-unverified: %+v", status) + } +} + +// control-law: detached-state-controls-only-its-bound-repository +func TestMovedRepositoryWithMismatchedIdentityFailsClosed(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + stateRoot := os.Getenv(stateRootEnv) + boatstackRoot := filepath.Join(stateRoot, "boatstack") + identity, _ := repoIdentity(repo) + // Rewrite the binding to claim a different origin/initial-commit identity while + // keeping the same repo_id index entry: the strong-identity check must reject it. + binding, err := loadBinding(boatstackRoot, identity.RepoID) + if err != nil { + t.Fatal(err) + } + binding.NormalizedOrigin = "github.com/acme/somethingelse" + binding.InitialCommit = "0000000000000000000000000000000000000000" + raw, _ := MarshalJSON(binding) + if err := os.WriteFile(bindingPath(boatstackRoot, identity.RepoID), raw, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if _, _, err := detachedContextFor(repo); err == nil { + t.Fatal("an identity mismatch must fail closed, not silently rebind") + } +} + +// control-law: supervisor-semantics-are-mode-invariant +// The delivery-state layer round-trips identically whether stored embedded (in the +// Git dir) or detached (in the external control root); detached storage lands +// outside the repository. +func TestDeliveryStateRoundTripsIdenticallyAcrossLayouts(t *testing.T) { + state := DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, + Feature: "sample-feature", + ActiveIndex: 1, + Slices: []DeliverySlice{ + {ID: "delivery", Title: "Delivery", Status: "PUBLISHED", HeadBranch: "feat/sample-feature"}, + }, + } + + // Embedded: no attachment; state lives in the Git directory. + embedded := detachedTestRepo(t, "https://github.com/acme/embedded.git") + if err := saveDeliveryState(embedded, state); err != nil { + t.Fatal(err) + } + gotEmbedded, err := LoadDeliveryState(embedded, state.Feature) + if err != nil { + t.Fatal(err) + } + + // Detached: attached; state lives under the external control root. + detached := detachedTestRepo(t, "https://github.com/acme/detached.git") + if _, err := AttachDetached(AttachOptions{Repo: detached}); err != nil { + t.Fatal(err) + } + if err := saveDeliveryState(detached, state); err != nil { + t.Fatal(err) + } + gotDetached, err := LoadDeliveryState(detached, state.Feature) + if err != nil { + t.Fatal(err) + } + + dir, err := WorkspaceFor(detached).DeliveryDir() + if err != nil { + t.Fatal(err) + } + if strings.HasPrefix(dir, detached+string(filepath.Separator)) { + t.Fatalf("detached delivery state must live outside the repo, got %s", dir) + } + + if gotEmbedded.Feature != gotDetached.Feature || + gotEmbedded.ActiveIndex != gotDetached.ActiveIndex || + len(gotEmbedded.Slices) != len(gotDetached.Slices) || + gotEmbedded.Slices[0].Status != gotDetached.Slices[0].Status || + gotEmbedded.Slices[0].HeadBranch != gotDetached.Slices[0].HeadBranch { + t.Fatalf("delivery state differs across layouts:\nembedded=%+v\ndetached=%+v", gotEmbedded, gotDetached) + } +} + +// control-law: detached-control-state-never-enters-the-plant +// The safety guard denies direct model mutation of the detached external control +// root exactly as it does the embedded runtime state. +func TestSafetyProtectsExternalControlRoot(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + for _, command := range []string{ + "rm -rf /Users/dev/.local/state/boatstack/repositories/abc123", + "echo tampered > ~/.local/state/boatstack/registry.json", + "cp evil ~/Library/Application Support/boatstack/runtimes/x/y/z/boatstack-helper", + } { + findings := ClassifyCommand(repo, command) + if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" { + t.Fatalf("external control-root mutation was not denied as tamper: %q -> %#v", command, findings) + } + } +} + +// control-law: supervisor-semantics-are-mode-invariant +func TestContextProjectionReportsMode(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + + embedded, err := ProjectOperatorContext(repo, "build", "claude") + if err != nil { + t.Fatal(err) + } + if embedded.Mode != string(SupervisionEmbedded) || embedded.Attached { + t.Fatalf("unattached context should be embedded/unattached: %+v", embedded) + } + + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + detached, err := ProjectOperatorContext(repo, "build", "claude") + if err != nil { + t.Fatal(err) + } + if detached.Mode != string(SupervisionDetached) || !detached.Attached || detached.ControlRoot == "" { + t.Fatalf("attached context should be detached with a control root: %+v", detached) + } + if strings.HasPrefix(detached.ControlRoot, repo+string(filepath.Separator)) { + t.Fatalf("context control root must be external: %s", detached.ControlRoot) + } +} + +// control-law: unattached-repositories-are-not-controlled +// A developer-level guard allows an unmanaged repository (no Boatstack control) but +// enforces the full policy on a managed one. +func TestAmbientHookNoOpsUnmanagedButEnforcesManaged(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + event := []byte(`{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git reset --hard HEAD~1"}}`) + + // Unmanaged: the ambient guard must not control this repository. + output, denied := AmbientHookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) + if denied { + t.Fatalf("ambient guard controlled an unattached repository: %s", output) + } + + // Attached (detached): the same destructive command is now denied. + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + output, denied = AmbientHookDecision(SafetyHookOptions{Host: "claude", Repo: repo, Input: event}) + if !denied || !strings.Contains(string(output), `"permissionDecision":"deny"`) { + t.Fatalf("ambient guard did not enforce policy on a managed repository: %s", output) + } +} + +// control-law: unattached-repositories-are-not-controlled +// Activation instructions are host-neutral (every agent), point at developer-level +// config outside the repo, and install the ambient guard that no-ops unattached +// repositories. An unattached repository has nothing to activate. +func TestActivationPlanIsHostNeutralAndExternal(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + t.Setenv("BOATSTACK_USER_CONFIG_ROOT", t.TempDir()) + + unattached, err := DetachedActivationPlan(repo, nil) + if err != nil { + t.Fatal(err) + } + if unattached.Attached || len(unattached.Hosts) != 0 { + t.Fatalf("unattached repo should have no activation plan: %+v", unattached) + } + + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + plan, err := DetachedActivationPlan(repo, nil) + if err != nil { + t.Fatal(err) + } + if !plan.Attached || len(plan.Hosts) != 4 { + t.Fatalf("expected activation for all four agents: %+v", plan) + } + userRoot := os.Getenv("BOATSTACK_USER_CONFIG_ROOT") + for _, host := range plan.Hosts { + if !strings.HasPrefix(host.ConfigPath, userRoot) { + t.Fatalf("%s config path must be developer-level, got %s", host.Host, host.ConfigPath) + } + if strings.HasPrefix(host.ConfigPath, repo+string(filepath.Separator)) { + t.Fatalf("%s activation must not point inside the repo: %s", host.Host, host.ConfigPath) + } + if !strings.Contains(host.Snippet, "ambient-safety-hook") || !strings.Contains(host.Snippet, "--host "+host.Host) { + t.Fatalf("%s snippet missing ambient guard command: %s", host.Host, host.Snippet) + } + var decoded any + if err := json.Unmarshal([]byte(host.Snippet), &decoded); err != nil { + t.Fatalf("%s snippet is not valid JSON: %v", host.Host, err) + } + } +} + +// control-law: detached-control-state-never-enters-the-plant +func TestDetachRemovesAttachmentAndState(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/app.git") + if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { + t.Fatal(err) + } + control := WorkspaceFor(repo).controlRoot + result, err := DetachDetached(DetachOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || !result.StateRemoved { + t.Fatalf("detach did not remove state: %+v", result) + } + if _, err := os.Stat(control); !os.IsNotExist(err) { + t.Fatalf("detach left external control state behind: %s", control) + } + if WorkspaceFor(repo).Mode != SupervisionEmbedded { + t.Fatal("after detach the repo must resolve embedded again") + } +} diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index ded3aaf..08f18b2 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -157,7 +157,7 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "review") cmd.RequiresHumanInput = []string{"--status", "--evidence", "--reviewer-identity", "--review-method"} case PublishTransition: - preview := filepath.Join(repo, ".product-loop", "features", feature, "pr.md") + preview := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature, "pr.md") cmd.Args = append(repoArgs, "--preview", preview, "--action", "open") cmd.RequiresHumanInput = []string{"--preview-fingerprint"} default: diff --git a/boatstack/flow_trace.go b/boatstack/flow_trace.go index a28e932..80ef76a 100644 --- a/boatstack/flow_trace.go +++ b/boatstack/flow_trace.go @@ -2,7 +2,6 @@ package boatstack import ( "os" - "path/filepath" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) @@ -16,11 +15,7 @@ const flowTraceKillSwitch = "BOATSTACK_FLOW_TRACE" // /boatstack/flow, a sibling of the delivery-state directory. It reuses // deliveryStateDirectory's Git-dir resolution so the two stay in lockstep. func flowLogDirectory(repo string) (string, error) { - deliveries, err := deliveryStateDirectory(repo) - if err != nil { - return "", err - } - return filepath.Join(filepath.Dir(deliveries), "flow"), nil + return WorkspaceFor(repo).FlowDir() } // RecordFlowTransition appends a best-effort shadow record of one delivery-flow diff --git a/boatstack/init.go b/boatstack/init.go index 40c175b..de385e6 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -329,7 +329,7 @@ func RunInit(options InitOptions) (returnErr error) { return err } reader := bufio.NewReader(options.Input) - configPath := filepath.Join(repo, ".boatstack-project.json") + configPath := WorkspaceFor(repo).SourceConfigPath() configExists := fileExists(configPath) installed := fileExists(filepath.Join(repo, ".product-loop", "generated.lock.json")) || fileExists(filepath.Join(repo, ".product-loop", "bin", helperName())) if installed && !options.Update { @@ -793,7 +793,7 @@ func RunUpdate(options InitOptions) error { return reexecUpdate(options.BinaryPath, options) } } - config, _, configErr := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).SourceConfigPath()) if configErr != nil { return configErr } diff --git a/boatstack/installation_repair.go b/boatstack/installation_repair.go index 3527d26..1df959b 100644 --- a/boatstack/installation_repair.go +++ b/boatstack/installation_repair.go @@ -350,7 +350,7 @@ func ClassifyInstallationRepair(repoPath string, adapters []string, allowDowngra result.Items = append(result.Items, InstallationRepairItem{Path: filepath.ToSlash(relative), Classification: classification, Reason: reason, CurrentSHA256: currentFileHash(filepath.Join(repo, filepath.FromSlash(relative)))}) } } - config, _, configErr := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).SourceConfigPath()) if configErr == nil { states, stateErr := readInstalledIntegrations(repo, config) if stateErr == nil { diff --git a/boatstack/next.go b/boatstack/next.go index 0fbdf86..3ab774b 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -208,14 +208,14 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { return NextStatus{}, err } base := NextStatus{SchemaVersion: nextStatusSchemaVersion} - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { base.VerificationStatus = "UNVERIFIED" base.ObservedStage = "NOT_INITIALIZED" base.NextOperation = "init" base.Reason = "This repository has no Boatstack project installation to inspect." return base, nil } - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack project configuration is invalid: "+configErr.Error()), nil } diff --git a/boatstack/operation.go b/boatstack/operation.go index f2432ec..c1f50ce 100644 --- a/boatstack/operation.go +++ b/boatstack/operation.go @@ -116,11 +116,7 @@ func operationTimestamp() string { // so bumping the version cleanly orphans the legacy clone-shared "v1" ledger for // every worktree — including main — instead of silently inheriting its receipts. func operationDirectory(repo string) (string, error) { - gitDir, err := worktreeGitDir(repo) - if err != nil { - return "", err - } - return filepath.Join(gitDir, "boatstack", "operations", "v2"), nil + return WorkspaceFor(repo).OperationDir() } // pruneLegacyOperationLedger removes the pre-isolation clone-shared "v1" ledger diff --git a/boatstack/paths.go b/boatstack/paths.go new file mode 100644 index 0000000..584b6fe --- /dev/null +++ b/boatstack/paths.go @@ -0,0 +1,230 @@ +package boatstack + +import ( + "path/filepath" + "sync" +) + +// SupervisionMode selects where Boatstack keeps its controller-owned state. +type SupervisionMode string + +const ( + // SupervisionEmbedded keeps controller state inside the target repository + // (.product-loop/**, host adapter dirs) and its Git directory — the original, + // repository-owned layout. + SupervisionEmbedded SupervisionMode = "embedded" + // SupervisionDetached keeps controller state under an external, developer-local + // control root, leaving the target repository free of Boatstack-owned files. + // Detached resolution is wired in a later stage; the type is mode-aware now so + // callers route every controller path through this one seam. + SupervisionDetached SupervisionMode = "detached" +) + +const ( + // productLoopDirName is the in-repo embedded controller directory. + productLoopDirName = ".product-loop" + // sourceConfigName is the editable root configuration file (embedded mode). + sourceConfigName = ".boatstack-project.json" + // controlDirName is the Boatstack subtree inside a Git directory (embedded) or + // external control root (detached) that holds mutable controller state. + controlDirName = "boatstack" +) + +// WorkspaceContext is the single resolver for every Boatstack-owned path. Callers +// obtain controller locations through its methods instead of joining onto the +// repository root directly, so the embedded and detached layouts differ in exactly +// one place. The plant — product files, commits, branches, PRs — is always +// addressed through RepoRoot; everything controller-owned flows through the roots +// below. +// +// Path classes: +// - embedded controller (generated, model-visible): GeneratedRoot / *ConfigPath +// - per-worktree mutable controller: DeliveryDir / OperationDir / FlowDir +// - shared immutable runtime: RuntimeDir +// - host activation: HostActivationRoot +type WorkspaceContext struct { + Mode SupervisionMode + RepoRoot string // git worktree top-level (the plant) + RepoID string // stable repository identity (detached binding key; empty in embedded) + WorktreeID string // stable per-worktree identity (detached; empty in embedded) + + // controlRoot is the base for generated/config state: RepoRoot in embedded + // mode, the external repositories/ root in detached mode. + controlRoot string + // worktreeControlRoot is the base for per-worktree mutable state. In embedded + // mode it is left empty and derived lazily from the Git worktree directory; in + // detached mode it is the external per-worktree directory. + worktreeControlRoot string + // sharedControlRoot is the base for shared immutable runtime state. In embedded + // mode it is left empty and derived lazily from the Git common directory; in + // detached mode it is the external shared root. + sharedControlRoot string +} + +// WorkspaceFor returns the resolver for a repository. It consults the external +// attachment registry and returns a detached context when the repository is +// attached and its binding verifies; otherwise it returns the embedded layout. +// An attached-but-unverifiable repository resolves to embedded here (best effort, +// so deep path callers stay total) — the fail-closed denial with a bounded +// recovery action is raised at the safety and CLI entry points via +// ResolveWorkspaceContext. Results are cached per input path; attach/detach +// invalidate the cache. +func WorkspaceFor(repo string) WorkspaceContext { + workspaceCacheMu.Lock() + if cached, ok := workspaceCache[repo]; ok { + workspaceCacheMu.Unlock() + return cached + } + workspaceCacheMu.Unlock() + + resolved := embeddedWorkspace(repo) + if ctx, ok, err := detachedContextFor(repo); ok && err == nil { + resolved = ctx + } + + workspaceCacheMu.Lock() + workspaceCache[repo] = resolved + workspaceCacheMu.Unlock() + return resolved +} + +// ResolveWorkspaceContext is the strict resolver used at entry points that must +// fail closed: it returns an error for an attached-but-unverifiable repository +// (bad or missing binding, identity mismatch) instead of silently falling back to +// the embedded layout. +func ResolveWorkspaceContext(repo string) (WorkspaceContext, error) { + ctx, ok, err := detachedContextFor(repo) + if err != nil { + return WorkspaceContext{}, err + } + if ok { + return ctx, nil + } + return embeddedWorkspace(repo), nil +} + +func embeddedWorkspace(repo string) WorkspaceContext { + return WorkspaceContext{Mode: SupervisionEmbedded, RepoRoot: repo, controlRoot: repo} +} + +var ( + workspaceCacheMu sync.Mutex + workspaceCache = map[string]WorkspaceContext{} +) + +// invalidateWorkspaceCache clears the WorkspaceFor cache. Call it after any change +// to the attachment registry or bindings so subsequent resolution reflects it. +func invalidateWorkspaceCache() { + workspaceCacheMu.Lock() + workspaceCache = map[string]WorkspaceContext{} + workspaceCacheMu.Unlock() +} + +// configBase is the root under which the generated controller bundle (.product-loop +// and host adapter dirs) lives: the repository in embedded mode, the external +// control root in detached mode. Detached mirrors the embedded bundle layout so +// generation and guard resolution are reused unchanged, only relocated. +func (w WorkspaceContext) configBase() string { + if w.Mode == SupervisionDetached { + return w.controlRoot + } + return w.RepoRoot +} + +// GeneratedRoot is the root of generated, model-visible controller files +// (references, templates, project.json). Embedded: /.product-loop. +func (w WorkspaceContext) GeneratedRoot() string { + return filepath.Join(w.configBase(), productLoopDirName) +} + +// ProjectConfigPath is the generated runtime configuration copy that runtime +// operations read. Embedded: /.product-loop/project.json. +func (w WorkspaceContext) ProjectConfigPath() string { + return filepath.Join(w.configBase(), productLoopDirName, "project.json") +} + +// SourceConfigPath is the editable, authoritative configuration source. Embedded: +// /.boatstack-project.json. Detached: the external control root's copy. +func (w WorkspaceContext) SourceConfigPath() string { + return filepath.Join(w.configBase(), sourceConfigName) +} + +// HostActivationRoot is the base under which a host adapter's activation files are +// written. Embedded: the repository root (adapters live in /.claude, ...). +// Detached: the external control root (host dirs live outside the repository). +func (w WorkspaceContext) HostActivationRoot() string { + return w.configBase() +} + +// worktreeControlDir is the base subtree for this worktree's mutable controller +// state. Embedded: /boatstack; detached: the external per-worktree +// control directory. +func (w WorkspaceContext) worktreeControlDir() (string, error) { + if w.Mode == SupervisionDetached { + return w.worktreeControlRoot, nil + } + gitDir, err := worktreeGitDir(w.RepoRoot) + if err != nil { + return "", err + } + return filepath.Join(gitDir, controlDirName), nil +} + +// sharedControlDir is the base subtree for shared immutable runtime state. +// Embedded: /boatstack; detached: the external shared root. +func (w WorkspaceContext) sharedControlDir() (string, error) { + if w.Mode == SupervisionDetached { + return w.sharedControlRoot, nil + } + common, err := gitCommonDir(w.RepoRoot) + if err != nil { + return "", err + } + return filepath.Join(common, controlDirName), nil +} + +// DeliveryDir holds per-worktree delivery state. +func (w WorkspaceContext) DeliveryDir() (string, error) { + base, err := w.worktreeControlDir() + if err != nil { + return "", err + } + return filepath.Join(base, "deliveries"), nil +} + +// OperationDir holds the per-worktree operation ledger. The "v2" segment orphans +// the pre-isolation clone-shared "v1" ledger (see operation.go). +func (w WorkspaceContext) OperationDir() (string, error) { + base, err := w.worktreeControlDir() + if err != nil { + return "", err + } + return filepath.Join(base, "operations", "v2"), nil +} + +// FlowDir holds the per-worktree append-only flow trajectory log. +func (w WorkspaceContext) FlowDir() (string, error) { + base, err := w.worktreeControlDir() + if err != nil { + return "", err + } + return filepath.Join(base, "flow"), nil +} + +// RuntimeDir holds the shared, version-namespaced runtime binary for the current +// platform. version and sourceCommit are validated as single safe path segments. +func (w WorkspaceContext) RuntimeDir(version, sourceCommit string) (string, error) { + version, err := safeCacheSegment(version, "Boatstack version") + if err != nil { + return "", err + } + sourceCommit, err = safeCacheSegment(sourceCommit, "source commit") + if err != nil { + return "", err + } + base, err := w.sharedControlDir() + if err != nil { + return "", err + } + return filepath.Join(base, "runtimes", version, sourceCommit, platformKey()), nil +} diff --git a/boatstack/plan.go b/boatstack/plan.go index b694ad3..03b6168 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -967,7 +967,7 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return err } - config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err) } diff --git a/boatstack/plan_validation.go b/boatstack/plan_validation.go index 4ba2bd5..5e57ba3 100644 --- a/boatstack/plan_validation.go +++ b/boatstack/plan_validation.go @@ -208,7 +208,7 @@ func requireConfiguredPRVisualEvidenceDecision(plan map[string]any, opts *Valida if opts == nil || opts.RepoRoot == "" { return nil } - config, _, err := LoadConfig(filepath.Join(opts.RepoRoot, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(opts.RepoRoot).ProjectConfigPath()) if err != nil || normalizedPRVisualEvidencePolicy(config.Workflow.PRVisualEvidence) == "off" { return nil } diff --git a/boatstack/planning.go b/boatstack/planning.go index ab4b89c..feca0e7 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -320,7 +320,7 @@ func Doctor(repoPath string) error { if err != nil { return err } - configPath := filepath.Join(repo, ".boatstack-project.json") + configPath := WorkspaceFor(repo).SourceConfigPath() config, raw, err := LoadConfig(configPath) if err != nil { return fmt.Errorf("invalid or missing .boatstack-project.json: %w", err) @@ -380,7 +380,7 @@ func DoctorHookHosts(repoPath string) ([]string, error) { if err != nil { return nil, err } - config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath()) if err != nil { return nil, err } diff --git a/boatstack/pr.go b/boatstack/pr.go index f40ff00..1b846ce 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -223,7 +223,7 @@ func gitCommand(repo string, arguments ...string) (string, error) { } func defaultPRBase(repo string) string { - configPath := filepath.Join(repo, ".product-loop", "project.json") + configPath := WorkspaceFor(repo).ProjectConfigPath() if config, _, err := LoadConfig(configPath); err == nil && strings.TrimSpace(config.Project.DefaultBranch) != "" { return strings.TrimSpace(config.Project.DefaultBranch) } @@ -430,7 +430,7 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro if err != nil { return nil, nil, fmt.Errorf("managed PR requires a current plan: %w", err) } - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { return nil, nil, fmt.Errorf("managed PR requires a valid Boatstack project configuration: %w", configErr) } @@ -530,7 +530,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil || head == "" { return PRContext{}, fmt.Errorf("PR preparation requires a named branch") } - configPath := filepath.Join(repo, ".product-loop", "project.json") + configPath := WorkspaceFor(repo).ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return PRContext{}, fmt.Errorf("PR preparation requires a valid Boatstack project configuration: %w", err) diff --git a/boatstack/provision.go b/boatstack/provision.go index 08ee211..03ed29f 100644 --- a/boatstack/provision.go +++ b/boatstack/provision.go @@ -140,7 +140,7 @@ func CapabilityProvisionGuide(repo, name string) (ProvisionGuide, error) { if !ok { return ProvisionGuide{}, fmt.Errorf("unknown evidence capability %q", name) } - config, _, err := LoadConfig(filepath.Join(resolved, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(resolved).ProjectConfigPath()) if err != nil { return ProvisionGuide{}, fmt.Errorf("provisioning requires a valid Boatstack project configuration: %w", err) } @@ -224,7 +224,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability return RegisteredCapability{}, fmt.Errorf("capability-register requires a non-empty --command") } - sourcePath := filepath.Join(resolved, ".boatstack-project.json") + sourcePath := WorkspaceFor(resolved).SourceConfigPath() if fileExists(sourcePath) { config, _, err := LoadConfig(sourcePath) if err != nil { @@ -251,7 +251,7 @@ func RegisterCapabilityCommand(repo, name, command string) (RegisteredCapability return RegisteredCapability{Capability: capability.Name, Alias: capability.Name, Command: command, Source: "source-and-export"}, nil } - configPath := filepath.Join(resolved, ".product-loop", "project.json") + configPath := WorkspaceFor(resolved).ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return RegisteredCapability{}, err diff --git a/boatstack/recovery.go b/boatstack/recovery.go index cf7a6c2..9757d6c 100644 --- a/boatstack/recovery.go +++ b/boatstack/recovery.go @@ -341,7 +341,7 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { default: return RecoveryStatus{}, fmt.Errorf("recovery source stage must be ci, review, publication, or user") } - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return RecoveryStatus{ SchemaVersion: recoveryStatusSchemaVersion, VerificationStatus: "UNVERIFIED", NextOperation: "none", Reason: "This repository has no managed delivery installation to inspect.", @@ -359,7 +359,7 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { if err != nil { return blockedRecovery("Managed delivery state cannot be verified: " + err.Error()), nil } - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { return blockedRecovery("Boatstack project configuration is invalid: " + configErr.Error()), nil } diff --git a/boatstack/run.go b/boatstack/run.go index f47a56e..fa87cd1 100644 --- a/boatstack/run.go +++ b/boatstack/run.go @@ -2,7 +2,6 @@ package boatstack import ( "fmt" - "path/filepath" "strconv" "strings" ) @@ -44,7 +43,7 @@ func runBranches(repo, explicitFeature string) (string, string, error) { // Scope the ambiguity check to un-ignored deliveries so the foreground // coordinator matches ResolveNext. A config that fails to load leaves active // unfiltered, preserving the prior >1-active behavior. - if config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")); configErr == nil { + if config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr == nil { active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries) } @@ -96,7 +95,7 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if err != nil { return blockedRunPreflight("", "", "", "INVALID_REPOSITORY", err.Error()) } - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.") } if _, err := runGitCommand(repo, "remote", "get-url", "origin"); err != nil { diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go index 2d5cec3..dd08d87 100644 --- a/boatstack/runtime_cache.go +++ b/boatstack/runtime_cache.go @@ -90,19 +90,7 @@ func worktreeGitDir(repo string) (string, error) { } func sharedRuntimeDirectory(repo, version, sourceCommit string) (string, error) { - version, err := safeCacheSegment(version, "Boatstack version") - if err != nil { - return "", err - } - sourceCommit, err = safeCacheSegment(sourceCommit, "source commit") - if err != nil { - return "", err - } - common, err := gitCommonDir(repo) - if err != nil { - return "", err - } - return filepath.Join(common, "boatstack", "runtimes", version, sourceCommit, platformKey()), nil + return WorkspaceFor(repo).RuntimeDir(version, sourceCommit) } func sharedRuntimePaths(repo, version, sourceCommit string) (string, string, error) { @@ -364,7 +352,7 @@ func RunHydrateRuntime(repoPath string) error { if err != nil { return err } - config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).SourceConfigPath()) if err != nil { return fmt.Errorf("load project configuration for runtime hydration: %w", err) } diff --git a/boatstack/safety.go b/boatstack/safety.go index 6c2d3ae..ea88cff 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -86,7 +86,13 @@ var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack // boatstack-helper_darwin_arm64) that a running update may invoke after the installed // helper is swapped or removed. var approvedUpdatePublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper(?:[_.-][a-z0-9._-]+)?\s+publish-update-pr\b[^\n;&|]*$`) -var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\]deliveries|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`) +// deliveryStatePathPattern matches Boatstack's managed runtime/control state so +// the guard denies direct model mutation of it. It covers the embedded homes +// (boatstack/deliveries and any .git/.../boatstack subtree) and the Detached +// Supervision external control root (boatstack/{repositories,registry.json} and +// the version-namespaced boatstack/runtimes). Only Boatstack transitions and the +// sanctioned publisher (approvedUpdatePublisherPattern) may name these paths. +var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\](?:deliveries|operations|flow|repositories|runtimes|registry\.json)|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`) var mutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create|delete|remove|move|rename|update|insert|upload|install)`) var planningMutationToolPattern = regexp.MustCompile(`(?i)(?:write|edit|apply[_-]?patch|create)`) var externalReadOnlyToolPattern = regexp.MustCompile(`(?i)(?:^|[_-])(?:get|list|read|search|find|status|inspect|query|fetch|open)(?:[_-]|$)`) @@ -314,7 +320,7 @@ func publicationBypassFinding(repo, reason, source string) (SafetyFinding, bool) // band) poisons authority for every other delivery with relation=ambiguous. // A config that fails to load leaves active unfiltered, preserving the prior // behavior. - if config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")); configErr == nil { + if config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr == nil { active = withoutIgnoredDeliveries(active, config.Workflow.IgnoredDeliveries) } if len(active) == 0 { @@ -999,6 +1005,26 @@ func denialMessage(host string, finding SafetyFinding) string { return denialFor(host, finding).Render(RenderPlain) } +// AmbientHookDecision is the entry point for a developer-level (user-scoped) guard +// that runs for every repository the coding agent opens. It enforces Boatstack only +// on managed repositories — those with a detached attachment or an embedded install +// — and returns a plain allow (no Boatstack decision) everywhere else, so a +// user-level hook never controls an unattached repository. On a managed repository +// it delegates to the full HookDecision. +func AmbientHookDecision(options SafetyHookOptions) ([]byte, bool) { + host := strings.ToLower(strings.TrimSpace(options.Host)) + contract, supported := hookHostContracts[host] + repo, err := ResolveRepository(options.Repo) + if err != nil || !RepositoryIsManaged(repo) { + if supported { + value, _ := contract.allow() + return value, false + } + return nil, false + } + return HookDecision(options) +} + func HookDecision(options SafetyHookOptions) ([]byte, bool) { host := strings.ToLower(strings.TrimSpace(options.Host)) contract, supported := hookHostContracts[host] @@ -1100,7 +1126,7 @@ func CheckRepositorySafety(repoPath string) (SafetyReport, error) { } highRisk := []string{} defaultBranch := "" - configPath := filepath.Join(repo, ".product-loop", "project.json") + configPath := WorkspaceFor(repo).ProjectConfigPath() if value, readErr := os.ReadFile(configPath); readErr == nil { var config ProjectConfig if json.Unmarshal(value, &config) == nil { diff --git a/boatstack/update_publication.go b/boatstack/update_publication.go index 83618df..6d8b35c 100644 --- a/boatstack/update_publication.go +++ b/boatstack/update_publication.go @@ -171,7 +171,7 @@ func PrepareUpdatePublication(repoPath, requestedVersion string) (UpdatePublicat if err != nil { return UpdatePublicationPreview{}, err } - config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return UpdatePublicationPreview{}, err } diff --git a/boatstack/visual_publisher.go b/boatstack/visual_publisher.go index fb9dd2e..e591c51 100644 --- a/boatstack/visual_publisher.go +++ b/boatstack/visual_publisher.go @@ -69,7 +69,7 @@ func SelectVisualPublisher(repo string) PRVisualEvidencePublisher { // or leaves the block unset so the caller falls back to the default public-branch // behavior. func visualPublishConfig(repo string) *VisualEvidencePublish { - config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return nil } diff --git a/boatstack/workspace.go b/boatstack/workspace.go index 9fb7d23..49b0e64 100644 --- a/boatstack/workspace.go +++ b/boatstack/workspace.go @@ -91,7 +91,7 @@ func needsFreshCut(repo, feature string) bool { } func loadWorkspacePolicy(repo string) (ResolvedWorkspace, error) { - config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if err != nil { return ResolvedWorkspace{}, err } @@ -141,7 +141,7 @@ func CutFeatureWorkspace(options WorkspaceCutOptions) (WorkspaceCut, error) { if err != nil { return blockedCut(err.Error()), nil } - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedCut("This repository has no Boatstack project installation."), nil } policy, err := loadWorkspacePolicy(repo) @@ -366,7 +366,7 @@ func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, if branch == "" { return blockedCleanup(branch, "A branch is required to clean up a workspace."), nil } - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedCleanup(branch, "This repository has no Boatstack project installation."), nil } policy, err := loadWorkspacePolicy(repo) @@ -555,7 +555,7 @@ func CountReclaimableWorkspaces(repoPath string) int { if err != nil { return 0 } - config, _, cfgErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, cfgErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if cfgErr != nil || !resolveWorkspace(config.Workspace).Enabled { return 0 } @@ -576,10 +576,10 @@ func ReapWorkspaces(options WorkspaceReapOptions) (WorkspaceReap, error) { if err != nil { return blockedReap(err.Error()), nil } - if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedReap("This repository has no Boatstack project installation."), nil } - config, _, cfgErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + config, _, cfgErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if cfgErr != nil { return blockedReap("Boatstack could not read the workspace policy: " + cfgErr.Error()), nil } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 0629fb2..a6b51b6 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -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 [`64d586468baf5a7b45a2debbfc747b0d9ec9a233`](https://github.com/operatorstack/intelligence-flow/tree/64d586468baf5a7b45a2debbfc747b0d9ec9a233/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 [`e960b4ccd5929bac729d10ea9a800bad3dc572fd`](https://github.com/operatorstack/intelligence-flow/tree/e960b4ccd5929bac729d10ea9a800bad3dc572fd/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index 784785c..001be46 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", + "source_commit": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" }, { "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:64d586468baf5a7b45a2debbfc747b0d9ec9a233" + "last_verified_version": "source:e960b4ccd5929bac729d10ea9a800bad3dc572fd" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 5607855..37003df 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": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", + "source_commit": "e960b4ccd5929bac729d10ea9a800bad3dc572fd", "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-26-detached-context-and-guard.md b/release-notes/2026-07-26-detached-context-and-guard.md new file mode 100644 index 0000000..90d7f7a --- /dev/null +++ b/release-notes/2026-07-26-detached-context-and-guard.md @@ -0,0 +1,31 @@ +### Detached Supervision can guide and guard a live coding session + +Detached Supervision keeps Boatstack's controller state outside the repository. A +coding agent working in a detached repository cannot read the in-repo references it +would normally rely on, and a guard that runs for every repository must not control +the ones you never attached. This change adds the two pieces that let a live session +run under detached supervision. + +First, a bounded context projection. `boatstack-helper context --repo .` returns a +small, read-only view of the current supervisory position for the operation about to +run: the ownership mode, the attached repository and its verified delivery state, the +active slice, the recommended next operation, and the exact next command. It reuses +Boatstack's authoritative resolver and deterministic next-move oracle rather than +asking the agent to reconstruct the workflow, so the guidance is identical to +embedded mode. An attached repository whose binding no longer verifies reports a +blocked position with one recovery action instead of a normal one. + +Second, a developer-level guard entry. `boatstack-helper ambient-safety-hook` runs +Boatstack's full safety policy only on managed repositories — those with a detached +attachment or an embedded install — and allows everything else with no Boatstack +decision. This lets a single user-level hook protect your attached repositories while +leaving every other repository you open completely uncontrolled. On a managed +repository the guard's decisions are exactly those of the in-repo guard. + +To wire the guard, `boatstack-helper activate --repo .` prints the exact per-agent +developer-level configuration to add — the config location and the precise +Boatstack-owned snippet — for every supported coding agent. It never rewrites your +global host configuration silently, so activation is transparent and cannot clobber +your existing hooks. + +Both surfaces are host-neutral and behave the same for every supported coding agent. diff --git a/release-notes/2026-07-26-detached-supervision.md b/release-notes/2026-07-26-detached-supervision.md new file mode 100644 index 0000000..48e0f84 --- /dev/null +++ b/release-notes/2026-07-26-detached-supervision.md @@ -0,0 +1,35 @@ +### Attach Boatstack to a repository without adding Boatstack to it + +Until now, using Boatstack meant adopting its control plane into the repository: `init` +wrote `.boatstack-project.json`, a `.product-loop/` tree, host adapter directories, and a +pull-request template into the working tree. That is the right choice for a team that owns +Boatstack, but not for a developer who wants a personal delivery supervisor on a repository +they are only evaluating, a client repository, an open-source checkout, or a large monorepo. + +Boatstack now supports a second ownership mode, **Detached Supervision**. In this mode the +controller — configuration, plans, delivery state, operation and mutation receipts, evidence, +flow traces, generated references, and the runtime — lives under an external, developer-local +control root, not inside the target repository. The repository stays free of Boatstack-owned +files; the supervisor still changes it only through the same approved product and delivery +actuators. + +Three new commands manage an attachment. `boatstack-helper attach --repo . --mode detached` +inspects the repository, detects its test command, and writes the controller state and a +binding to the external control root, leaving the working tree and `.git` byte-for-byte +unchanged. `detached-status` reports whether a repository is attached and whether its binding +verifies. `detach` removes the attachment, and its state unless you pass `--preserve-state`. +Use `--state-root` to point at a specific control root; otherwise Boatstack uses the standard +per-OS user state directory. + +The layout is host-neutral: it works the same for every supported coding agent. Every +repository is bound by a stable identity derived from its origin and history, so one +repository's controller state can never be applied to another, and two worktrees keep +isolated mutable state. If a bound repository's identity no longer matches — a corrupt or +mismatched binding — Boatstack fails closed rather than silently rebinding. The safety guard +now protects the external control root from direct model mutation exactly as it protects the +embedded runtime state. + +Existing embedded installations are unchanged: every controller path now flows through one +resolver that returns today's exact locations in embedded mode. This release delivers the +attach/detach lifecycle and the detached control plane; host activation for a running coding +session is delivered separately.