From 7e2f64c110ef437bbc6c647b0a649bdca34f8876 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 9 Aug 2026 16:37:19 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 4f00d6d7338b --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 86 +++++---- boatstack/attach.go | 18 +- boatstack/capture.go | 4 + .../cmd/boatstack-helper/command_trace.go | 107 +++++++++++ .../coverage_conformance_test.go | 29 +++ boatstack/cmd/boatstack-helper/flow.go | 16 +- boatstack/cmd/boatstack-helper/main.go | 41 ++++- boatstack/command_trace.go | 118 ++++++++++++ boatstack/command_trace_test.go | 73 ++++++++ boatstack/delivery.go | 4 + .../denial_solutions_conformance_test.go | 2 +- .../detached_ownership_conformance_test.go | 4 +- boatstack/export.go | 6 +- boatstack/flow_control.go | 30 ++- boatstack/flow_drive.go | 12 +- boatstack/flow_drive_conformance_test.go | 22 ++- boatstack/flow_prescribe_conformance_test.go | 30 +++ boatstack/flow_report.go | 69 ++++++- boatstack/init.go | 16 ++ .../internal/deliverycontrol/commandlog.go | 114 ++++++++++++ .../deliverycontrol/commandlog_test.go | 37 ++++ .../internal/deliverycontrol/trajectory.go | 17 +- boatstack/journey.go | 4 + boatstack/migrate_test.go | 10 +- boatstack/planning.go | 55 +++++- .../planning_first_write_conformance_test.go | 3 + boatstack/planning_test.go | 16 ++ .../planning_transport_conformance_test.go | 31 +++- boatstack/pr.go | 121 +++++++++++- boatstack/pr_test.go | 86 ++++++++- boatstack/references/workflow.md | 2 +- boatstack/run.go | 43 ++++- boatstack/run_test.go | 21 +++ boatstack/statemap.go | 2 +- boatstack/update_publication.go | 31 ++++ boatstack/update_publication_test.go | 33 ++++ boatstack/visual_evidence.go | 173 ++++++++++++++++++ boatstack/visual_evidence_test.go | 63 +++++++ docs/evidence-engineered-coding.md | 4 +- docs/public-claims.json | 24 +-- labs/diagram-json/plan.lock.json | 2 +- .../2026-08-09-boatstack-delivery-frontier.md | 5 + 43 files changed, 1450 insertions(+), 136 deletions(-) create mode 100644 boatstack/cmd/boatstack-helper/command_trace.go create mode 100644 boatstack/command_trace.go create mode 100644 boatstack/command_trace_test.go create mode 100644 boatstack/internal/deliverycontrol/commandlog.go create mode 100644 boatstack/internal/deliverycontrol/commandlog_test.go create mode 100644 release-notes/2026-08-09-boatstack-delivery-frontier.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a22f236a..951e2a41 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/53f0f064a116c6d0edbacd2b07dba935481bba96/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/4f00d6d7338b12116fd5757238dd45a2ae344237/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 c83db56b..49da42c0 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 97918, - "estimated_tokens": 24480, + "characters": 98168, + "estimated_tokens": 24542, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "3fd277a4f4bc4ea63b33094aa52671235609e363a0073ac949371b67172b4c38", + "CONTRIBUTING.md": "49b598c71f472381ef204dc952e6087c9a820c4ba7d2c3cba91a3e04756a64a1", "README.md": "534091974042589c31978080b0268761164f2279850f02c30e07f48945ef2321", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -35,32 +35,35 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/attach.go": "640616e4f08a72d045aa6f420fb47bbc99f0cf217f18479a88b39c771598613e", + "boatstack/attach.go": "cb4f7655db661bb7817ac582280755bffa73e0798d7f9890781001923775dccb", "boatstack/authority.go": "2ed62d4e9a93cbca946f41a5dd9292d00bb4de2ab030a13fa9f554701fdfa2f7", "boatstack/authority_test.go": "5e0d96b9f095170cf3288bd58a9b5394acb4f757a527639b91f96383c7eab3b9", "boatstack/autonomy.go": "45091e2451b9f8862c5620e13f2cec1e990161aec54cf9ce5073084f3de85c9a", "boatstack/autonomy_conformance_test.go": "eb0e880671e3304f5bc3f3d6ebda4b46fab824d2ef1289ffbbd7c486c7be3593", "boatstack/capability.go": "270288270ac9689551232e772d1656acbb4c3b2e730c441beb7064b53dd21836", "boatstack/capability_test.go": "4537db261b48311f6fd50f4bdf7c88b0795407dcb2ea627273d8fbd167c40892", - "boatstack/capture.go": "36b6e2b0b297dd42840a85ae765f7f0c0575739016ce329872dd909d8db78337", + "boatstack/capture.go": "c56823941107b88700720777c6a9094e28fa838042df43eb950cdccc50752428", "boatstack/capture_test.go": "001f2268773cc57abcec1737fc0336e63bd7a71eb2f34336d4c88531912222d3", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "188a6d60b819e1594e45468262f8e868e56c14ebec20d81de1c002b5db7e67e7", - "boatstack/cmd/boatstack-helper/flow.go": "0d41c7a86b49004e897f59551780220841d5941396202c81de97a4d52f593520", + "boatstack/cmd/boatstack-helper/command_trace.go": "85d22619cfcbb601f1d07d91f261405d28f62f475f0a8f9825c842199606046f", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "82115b8dbe3a3c2710865c782b71c303097b521ba590c1032712caed1a20ff51", + "boatstack/cmd/boatstack-helper/flow.go": "847ef8b83a757d7d2bbc03485641f82f69ae657be8f07a29b62ed796cb9ba0d2", "boatstack/cmd/boatstack-helper/insight.go": "a6bb2afbf631eecd6005662e71b9956950fe2e56b1522dfd6ca93092d9a8d729", - "boatstack/cmd/boatstack-helper/main.go": "e720856950d3456a33f5527ffeb17c433be83a48cee6b797fc46ccd3ece2fee9", + "boatstack/cmd/boatstack-helper/main.go": "bd11b2a4bf7fb24fcc96a5e769aa0436ba63a9a011a9fe565ba3562e42b42d99", "boatstack/cmd/boatstack-helper/main_test.go": "4370dedc65cfc1570857c46a5280fcc05c6c4d0812cf19b66a485222829d8bbb", "boatstack/cmd/boatstack-helper/retro.go": "68b83e33ade5b5fec126c70ec798fdcbed22fda755dc1cad2758143fead8e187", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", + "boatstack/command_trace.go": "dae0dd65c302474146b6d43e8c560f51433b587e8d0ade12c47dd079e29a5b3b", + "boatstack/command_trace_test.go": "36e67eccb864d0fc6ecdb05ca88bfc3853eb5d33bbbe5317e48df64bf3ddc2b5", "boatstack/compiled_artifact_resolution_test.go": "0748d67643263e698211eb04d46464e1dd3db15d94537f5fd5092b5aa689745b", "boatstack/config_documentation_test.go": "27a39268ff782d0e7e8bdb1247a1139ada8b1e65650edf6ffb4c5c941f7f8a20", "boatstack/content_effect_conformance_test.go": "ebf4f6d50af0a76722177c717c79d33921948b9c06bec2e9d062769dce32b8aa", "boatstack/context.go": "fcab6ad475603b30bc6a8a59d82e257e74c587636adfe20a2016f48e24019d17", "boatstack/decision.go": "944fceed965396c66c8089edbf73dbe8d4c716da17289e407e45832c91211885", "boatstack/decision_test.go": "ac36687c5012f6d142d89472490e1c5f60c4427470f275afbe3afd4247db2281", - "boatstack/delivery.go": "b21c8893c5fa202d732970d1d9624a430cb3bfd5eee6fe85c4b229afed8412e6", + "boatstack/delivery.go": "d679cba00e6f4f20a3a7c68622717120e0d495c488311d3cae460d6b60ed2aca", "boatstack/delivery_boundary_conformance_test.go": "53dde765046420b9119e82034d137742e600019938ed908c608f725d8a0c84c6", "boatstack/delivery_migrate.go": "4f31a1f2665200e86616e5b9b1cc1d1ba2e46edf0c5b0e9df2e98bab90c3763c", "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", @@ -73,30 +76,30 @@ "boatstack/denial_escalation_conformance_test.go": "d50f0e1c803c8f5731a46dbe6f82c7935c0ccbd582f513cbe07211f5e902157e", "boatstack/denial_ledger.go": "a35bf8fd8c1f6b9302109cf0087e1b9158e43e07b92b18df5e589a637d58491d", "boatstack/denial_solutions.go": "097141290cc684ea688887ae37dfdbbf3cffecf43fe12f64f22e09496d71cee6", - "boatstack/denial_solutions_conformance_test.go": "aace80640a4e0d331263bb3f82172ad8ff2e4a27e0544000fca3d475a8afb9aa", + "boatstack/denial_solutions_conformance_test.go": "9e8aec866e84734aecb642973bb3f77266bc6ae697d0ff36fd3715952f7ef4b5", "boatstack/denial_test.go": "9dc9f0f79328c4947073efaa785479b34e70eb214da57cd72348f39fd672e4fd", "boatstack/detached.go": "3e3f2b81e2d79107ede2adc55fa296d0f487aa90d8d847d3cac2aa82120c3b12", "boatstack/detached_external_config_conformance_test.go": "6554739e80d32672f472fa55b1599e9594f4ba8790eb9939c3aef9e883316f1c", "boatstack/detached_migration.go": "01a45e392307d14033fbf40aca379d91ce745ef0f386cc57a6273275386fc2f7", - "boatstack/detached_ownership_conformance_test.go": "9a6044d33d3a49c916241846e51a4411cba6e64ddef8f142cc5627af3caad6dd", + "boatstack/detached_ownership_conformance_test.go": "f0ba5b20af9922a4cb7545c77869e081b431ff7b2c6921a2f89333ee0cc2bbb4", "boatstack/detached_test.go": "2cd744335a80b9fbc2db8fa7955658dd31691c43150d67bf15029d06ce84277a", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "86eeea828a0bb354114a6606aeb2a1bb1f374c10f7af0363797e5406ab69a290", + "boatstack/export.go": "afe5c2b60415076245ba0eeffafc3a2aaf3cf283ac120761706269ba388b9f34", "boatstack/export_test.go": "076cc6c31ceadbc8c4ba0ebdff047490a84a2cd8d7638ddfbb2835800fdc65d0", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "75de1b2fcb2468c6a36de3c31c4da95d31f8ccf14d211583386e57b2d2a9041c", + "boatstack/flow_control.go": "55abf4e7110d65e945411a00546ae66c47190f44ec74670baa091ac6d2ac52e4", "boatstack/flow_control_test.go": "02d788c83be55ebd79ffc73875bfd019de45325151eb1f70f506980eb8e77f29", - "boatstack/flow_drive.go": "90f57e178884aff017195a126954ac0aeb85f27707b9d42844323341dc1fefd9", - "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", + "boatstack/flow_drive.go": "e0512548952d14d1893a15045151bb8380ce71b71cc20aa8970e984223f3f5a1", + "boatstack/flow_drive_conformance_test.go": "e86609b2ac4a4a00230e2da583392145c1b4df6a0539d9d23b7e72f277910548", "boatstack/flow_frontier.go": "c69a225abd32306ce3da94e7566cc99ef28b098230a9c965294d7bc5f0f7507c", "boatstack/flow_frontier_conformance_test.go": "771838f06d6157547c1277eca1f7c1df609bb9f3ae630b2fa509f641b49aba86", "boatstack/flow_guard.go": "dd18524d95f4a220cfd3d11b11003dacc52120785ee0ccdbeceb2307fab55872", "boatstack/flow_guard_test.go": "8ba75f11ddd080427c15bd7e25f7d03c1b746a2f587c212cea0e710337d1c0e1", "boatstack/flow_planning_prescribe_conformance_test.go": "c2fa2566b0676f34a777764ade87a0670d41413d052a9a339e01bdb6a9a8699b", - "boatstack/flow_prescribe_conformance_test.go": "42dc20b26472531deedcaace94cf0ca5d086f0d76d1d91b37e38fc0138283ddf", - "boatstack/flow_report.go": "a31d0764725eb38f717bab1e5509da82860d3bed23dd2b65ff388bb39573f418", + "boatstack/flow_prescribe_conformance_test.go": "3de0fb4e19833efec961f4aeae384f328fa0837e65aae147401e5be4656d08b4", + "boatstack/flow_report.go": "2b7058350c75471b425fd4aa0fcc5ae295b39d74b37f500741bb10ae5dad2f6c", "boatstack/flow_report_test.go": "ec989f2f14c7ae52840f61822a11dd0ac91055390f775d6a4b04da4993166b50", "boatstack/flow_solutions.go": "f9674a970ca018b33865b49809c7e3aee138d06420860a98e5e3feaeba3c2299", "boatstack/flow_tasks.go": "a3a8699ac0fd6cda3a3420acf83cc5881c5a821b17a3f9cf0d20da460c6e5252", @@ -113,7 +116,7 @@ "boatstack/hooks_hydrate_test.go": "7beeb26b2b1398741e8a28963a9686e974047016cc736f233024004add1afc32", "boatstack/hooks_test.go": "fb75e3aabf2204871b3e6d16de98d26fb33b0ec19e41aae761cf1f34397c31f4", "boatstack/hydrate_runtime_test.go": "dbd5eae2ba85701e4af0430ba3a0d70ea98e028b66992bd4fc05f3f582398627", - "boatstack/init.go": "0cc4cf8d74a6be419759697d412f3748899315904eb30093bddb50cc0595e339", + "boatstack/init.go": "407376d67cac4faa31313739e5189f0ca71950dc00a0983ced47ec5f8a827560", "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/insight.go": "7ec492b65043f7b10dae9dd55104d22bc56775759b8c3fb288545dede01b9f89", @@ -126,6 +129,8 @@ "boatstack/internal/deliverycontrol/coding.go": "fe312c1aee11edcdbf24d7d525f4baecbac59c0a57125b1a5e356df9d295d657", "boatstack/internal/deliverycontrol/coding_test.go": "58ba20bc4b1ca97f841523b73075dc953598263cd888f1d13c24e081fb0f8750", "boatstack/internal/deliverycontrol/codinglog.go": "35cc491c1684b064cb260b0b6e889bc64f9f416a2a300dfb6ac5bccb2188ec8b", + "boatstack/internal/deliverycontrol/commandlog.go": "dac82bbf0c7cfe240a41a400830a6f6a9fe428fc34c9bbd689c664ff843fef60", + "boatstack/internal/deliverycontrol/commandlog_test.go": "21d9fd9ca4baff9742eb1c98d1772a4c7b1c0fa5dae68810201b3765692f2dd1", "boatstack/internal/deliverycontrol/cost.go": "a0a22292b8ed55cbfce9808599449d5128ae5b67ef6adc4881e604db0897f3f0", "boatstack/internal/deliverycontrol/graph.go": "13367b068d0004e0f2e857e7b6e9d19e758ef345070b3fbc008644be27438902", "boatstack/internal/deliverycontrol/liveness.go": "23601670005085c62b9e7f4bd7eab74f340d622b2d80588f5cc90126307c8a8a", @@ -135,7 +140,7 @@ "boatstack/internal/deliverycontrol/registry.go": "4b07d68fdf4d12fed278e4d7eb04546810f32941c55a22e4171ffc14f664a0c0", "boatstack/internal/deliverycontrol/registry_test.go": "473ab5e5d33f84d34c29a219db867abfc6eb3ad4489f3d5d0c7dc09b06d193f3", "boatstack/internal/deliverycontrol/state.go": "2551624bbcbd8f9dd897a1e2240cef2cc1895d117a4030525d88f1d62f6e395e", - "boatstack/internal/deliverycontrol/trajectory.go": "0163ebe712f66205d1639774656bbb1c3159c42df2406c1ce33cc3357620d228", + "boatstack/internal/deliverycontrol/trajectory.go": "663f19ca1e9c9125d13ba192fb0bab7355d04fa0ff5c7ad14c5411012e55740b", "boatstack/internal/deliverycontrol/trajectory_test.go": "df5be9a8f55b09a94b0f6b94d2847180d39d357619eb4d1d11181015935c2f96", "boatstack/internal/deliverycontrol/trajectorylog.go": "a1da7e7252b33f63f232c101de683b4a515e80243801caf69fda53d24233e42f", "boatstack/internal/deliverycontrol/trajectorylog_test.go": "227dd6ed9ce181d517a37b67ef4d64dd93779a533eae804798ab54de35c7f13e", @@ -148,12 +153,12 @@ "boatstack/internal/retromine/retromine_conformance_test.go": "33883893db1e455901d9c0e93767c39b475fae728aa9ee9dd925a2c21e5b0ec4", "boatstack/internal/retromine/testdata/session-alpha.jsonl": "45ed8fa691af9db3f957e87175dbbeffd8fdd2f001324e86a5fd68e29ffbe244", "boatstack/internal/retromine/testdata/session-beta.txt": "8e85e6a4442e3c892166df97ec879d998b3a47bdad5ef2bf785674b3149c36c2", - "boatstack/journey.go": "a6d3d6f9873288a629a0fa59836c54a62046522e266b40962f7a404664f57509", + "boatstack/journey.go": "a17680218775c493bf01783f4f40aa523139260a5bc5aeef7838dd493b82d1cc", "boatstack/journey_conformance_test.go": "8acc6b93279b41ba1f56be64a4f598e4c505bd19bc98a1082de949d2fbe97f23", "boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3", "boatstack/migrate_effect_grade.go": "bccb58e770001aa9554d8e7f151663d907f152508a61118f56fd90465ba6f32e", "boatstack/migrate_effect_grade_test.go": "fea1d1057bc6d8eaf015e377864a3adab29ef5731f597fe0a38b96fa80355d14", - "boatstack/migrate_test.go": "9f4bda2fb158c5e54bcc0242dace1da3c1965f9846a213c573956a35b7d1724e", + "boatstack/migrate_test.go": "f2b18071bf1698aaebb13275fd01e89b8f09e5920e633f54755e6924d86ac4b9", "boatstack/mutation.go": "c4dce871f63f259a7bb5521c702982c0a135fc97b7928f7849cf30396cde80b8", "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", @@ -171,16 +176,16 @@ "boatstack/plan_test.go": "1b01e7d9d7794eb11c998e19a2f3532d3b8509d984eca934cf5f1662a0a7e573", "boatstack/plan_validation.go": "06ff8fa8c22525bd371848a7776682a2b041ea5e1aacc913170856d0dda83edd", "boatstack/plan_validation_test.go": "ce17eb7449c1d9ec2e829943082bb9680414e02b8f0d5049418bace5488bbedb", - "boatstack/planning.go": "2f1f28930740d5139d5838ff250ed329676c02f1b41500ab1494bc150d8cbfd4", - "boatstack/planning_first_write_conformance_test.go": "eb8725417ea5704dff89d09d03602918e1d5f8eeac7e7002cbb4518aaa80104f", - "boatstack/planning_test.go": "c5966d293a0e71b0c8b4391368b837e807737b9fa7c5c2604758240ac6c03017", + "boatstack/planning.go": "fcafc8b6743a2497702627913f03aab0fa44cda4f36e0c13a21320f2e3aa7182", + "boatstack/planning_first_write_conformance_test.go": "10f310fdc22cd633c48cbefe6a09ad99ca8a71f120f447eab92eebdf09c929ee", + "boatstack/planning_test.go": "6a332cbb3de002b0a41b2ef56d6907ec3e5ed540994d88e8c46d28ce362f9cb6", "boatstack/planning_transport.go": "8beaa1cc139e55f1899dc7ae4280ac18183b1a87c1ef22122d63e2ba55bc7434", - "boatstack/planning_transport_conformance_test.go": "e1ac2c9d410f07037dec0d065aed2117957a88ca99aa0b0403772bf14ead0a77", + "boatstack/planning_transport_conformance_test.go": "13a36d6c4564d8162327de310de4aa928aaf4271a231f8ce218f3337457c0347", "boatstack/post_publish_prescribe_conformance_test.go": "3c20d359ff84648db7dedb227b4d64e6574d9f41d3cdca0adefec1c60bfbf4ae", - "boatstack/pr.go": "843f83e4a5d9ad0e997c6d8bd5795b6e8324f34f0f4264d2500933f1bcf36917", + "boatstack/pr.go": "a4f4580dbf72079286e11fc9aaf5a0071becd285f83fefcbef4dc1e2c5727adb", "boatstack/pr_phase.go": "59f8cbb75b6b538a5345474acd6a725450979579bf8ecf9591956cbbe1cc4737", "boatstack/pr_phase_conformance_test.go": "bc9c834e9c4ed43b35d81abafd7b1bf2a264ea2a8c4a4ec9758ee18d1d438968", - "boatstack/pr_test.go": "ab99ba83bd33fe10e1fca61c0f164f0a59711ba2fa42ab3e7f870a0ed3dcc890", + "boatstack/pr_test.go": "aac5376c0e9dc9f6569471b2d214a21256781dca8f8c8d1f098b7109df3829ce", "boatstack/provenance.go": "d44dcd5421306269326f1202ba1d52df8c252490550270ef9d022e8ec2b65210", "boatstack/provision.go": "9531f975f09f61ba3eed120ca7518cdc53b1e0df9f45a4da2bd7f1f8d96984fe", "boatstack/provision_test.go": "70199eac574cc8843ce12f2bad58b7fea0f86a4205a6d3be0a96594abd967b5d", @@ -199,15 +204,15 @@ "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "b52fef435362d2e80ab5569a0d145d5dd840ebd7cc77f02505b19588a7786386", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "cf25e53a58279cf7b3e1d8b3619a76ffc8b947e7035fcef31f1ccf21e420e772", + "boatstack/references/workflow.md": "a7afbeddb799dafeca5b79364a5d9071a18a32bd867bf80080067a22a7a94f07", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_budget_conformance_test.go": "05793600dac06bbf39075b15bc1262a9bd1e916738d801dcd6ff515771262b4c", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", "boatstack/retro.go": "8a6f13b948574c90d0f06c3b9f5570d08931e66a4c78dbcc208da8c696c2a42b", "boatstack/retro_conformance_test.go": "827250d2fc49717fb5e58a4cf79e1d5c489c37574d8a2cf9348fa1cd8c328713", - "boatstack/run.go": "9def8566965735e7b028235ecfe01e525b06fe42c4948fca7f5572430b0b1e47", - "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", + "boatstack/run.go": "7f25b0a271cb9dc68b4d207b7a4cf067ac2293a2cb9ebdfed6c6c33019ed7cfc", + "boatstack/run_test.go": "a9062d3e38a9a2d66ad229b95a5b80ad78945e372bb2992604477679993233db", "boatstack/runtime.go": "6cb321617d81618672391dd1988bd7f6cd4e4bdbdc8c68d6b458001e2f33bc3e", "boatstack/runtime_cache.go": "6f6b023170cce982bf155e7c2fc7752cca2f7acff771967b4a523c1c13ea876f", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", @@ -219,20 +224,20 @@ "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "5ebf971d2fb0d02b2144a47883067bca7c89863e4285933dfac6f2c5816d560a", "boatstack/solution_closure_conformance_test.go": "366c0f7d8ed427717572da38ad623ad50f14276af62cdc115939042d040a3ab4", - "boatstack/statemap.go": "0db3a980f2fd00538498f6599b488a6719a5ee0f0764172834165e7d9d8f8057", + "boatstack/statemap.go": "477198ad04df5c20710633bd415009036ef0e208775cc85fefcc32a3cbf50508", "boatstack/statemap_conformance_test.go": "504debd406c1b5ad6cc9cb38715dea08954ef9c258cb5f91c59fb04d1398e53e", "boatstack/supervisory_control_test.go": "c7ea4bcd678e8ec211dac772c834981c4e21762914be2770a5e181bc24605e06", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", "boatstack/update.go": "cebd3a3300055fe1fa7646e8c8182662a066771afd44e35cb5d64530f52e26e8", - "boatstack/update_publication.go": "5c1ac8445345c6546d165b9321a736453b313ced96a0059465b8ad637498bac6", - "boatstack/update_publication_test.go": "c5f32578db53be65e35452d5e8b4520884354e4e80a370dcec19860ea644d091", + "boatstack/update_publication.go": "6c0ad85a7e418d1f5aa2911ed0875f9ca180f115763f6e93e68948d196b8eb39", + "boatstack/update_publication_test.go": "e96691cfee9ec8416756efbab61cf63eac982d2698704585a89f9bad1feca41a", "boatstack/update_test.go": "bf5f19f8499db6dd7356917d867b113b790548ab89bfdea59e2adf4999a82a6a", "boatstack/visual_attach_conformance_test.go": "04d9723e3575d82296d370693929dadc6e795c7f715e3fab8c6387058893bc37", "boatstack/visual_delivery_strengthening_test.go": "8bf111f7dddd7bfefb79fe59b1d8cbf705e20c91519548ca8412eab73c69ded1", - "boatstack/visual_evidence.go": "ce95234097cbce211ec702d736d1fcc8acbf6c1de295f40e887ea0428fc09eb8", - "boatstack/visual_evidence_test.go": "2a5fcffa9218edbf86c8bddef25d8ba7b64013110ba8fa88e8a0b7587cc2e7cf", + "boatstack/visual_evidence.go": "acd7ac94bc080b1cbf0e064fb9c27b4ea2e16634bb0256f6aacc92d093b5425c", + "boatstack/visual_evidence_test.go": "9366ee6c8358df2e9b85bd6641d834711b0fdf4b34688668377d1c51feea36b8", "boatstack/visual_publisher.go": "0eaa79b7faf022b7ee6460390a6ff7d879ace8b99e9aabf2fe5be8b8f7edc096", "boatstack/visual_publisher_test.go": "862bb55ae5d0cd1296f4007256fb6a2da29f7c91bd8b2f6cba5f13e6671de78d", "boatstack/workspace.go": "79f472d0d10794bf5193518d0c2798d2f5e7e530226e02fb33f99840110f995f", @@ -245,10 +250,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "2ceb050bb67737c725b66d7e191949c2f3b652ecbafc5e44002e99bc785114ab", - "docs/evidence-engineered-coding.md": "b48fed361457a1a12b426a277447b946096d839956e7d7a8058aa13ab36a0da9", + "docs/evidence-engineered-coding.md": "0c8a4d6e5cc9a5410cee2fd69a28cee10ddd6d7d283183350b44268016020f2c", "docs/generated-files.md": "8679b960bacbdf2ca7b898aa44eb3a486ebb325eca8ce9cc4e316191e7ef5087", "docs/getting-started.md": "834e6d1c33d5198743a3f896c4e205801713762dcd2fa339e47c99b532df2cf5", - "docs/public-claims.json": "93cc3bba122ad9519f1a280e8d0ef5bddafdb59efae08e9f8a5be631e441a6a0", + "docs/public-claims.json": "26759221477976df58b438490edba03e678efe4e923579a20063ba1921f6f04c", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "1a2b84e0a4b9aa6322d35d6677ff52662c306089031295692b569233cdd94d8f", @@ -262,7 +267,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": "95dfd06cfc3e25dade87d9770659f06904d98a55d95f666948d3f8a24ed6e339", + "labs/diagram-json/plan.lock.json": "8d1c75a8893ba0b8cbb792a65a7a8a4092c8c0df636e3b2df8377b6a2eefd6f3", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -419,12 +424,13 @@ "release-notes/2026-08-04-external-authority-boundary.md": "0bc788db940f7fbc3624a01fff554d7002137e9915b10c47fd287dc1b24cdd66", "release-notes/2026-08-05-codex-operation-skills.md": "2402adbe3ef21762418648f6ac19ba282b8c9524546369e3f8aa76ad33d2c53d", "release-notes/2026-08-05-detached-external-config.md": "b8b5ab914eae695f67c4e25deba3957894439e054b42ab93751bccb614235fda", - "release-notes/2026-08-08-literal-planning-transport.md": "d1abc9e64cb724fffa71fd81eeab2bb07bb982be28de1a50a845016cd878560a" + "release-notes/2026-08-08-literal-planning-transport.md": "d1abc9e64cb724fffa71fd81eeab2bb07bb982be28de1a50a845016cd878560a", + "release-notes/2026-08-09-boatstack-delivery-frontier.md": "7b75d8d4d584fde715a0610d0432220053024c5febffaaaf8c2ae7b3a1445dca" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "53f0f064a116c6d0edbacd2b07dba935481bba96", + "commit": "4f00d6d7338b12116fd5757238dd45a2ae344237", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/attach.go b/boatstack/attach.go index b076d7e6..ee834b86 100644 --- a/boatstack/attach.go +++ b/boatstack/attach.go @@ -18,6 +18,10 @@ import ( type AttachOptions struct { Repo string ConfigPath string + // BinaryPath is the already verified helper to install into detached + // controller state. The CLI leaves it empty and uses its running binary; + // tests and embedders may bind an equivalent verified helper explicitly. + BinaryPath string Force bool } @@ -174,11 +178,19 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { // Populate the external shared-runtime slot from the running helper so the // developer-level ambient guard has a stable helper to invoke. The binding is // written above, so WorkspaceFor now resolves detached and the slot is external. - if source, execErr := os.Executable(); execErr == nil { - if _, runtimeErr := installDetachedRuntime(root, source); runtimeErr != nil { - return blockedAttach("Boatstack could not install the external runtime: " + runtimeErr.Error()), nil + source := strings.TrimSpace(opts.BinaryPath) + if source == "" { + source, err = os.Executable() + if err != nil { + return blockedAttach("Boatstack could not locate its running helper: " + err.Error()), nil } } + if _, runtimeErr := installDetachedRuntime(root, source); runtimeErr != nil { + return blockedAttach("Boatstack could not install the external runtime: " + runtimeErr.Error()), nil + } + if _, _, runtimeErr := installControllerLocalRuntime(ctx.ExportRoot(), source, config.Integrations); runtimeErr != nil { + return blockedAttach("Boatstack could not install the detached controller helper: " + runtimeErr.Error()), nil + } return AttachResult{ SchemaVersion: detachedSchemaVersion, diff --git a/boatstack/capture.go b/boatstack/capture.go index e8242a3a..1146a121 100644 --- a/boatstack/capture.go +++ b/boatstack/capture.go @@ -128,6 +128,10 @@ func CaptureEvidence(options CaptureEvidenceOptions) (PRVisualEvidenceManifest, if base == "" { base = defaultPRBase(repo) } + base, err = canonicalPRBase(repo, base) + if err != nil { + return PRVisualEvidenceManifest{}, err + } headCommit, diffHash, err := captureProductDiff(repo, base, feature, head) if err != nil { return PRVisualEvidenceManifest{}, err diff --git a/boatstack/cmd/boatstack-helper/command_trace.go b/boatstack/cmd/boatstack-helper/command_trace.go new file mode 100644 index 00000000..e09b38b5 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/command_trace.go @@ -0,0 +1,107 @@ +package main + +import ( + "strings" + "time" + + boatstack "github.com/operatorstack/boatstack/boatstack" + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +type commandTracePolicy struct { + Category string + ExcludedReason string +} + +// commandTracePolicies is the declared helper surface inventory. Safety hooks +// are intentionally excluded because telemetry must not add latency or writes +// to the enforcement path; every other dispatch is recorded once by run(). +var commandTracePolicies = map[string]commandTracePolicy{ + "attach": {Category: "supervision"}, "detach": {Category: "supervision"}, + "detached-status": {Category: "supervision"}, "context": {Category: "supervision"}, + "activate": {Category: "supervision"}, "deactivate": {Category: "supervision"}, + "init": {Category: "installation"}, "update": {Category: "installation"}, + "check-update": {Category: "installation"}, "repair-status": {Category: "installation"}, + "prepare-update-pr": {Category: "update-publication"}, "publish-update-pr": {Category: "update-publication"}, + "release-classify": {Category: "release"}, "next-patch": {Category: "release"}, + "export": {Category: "installation"}, "migrate-config": {Category: "installation"}, + "hydrate-runtime": {Category: "installation"}, "doctor": {Category: "readiness"}, + "check-source-plan": {Category: "planning"}, "check-plan": {Category: "planning"}, + "planning-write": {Category: "planning"}, "record-approval": {Category: "planning"}, + "record-autonomy": {Category: "planning"}, "activate-plan": {Category: "delivery"}, + "delivery-status": {Category: "delivery"}, "next-status": {Category: "delivery"}, + "recovery-status": {Category: "recovery"}, "repair-state": {Category: "recovery"}, + "mutation-status": {Category: "recovery"}, "undo": {Category: "recovery"}, + "run-preflight": {Category: "readiness"}, "authority-context": {Category: "readiness"}, + "record-change": {Category: "recovery"}, "record-journey-results": {Category: "evidence"}, + "ignore-delivery": {Category: "delivery"}, "discard-delivery": {Category: "recovery"}, + "record-delivery-gate": {Category: "delivery"}, "record-pr-visual-evidence": {Category: "visual-evidence"}, + "review-pr-visual-evidence": {Category: "visual-evidence"}, "capture-evidence": {Category: "visual-evidence"}, + "provision-capability": {Category: "capability"}, "capability-register": {Category: "capability"}, + "record-pr-visual-publication": {Category: "visual-evidence"}, "attach-evidence": {Category: "visual-evidence"}, + "pr-context": {Category: "publication"}, "check-pr": {Category: "publication"}, + "publish-pr": {Category: "publication"}, "operation-status": {Category: "publication"}, + "diagnose-hook": {Category: "diagnostic"}, "render-denial": {Category: "diagnostic"}, + "check-safety": {Category: "readiness"}, "workspace-cut": {Category: "workspace"}, + "workspace-cleanup": {Category: "workspace"}, "workspace-reap": {Category: "workspace"}, + "workspace-status": {Category: "workspace"}, "workspace-sync": {Category: "workspace"}, + "flow": {Category: "flow"}, "retro": {Category: "analysis"}, + "insight": {Category: "insight"}, "version": {Category: "diagnostic"}, + "safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, + "ambient-safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, + "bootstrap-safety-hook": {Category: "safety", ExcludedReason: "latency-sensitive enforcement path"}, +} + +func traceFlag(arguments []string, name string) string { + for index, argument := range arguments { + if argument == name && index+1 < len(arguments) { + return strings.TrimSpace(arguments[index+1]) + } + if strings.HasPrefix(argument, name+"=") { + return strings.TrimSpace(strings.TrimPrefix(argument, name+"=")) + } + } + return "" +} + +func traceTransition(verb string, arguments []string) deliverycontrol.TransitionID { + if verb == "record-delivery-gate" { + switch strings.ToLower(traceFlag(arguments, "--gate")) { + case "test": + return "delivery.record_gate_test" + case "review": + return "delivery.record_gate_review" + } + } + for _, transition := range deliverycontrol.Transitions() { + if transition.CLIVerb == verb { + return transition.ID + } + } + return "" +} + +func commandTraceCompletion(verb string, arguments []string) func(int) { + policy, ok := commandTracePolicies[verb] + if !ok || policy.ExcludedReason != "" { + return nil + } + started := time.Now() + recordedVerb := verb + if (verb == "flow" || verb == "retro" || verb == "insight") && len(arguments) > 0 && !strings.HasPrefix(arguments[0], "-") { + recordedVerb += "/" + arguments[0] + } + repo := traceFlag(arguments, "--repo") + if repo == "" { + repo = "." + } + feature := traceFlag(arguments, "--feature") + slice := traceFlag(arguments, "--slice") + transition := traceTransition(verb, arguments) + return func(exitCode int) { + boatstack.RecordCommandEvent(boatstack.CommandTraceInput{ + Repo: repo, Verb: recordedVerb, Category: policy.Category, Feature: feature, Slice: slice, + Transition: transition, StartedAt: started, FinishedAt: time.Now(), ExitCode: exitCode, + }) + } +} diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index bfab5341..1c7849db 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -5,6 +5,7 @@ import ( "go/parser" "go/token" "sort" + "strings" "testing" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" @@ -61,6 +62,7 @@ var nonDeliveryVerbs = map[string]bool{ "workspace-status": true, // Evidence / capability substrate (a separate tenant, not the delivery graph). "record-pr-visual-evidence": true, + "review-pr-visual-evidence": true, "capture-evidence": true, "provision-capability": true, "capability-register": true, @@ -136,6 +138,33 @@ func dispatchVerbs(t *testing.T) map[string]bool { return verbs } +// control-law: every-helper-dispatch-is-observed-or-explicitly-excluded +func TestCommandTraceInventoryCoversDispatchSurfaceExactly(t *testing.T) { + dispatch := dispatchVerbs(t) + for verb := range dispatch { + policy, ok := commandTracePolicies[verb] + if !ok { + t.Errorf("dispatch verb %q has no command trace policy", verb) + continue + } + _ = policy + } + for verb := range commandTracePolicies { + if !dispatch[verb] { + t.Errorf("command trace policy names stale verb %q", verb) + } + } + allowedExcluded := map[string]bool{"safety-hook": true, "ambient-safety-hook": true, "bootstrap-safety-hook": true} + for verb, policy := range commandTracePolicies { + if policy.ExcludedReason != "" && !allowedExcluded[verb] { + t.Errorf("only latency-sensitive safety hooks may be excluded; found %q", verb) + } + if allowedExcluded[verb] && strings.TrimSpace(policy.ExcludedReason) == "" { + t.Errorf("excluded safety hook %q has no reason", verb) + } + } +} + // switchesOnArgs reports whether a switch tag is an index into os.Args (the // command dispatch), e.g. `os.Args[1]`. func switchesOnArgs(tag ast.Expr) bool { diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go index f0eb6733..43e5e3d5 100644 --- a/boatstack/cmd/boatstack-helper/flow.go +++ b/boatstack/cmd/boatstack-helper/flow.go @@ -137,16 +137,15 @@ func driveExecute(repo, feature string, next boatstack.FlowNext) int { // executePrescribed is the driver's second, independent gate: even a move the pure // decision blessed as auto-drivable runs only if an executor is explicitly -// registered here for its verb. Nothing is registered today — every real forward -// move owes human input and is refused by the decision before reaching here — so -// this defends against a future allowlist entry landing without a deliberate, -// reviewed executor. It never synthesizes arguments; it would only ever invoke the +// registered here for its verb. It never synthesizes arguments; it only invokes the // same verb dispatch a human would run. func executePrescribed(cmd *boatstack.PrescribedCommand) error { switch cmd.Verb { - // No verbs are registered for auto-execution. Add a case here only together with - // an allowlist entry in flow_drive.go, and only for a verb whose arguments are - // fully state-derivable with nothing to fabricate. + case "publish-pr": + if code := publishPRCommand(cmd.Args); code != 0 { + return fmt.Errorf("publish-pr exited with status %d", code) + } + return nil default: return fmt.Errorf("no registered auto-executor for verb %q; run it by hand: %s", cmd.Verb, cmd.CommandLine()) } @@ -249,11 +248,12 @@ func flowTasksCommand(arguments []string) int { func flowReportCommand(arguments []string) int { flags := flag.NewFlagSet("flow report", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose flow session should be reported") + feature := flags.String("feature", "", "optional managed feature used to filter command telemetry") jsonOutput := flags.Bool("json", false, "print the structured report") if err := flags.Parse(arguments); err != nil { return 2 } - report, err := boatstack.FlowReport(*repo) + report, err := boatstack.FlowReportFor(*repo, *feature) if err != nil { return fail(err) } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 0f73885b..88ad2da3 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -639,6 +639,30 @@ func recordPRVisualEvidenceCommand(arguments []string) int { return 0 } +func reviewPRVisualEvidenceCommand(arguments []string) int { + flags := flag.NewFlagSet("review-pr-visual-evidence", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence") + key := flags.String("key", "", "managed feature or ad-hoc visual evidence key") + evidenceFingerprint := flags.String("evidence-fingerprint", "", "exact capture manifest fingerprint reviewed") + reviewerIdentity := flags.String("reviewer-identity", "", "human reviewer identity") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *key == "" || *evidenceFingerprint == "" || *reviewerIdentity == "" { + return fail(fmt.Errorf("review-pr-visual-evidence requires --key, --evidence-fingerprint, and --reviewer-identity")) + } + review, err := boatstack.RecordPRVisualPrivacyReview(*repo, *key, *evidenceFingerprint, *reviewerIdentity) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(review) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + return 0 +} + func captureEvidenceCommand(arguments []string) int { flags := flag.NewFlagSet("capture-evidence", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Git-common state owns the evidence") @@ -954,11 +978,17 @@ func runPreflightCommand(arguments []string) int { flags := flag.NewFlagSet("run-preflight", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Git state should be verified before boatstack run") feature := flags.String("feature", "", "optional specific managed feature to verify") + healthOnly := flags.Bool("health-only", false, "verify installation and generated state without Git network or branch checks") jsonOutput := flags.Bool("json", false, "print the versioned structured preflight") if err := flags.Parse(arguments); err != nil { return 2 } - status := boatstack.CheckRunPreflight(*repo, *feature) + var status boatstack.RunPreflight + if *healthOnly { + status = boatstack.CheckInstallationPreflight(*repo) + } else { + status = boatstack.CheckRunPreflight(*repo, *feature) + } if *jsonOutput { value, err := boatstack.MarshalJSON(status) if err != nil { @@ -1563,11 +1593,14 @@ func workspaceSyncCommand(arguments []string) int { return 0 } -func run() int { +func run() (result int) { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } + if complete := commandTraceCompletion(os.Args[1], os.Args[2:]); complete != nil { + defer func() { complete(result) }() + } switch os.Args[1] { case "attach": return attachCommand(os.Args[2:]) @@ -1641,6 +1674,8 @@ func run() int { return recordDeliveryGateCommand(os.Args[2:]) case "record-pr-visual-evidence": return recordPRVisualEvidenceCommand(os.Args[2:]) + case "review-pr-visual-evidence": + return reviewPRVisualEvidenceCommand(os.Args[2:]) case "capture-evidence": return captureEvidenceCommand(os.Args[2:]) case "provision-capability": diff --git a/boatstack/command_trace.go b/boatstack/command_trace.go new file mode 100644 index 00000000..f828da68 --- /dev/null +++ b/boatstack/command_trace.go @@ -0,0 +1,118 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// CommandTraceInput is the bounded, secret-free observation supplied by the +// helper dispatcher. Raw arguments and process I/O are intentionally absent. +type CommandTraceInput struct { + Repo string + Verb string + Category string + Feature string + Slice string + Transition deliverycontrol.TransitionID + StartedAt time.Time + FinishedAt time.Time + ExitCode int +} + +func commandAuthorityFingerprint(repo, feature string) string { + if strings.TrimSpace(feature) == "" { + return "" + } + path := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "autonomy.md") + value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true) + if err != nil { + return "" + } + return stringValue(value["fingerprint"]) +} + +func commandOperationFingerprint(repo, feature, slice string) string { + receipts, err := operationReceipts(repo) + if err != nil { + return "" + } + selected := OperationReceipt{} + for _, receipt := range receipts { + if receipt.State == OperationSucceeded || receipt.State == OperationFailedFinal { + continue + } + if feature != "" && receipt.Scope.Feature != "" && receipt.Scope.Feature != feature { + continue + } + if slice != "" && receipt.Scope.Slice != "" && receipt.Scope.Slice != slice { + continue + } + if selected.UpdatedAt == "" || receipt.UpdatedAt > selected.UpdatedAt { + selected = receipt + } + } + return selected.PackageFingerprint +} + +func resolveCommandFeature(repo, feature, slice string) (string, string) { + if feature != "" { + return feature, slice + } + active, err := ActiveManagedDeliveries(repo) + if err != nil || len(active) != 1 { + return "", slice + } + feature = active[0] + if slice == "" { + if state, stateErr := LoadDeliveryState(repo, feature); stateErr == nil { + if activeSlice, sliceErr := activeDeliverySlice(state); sliceErr == nil { + slice = activeSlice.ID + } + } + } + return feature, slice +} + +// RecordCommandEvent appends one best-effort shadow event. Telemetry is never a +// control point: all failures and panics are swallowed after the command result +// has already been decided. +func RecordCommandEvent(input CommandTraceInput) { + defer func() { _ = recover() }() + if strings.TrimSpace(input.Verb) == "" || strings.TrimSpace(input.Category) == "" || input.StartedAt.IsZero() || input.FinishedAt.IsZero() { + return + } + if strings.TrimSpace(os.Getenv(flowTraceKillSwitch)) == "0" { + return + } + repo, err := ResolveRepository(input.Repo) + if err != nil { + return + } + feature, slice := resolveCommandFeature(repo, strings.TrimSpace(input.Feature), strings.TrimSpace(input.Slice)) + outcome := "succeeded" + if input.ExitCode == 2 { + outcome = "usage_error" + } else if input.ExitCode != 0 { + outcome = "failed" + } + duration := input.FinishedAt.Sub(input.StartedAt) + if duration < 0 { + return + } + directory, err := flowLogDirectory(repo) + if err != nil { + return + } + _ = deliverycontrol.AppendCommandEvent(directory, deliverycontrol.CommandEvent{ + Verb: input.Verb, Category: input.Category, Feature: feature, Slice: slice, + Transition: input.Transition, StartedAt: input.StartedAt.UTC().Format(time.RFC3339Nano), + FinishedAt: input.FinishedAt.UTC().Format(time.RFC3339Nano), DurationMS: duration.Milliseconds(), + ExitCode: input.ExitCode, Outcome: outcome, + AuthorityFingerprint: commandAuthorityFingerprint(repo, feature), + OperationFingerprint: commandOperationFingerprint(repo, feature, slice), + }) +} diff --git a/boatstack/command_trace_test.go b/boatstack/command_trace_test.go new file mode 100644 index 00000000..0a5dc291 --- /dev/null +++ b/boatstack/command_trace_test.go @@ -0,0 +1,73 @@ +package boatstack + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// control-law: helper-dispatch-produces-secret-free-shadow-event +func TestRecordCommandEventIsSecretFreeAndBestEffort(t *testing.T) { + repo := prTestRepo(t) + started := time.Date(2026, 8, 9, 10, 0, 0, 0, time.UTC) + RecordCommandEvent(CommandTraceInput{ + Repo: repo, Verb: "run-preflight", Category: "readiness", Feature: "demo", + StartedAt: started, FinishedAt: started.Add(1500 * time.Millisecond), ExitCode: 1, + }) + dir, err := flowLogDirectory(repo) + if err != nil { + t.Fatal(err) + } + events, err := deliverycontrol.ReadCommandEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Outcome != "failed" || events[0].DurationMS != 1500 { + t.Fatalf("events = %+v", events) + } + raw, _ := json.Marshal(events[0]) + for _, forbidden := range []string{"arguments", "stdin", "stdout", "stderr", "environment"} { + if strings.Contains(string(raw), forbidden) { + t.Fatalf("secret-bearing field %q entered event: %s", forbidden, raw) + } + } + + t.Setenv(flowTraceKillSwitch, "0") + RecordCommandEvent(CommandTraceInput{ + Repo: repo, Verb: "doctor", Category: "readiness", + StartedAt: started, FinishedAt: started, ExitCode: 0, + }) + events, err = deliverycontrol.ReadCommandEvents(dir) + if err != nil || len(events) != 1 { + t.Fatalf("kill switch changed log: events=%v err=%v", events, err) + } + + // Invalid repositories and malformed timestamps are silent no-ops. + RecordCommandEvent(CommandTraceInput{Repo: t.TempDir(), Verb: "doctor", Category: "readiness"}) +} + +func TestFlowReportScopesCommandEvidenceByFeature(t *testing.T) { + repo := prTestRepo(t) + dir, _ := flowLogDirectory(repo) + for _, event := range []deliverycontrol.CommandEvent{ + {Verb: "check-plan", Category: "planning", Feature: "one", StartedAt: "2026-08-09T10:00:00Z", FinishedAt: "2026-08-09T10:00:01Z", DurationMS: 1000, Outcome: "succeeded"}, + {Verb: "publish-pr", Category: "publication", Feature: "two", StartedAt: "2026-08-09T10:00:02Z", FinishedAt: "2026-08-09T10:00:04Z", DurationMS: 2000, ExitCode: 1, Outcome: "failed"}, + } { + if err := deliverycontrol.AppendCommandEvent(dir, event); err != nil { + t.Fatal(err) + } + } + report, err := FlowReportFor(repo, "two") + if err != nil { + t.Fatal(err) + } + if report.CommandCoverageStatus != "SCOPED_COMPLETE" || report.CommandEvents != 1 || report.CommandFailures != 1 || report.ObservedCommandMS != 2000 { + t.Fatalf("report = %+v", report) + } + if report.CommandFailureByCategory["publication"] != 1 { + t.Fatalf("failure categories = %+v", report.CommandFailureByCategory) + } +} diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 2c9dc2f4..73409e20 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -1067,6 +1067,10 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if base == "" { base = defaultPRBase(repo) } + base, err = canonicalPRBase(repo, base) + if err != nil { + return DeliveryGateReceipt{}, err + } previewPath, _ := expectedPRPreviewPath("managed", options.Feature, "") head, headCommit, diffHash, changed, err := currentDiffIdentity(repo, base, previewPath) if err != nil { diff --git a/boatstack/denial_solutions_conformance_test.go b/boatstack/denial_solutions_conformance_test.go index d183e9b4..8452c449 100644 --- a/boatstack/denial_solutions_conformance_test.go +++ b/boatstack/denial_solutions_conformance_test.go @@ -124,7 +124,7 @@ func TestTamperDenialNamesDeclaredOwnerVerbs(t *testing.T) { ".git/boatstack/mutations/v1/abc.json": {"activate-plan", "undo"}, ".git/boatstack/quarantine/demo/receipt.json": {"repair-state"}, "state-root/boatstack/registry.json": {"attach", "detach"}, - ".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"}, + ".git/boatstack/visual-evidence/x/manifest.json": {"record-pr-visual-evidence", "review-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"}, "boatstack/repositories/sample/binding.json": {"attach", "detach", "activate"}, "docs/insights/ins-sample/capture.json": {"insight"}, } diff --git a/boatstack/detached_ownership_conformance_test.go b/boatstack/detached_ownership_conformance_test.go index 102a52a8..996051e6 100644 --- a/boatstack/detached_ownership_conformance_test.go +++ b/boatstack/detached_ownership_conformance_test.go @@ -80,8 +80,8 @@ func TestDetachedAttachImportsFeatureAndIgnoresEmbeddedDrift(t *testing.T) { if err := os.WriteFile(filepath.Join(repo, productLoopDirName, "project.json"), []byte("{}\n"), 0o644); err != nil { t.Fatal(err) } - if err := Doctor(repo); err == nil || !strings.Contains(err.Error(), "runtime provenance") { - t.Fatalf("doctor did not pass detached generated-state verification before the fixture's intentionally absent runtime: %v", err) + if err := Doctor(repo); err != nil { + t.Fatalf("doctor did not validate the complete detached controller without consulting embedded drift: %v", err) } status, err := ResolveNext(repo, "") if err != nil || status.Feature != "feature-one" || status.ObservedStage != "POLICY_READY" { diff --git a/boatstack/export.go b/boatstack/export.go index 78ab967b..340b25fb 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -385,7 +385,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the project-local helper workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } operations["boatstack-run"] = "Resolve an explicit target from --to plan|verified|pr or the user's wording; when absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --json and operation-status first and reconcile in-flight work. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run record-autonomy with the selected target. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action." - operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan; planning and plan-gate do not require delivery preflight. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action." + operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan, but first run run-preflight --repo . --health-only --json before auto-plan writes any feature artifact; planning and plan-gate do not require delivery preflight beyond this pure health check. Stop without writing when installation or generated state is unhealthy. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action." operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason." operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." operations["build"] = strings.Replace(operations["build"], "compiled task graph, test matrix, evidence ledger, and the plan lock", "compiled task graph, test matrix, evidence ledger, journey-oracle manifest, and the plan lock", 1) @@ -393,9 +393,9 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte operations["build"] += " Activation repeats readiness atomically and compiles journey-oracles.json into the same immutable authority boundary." operations["build"] += " For a goal-driven run targeting verified or pr, require the current autonomy.md and pass it to activate-plan with --autonomy; this scoped receipt selects policy authorization even when the project normally requires human plan approval." operations["repair"] += " Every repair classification requires --mechanism. Implementation, verification, and review repairs each have an independent three-attempt budget; identical class/evidence/mechanism retries are friction and amendments or readiness recovery consume no attempts." - operations["test-gate"] += " For relevant journey evidence, run every compiled oracle and import current typed results with record-journey-results before the gate." + operations["test-gate"] += " For relevant journey evidence, run every compiled oracle and import current typed results with record-journey-results before the gate. An automated privacy_status clean capture is not human-reviewed: after importing it, require review-pr-visual-evidence with the exact manifest fingerprint and PNGs before PR checking or publication." operations["review-gate"] += " Reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale required journey results." - operations["ship-gate"] += " When a current autonomy.md has target pr, its repository, branch, plan, and open/update action match, and the exact preview fingerprint still passes check-pr, invoke publish-pr with --autonomy without asking for o or u. Any mismatch invalidates autonomous publication and returns to the operator; runs without that receipt retain the normal confirmation." + operations["ship-gate"] += " A relevant automated capture must have a current review-pr-visual-evidence receipt binding the exact manifest fingerprint and PNG hashes before check-pr; changed pixels invalidate it. When a current autonomy.md has target pr, its repository, branch, plan, and open/update action match, and the exact preview fingerprint still passes check-pr, flow next may invoke publish-pr once with --autonomy without asking for o or u. Any mismatch invalidates autonomous publication and returns to the operator; runs without that receipt retain the normal confirmation." if contains(adapters, "cursor") { rule := fmt.Sprintf(`--- diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index 80153859..18f5c538 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -348,6 +348,28 @@ func (p PrescribedCommand) CommandLine() string { // faithfully — so the caller emits nothing rather than a guessed command. The // emitted verb is always the registry CLIVerb of the transition (single source), // and human-owed inputs are listed, never filled. +var deriveAutonomousPRPublish = func(repo, feature, previewPath string) (string, string, string, bool) { + preview, _, err := CheckPRPreview(repo, previewPath) + if err != nil { + return "", "", "", false + } + action, _, err := RecommendedPRAction(repo) + if err != nil || (action != "open" && action != "update") { + return "", "", "", false + } + planPath := filepath.Join(WorkspaceFor(repo).FeatureDir(feature), "plan.md") + check, err := CheckPlan(planPath) + if err != nil { + return "", "", "", false + } + autonomyPath := filepath.Join(filepath.Dir(planPath), "autonomy.md") + receipt, err := CheckAutonomyReceipt(autonomyPath, check, repo, RunTargetPR, action) + if err != nil || receipt.PRAction != action { + return "", "", "", false + } + return action, preview.Fingerprint, autonomyPath, true +} + func prescribeCommand(repo, feature string, status NextStatus, transition deliverycontrol.TransitionID) (*PrescribedCommand, bool) { desc, ok := deliverycontrol.Transition(transition) if !ok || desc.CLIVerb == "" { @@ -367,8 +389,12 @@ func prescribeCommand(repo, feature string, status NextStatus, transition delive cmd.RequiresHumanInput = []string{"--status", "--evidence", "--reviewer-identity", "--review-method"} case PublishTransition: preview := filepath.Join(WorkspaceFor(repo).GeneratedRoot(), "features", feature, "pr.md") - cmd.Args = append(repoArgs, "--preview", preview, "--action", "open") - cmd.RequiresHumanInput = []string{"--preview-fingerprint"} + if action, fingerprint, autonomyPath, authorized := deriveAutonomousPRPublish(repo, feature, preview); authorized { + cmd.Args = append(repoArgs, "--preview", preview, "--action", action, "--preview-fingerprint", fingerprint, "--autonomy", autonomyPath) + } else { + cmd.Args = append(repoArgs, "--preview", preview, "--action", "open") + cmd.RequiresHumanInput = []string{"--preview-fingerprint"} + } case deliverycontrol.TransitionID("delivery.record_change"): if feature == "" { return nil, false diff --git a/boatstack/flow_drive.go b/boatstack/flow_drive.go index a4439b10..23203590 100644 --- a/boatstack/flow_drive.go +++ b/boatstack/flow_drive.go @@ -18,15 +18,13 @@ const flowDriveKillSwitch = "BOATSTACK_FLOW_DRIVE" // argument set is state-derivable and it is read-only or reversible with nothing to // fabricate (no evidence, gate status, preview fingerprint, or reviewer identity). // -// Today every forward productive move owes human input — the test/review gate -// recordings need evidence and a status; publish needs a human-confirmed -// fingerprint — so none of them are auto-drivable and this allowlist is -// deliberately empty of forward moves. The driver therefore prescribes-and-stops -// at the first move, which is the correct, safe behavior. The allowlist is the -// mechanism: it grows only as specific verbs gain provably-derivable defaults, and +// Test/review gate recordings still owe evidence and a status. Publication is +// eligible only when prescription has already revalidated an exact preview and a +// PR-target autonomy receipt for the exact open/update action. The allowlist is +// the mechanism: it grows only as specific verbs gain provably-derivable inputs, and // execution is double-gated (a verb must be BOTH allowlisted here AND have an // explicit executor handler), so nothing runs by accident. -var autoDrivableTransitions = map[deliverycontrol.TransitionID]bool{} +var autoDrivableTransitions = map[deliverycontrol.TransitionID]bool{PublishTransition: true} // DriveAction is the driver's decision for one step. type DriveAction string diff --git a/boatstack/flow_drive_conformance_test.go b/boatstack/flow_drive_conformance_test.go index 48b5c6cf..c8092328 100644 --- a/boatstack/flow_drive_conformance_test.go +++ b/boatstack/flow_drive_conformance_test.go @@ -103,20 +103,26 @@ func TestDecideDriveHumanGatedNeverExecutes(t *testing.T) { } } -// Bypass (production allowlist): none of the real forward productive transitions is -// auto-drivable today — every one owes human input — so the production driver can -// never execute a forward move. This pins the honest scope of the shipped allowlist. -func TestProductionAllowlistDrivesNoForwardMove(t *testing.T) { - forward := []deliverycontrol.TransitionID{ +// Bypass (production allowlist): gate moves remain excluded. Publish eligibility +// alone is insufficient because canAutoDrive also requires an exact fully-derived +// command (which only a current PR receipt can produce). +func TestProductionAllowlistContainsOnlyReceiptBoundPublish(t *testing.T) { + gates := []deliverycontrol.TransitionID{ deliverycontrol.TransitionID("delivery.record_gate_test"), deliverycontrol.TransitionID("delivery.record_gate_review"), - PublishTransition, } - for _, tr := range forward { + for _, tr := range gates { if autoDrivableTransitions[tr] { - t.Errorf("forward transition %s must not be on the auto-drive allowlist", tr) + t.Errorf("human-gated transition %s must not be on the auto-drive allowlist", tr) } } + if !autoDrivableTransitions[PublishTransition] { + t.Fatal("receipt-bound publish transition must be allowlisted") + } + manual := &PrescribedCommand{Verb: "publish-pr", Transition: PublishTransition, RequiresHumanInput: []string{"--preview-fingerprint"}} + if canAutoDrive(manual, autoDrivableTransitions) { + t.Fatal("allowlisted publish with unresolved human input must not execute") + } } // Failure-state: the kill switch (BOATSTACK_FLOW_DRIVE=0) and an execute-off diff --git a/boatstack/flow_prescribe_conformance_test.go b/boatstack/flow_prescribe_conformance_test.go index 19589483..7c5dfc88 100644 --- a/boatstack/flow_prescribe_conformance_test.go +++ b/boatstack/flow_prescribe_conformance_test.go @@ -54,6 +54,36 @@ func TestPrescribePublishFromReviewPassed(t *testing.T) { } } +// Positive/replay: an already-validated PR-target receipt supplies the exact +// action, preview fingerprint, and receipt path. No new authority is fabricated. +func TestPrescribePublishBecomesDerivableOnlyWithExactPRAuthority(t *testing.T) { + original := deriveAutonomousPRPublish + t.Cleanup(func() { deriveAutonomousPRPublish = original }) + deriveAutonomousPRPublish = func(repo, feature, preview string) (string, string, string, bool) { + return "update", strings.Repeat("a", 64), "/repo/.product-loop/features/demo/autonomy.md", true + } + cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition) + if !ok || !cmd.AutoDerivable || len(cmd.RequiresHumanInput) != 0 { + t.Fatalf("valid PR authority did not produce a derivable command: %#v", cmd) + } + for _, expected := range []string{"--action", "update", "--preview-fingerprint", strings.Repeat("a", 64), "--autonomy", "/repo/.product-loop/features/demo/autonomy.md"} { + if !contains(cmd.Args, expected) { + t.Fatalf("authorized command omitted %q: %v", expected, cmd.Args) + } + } + if !canAutoDrive(cmd, autoDrivableTransitions) { + t.Fatal("exact receipt-bound publish should be executable") + } + + deriveAutonomousPRPublish = func(repo, feature, preview string) (string, string, string, bool) { + return "", "", "", false + } + stale, _ := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition) + if stale.AutoDerivable || !contains(stale.RequiresHumanInput, "--preview-fingerprint") { + t.Fatalf("stale or mismatched authority bypassed the stop: %#v", stale) + } +} + // Relation: the emitted verb is exactly the registry CLIVerb of the transition, // and the transition prescribed for each state is exactly the oracle's lowest-cost // next edge from that state. Prescription can never diverge from the registry or diff --git a/boatstack/flow_report.go b/boatstack/flow_report.go index 04a12b9c..d92c1275 100644 --- a/boatstack/flow_report.go +++ b/boatstack/flow_report.go @@ -4,6 +4,7 @@ import ( "fmt" "sort" "strings" + "time" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" ) @@ -15,6 +16,13 @@ import ( // J_flow, its oracle baseline J_flow*, the regret between them, and J_coding as // independent figures; regret is derived purely from flow navigation. func FlowReport(repo string) (deliverycontrol.FlowTrajectoryReport, error) { + return FlowReportFor(repo, "") +} + +// FlowReportFor scopes command telemetry to one feature when provided. The +// delivery trajectory remains the per-worktree walk because its legacy records +// predate feature correlation; command evidence is never silently attributed. +func FlowReportFor(repo, feature string) (deliverycontrol.FlowTrajectoryReport, error) { dir, err := flowLogDirectory(repo) if err != nil { return deliverycontrol.FlowTrajectoryReport{}, err @@ -29,7 +37,52 @@ func FlowReport(repo string) (deliverycontrol.FlowTrajectoryReport, error) { } weights := deliverycontrol.DefaultFlowCostWeights() graph := deliverycontrol.RegistryGraph(weights) - return deliverycontrol.ComputeReportWithCoding(trajectory, graph, weights, flowGoal, signals), nil + report := deliverycontrol.ComputeReportWithCoding(trajectory, graph, weights, flowGoal, signals) + report.Feature = strings.TrimSpace(feature) + events, err := deliverycontrol.ReadCommandEvents(dir) + if err != nil { + return deliverycontrol.FlowTrajectoryReport{}, err + } + filtered := make([]deliverycontrol.CommandEvent, 0, len(events)) + for _, event := range events { + if report.Feature == "" || event.Feature == report.Feature { + filtered = append(filtered, event) + } + } + applyCommandReport(&report, filtered) + return report, nil +} + +func applyCommandReport(report *deliverycontrol.FlowTrajectoryReport, events []deliverycontrol.CommandEvent) { + report.CommandCoverageStatus = "NO_EVENTS" + if len(events) == 0 { + return + } + report.CommandCoverageStatus = "SCOPED_COMPLETE" + report.CommandEvents = len(events) + report.CommandFailureByCategory = map[string]int{} + var first, last time.Time + for _, event := range events { + report.ObservedCommandMS += event.DurationMS + started, _ := time.Parse(time.RFC3339Nano, event.StartedAt) + finished, _ := time.Parse(time.RFC3339Nano, event.FinishedAt) + if first.IsZero() || started.Before(first) { + first = started + } + if last.IsZero() || finished.After(last) { + last = finished + } + if event.ExitCode != 0 { + report.CommandFailures++ + report.CommandFailureByCategory[event.Category]++ + } + } + if report.CommandFailures == 0 { + report.CommandFailureByCategory = nil + } + report.FirstCommandAt = first.UTC().Format(time.RFC3339Nano) + report.LastCommandAt = last.UTC().Format(time.RFC3339Nano) + report.CommandWallSpanMS = last.Sub(first).Milliseconds() } // FormatFlowReport renders a session flow report as human-facing lines. When the @@ -45,6 +98,10 @@ func FormatFlowReport(report deliverycontrol.FlowTrajectoryReport) string { fmt.Fprintf(&b, "J_flow=%d regret=unresolved (no oracle baseline for this start)\n", report.JFlow) } fmt.Fprintf(&b, "J_coding=%d (telemetry, separate from flow)\n", report.JCoding) + fmt.Fprintf(&b, "command_coverage=%s events=%d failures=%d observed_ms=%d wall_span_ms=%d\n", report.CommandCoverageStatus, report.CommandEvents, report.CommandFailures, report.ObservedCommandMS, report.CommandWallSpanMS) + if report.Feature != "" { + fmt.Fprintf(&b, "feature=%s\n", report.Feature) + } if len(report.PositiveGapByCategory) > 0 { categories := make([]string, 0, len(report.PositiveGapByCategory)) for category := range report.PositiveGapByCategory { @@ -55,6 +112,16 @@ func FormatFlowReport(report deliverycontrol.FlowTrajectoryReport) string { fmt.Fprintf(&b, "positive_gap[%s]=%d\n", category, report.PositiveGapByCategory[category]) } } + if len(report.CommandFailureByCategory) > 0 { + categories := make([]string, 0, len(report.CommandFailureByCategory)) + for category := range report.CommandFailureByCategory { + categories = append(categories, category) + } + sort.Strings(categories) + for _, category := range categories { + fmt.Fprintf(&b, "command_failure[%s]=%d\n", category, report.CommandFailureByCategory[category]) + } + } return b.String() } diff --git a/boatstack/init.go b/boatstack/init.go index 3c86c9d8..a852de4c 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -231,6 +231,17 @@ func copyHelper(source, repo string) (string, string, error) { return destination, SHA256Bytes(value), nil } +func installControllerLocalRuntime(root, source string, integrations map[string]IntegrationState) (string, string, error) { + binaryPath, binaryHash, err := copyHelper(source, root) + if err != nil { + return "", "", err + } + if err := writeInstallLock(root, binaryPath, binaryHash, integrations); err != nil { + return "", "", err + } + return binaryPath, binaryHash, nil +} + func writeInstallLock(repo, binaryPath, binaryHash string, integrations map[string]IntegrationState) error { value, err := buildInstallLock(repo, binaryPath, binaryHash, integrations) if err != nil { @@ -639,6 +650,11 @@ func RunInit(options InitOptions) (returnErr error) { if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { return err } + if ctx := WorkspaceFor(repo); ctx.Mode == SupervisionDetached { + if _, _, err := installControllerLocalRuntime(ctx.ExportRoot(), helperSource, states); err != nil { + return fmt.Errorf("refresh detached controller helper: %w", err) + } + } if err := initCheckpoint("install-lock-written"); err != nil { return fmt.Errorf("initialization checkpoint install-lock-written: %w", err) } diff --git a/boatstack/internal/deliverycontrol/commandlog.go b/boatstack/internal/deliverycontrol/commandlog.go new file mode 100644 index 00000000..44fbaa6a --- /dev/null +++ b/boatstack/internal/deliverycontrol/commandlog.go @@ -0,0 +1,114 @@ +package deliverycontrol + +import ( + "bufio" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "time" +) + +const ( + commandEventSchemaVersion = 1 + commandLogFile = "commands.jsonl" +) + +// CommandEvent is one secret-free helper dispatch. It deliberately excludes +// argv, stdin, stdout, stderr, and environment values; only bounded workflow +// identity and timing fields may enter the shadow log. +type CommandEvent struct { + SchemaVersion int `json:"schema_version"` + Verb string `json:"verb"` + Category string `json:"category"` + Feature string `json:"feature,omitempty"` + Slice string `json:"slice,omitempty"` + Transition TransitionID `json:"transition,omitempty"` + StartedAt string `json:"started_at"` + FinishedAt string `json:"finished_at"` + DurationMS int64 `json:"duration_ms"` + ExitCode int `json:"exit_code"` + Outcome string `json:"outcome"` + AuthorityFingerprint string `json:"authority_fingerprint,omitempty"` + OperationFingerprint string `json:"operation_fingerprint,omitempty"` +} + +func NewCommandEvent(event CommandEvent) CommandEvent { + event.SchemaVersion = commandEventSchemaVersion + return event +} + +func validateCommandEvent(event CommandEvent) error { + if event.SchemaVersion != commandEventSchemaVersion || event.Verb == "" || event.Category == "" { + return errors.New("command event identity is invalid") + } + started, err := time.Parse(time.RFC3339Nano, event.StartedAt) + if err != nil { + return errors.New("command event started_at is invalid") + } + finished, err := time.Parse(time.RFC3339Nano, event.FinishedAt) + if err != nil || finished.Before(started) || event.DurationMS < 0 { + return errors.New("command event timing is invalid") + } + if event.Outcome != "succeeded" && event.Outcome != "failed" && event.Outcome != "usage_error" { + return errors.New("command event outcome is invalid") + } + return nil +} + +func AppendCommandEvent(dir string, event CommandEvent) error { + if dir == "" { + return errors.New("command log directory is empty") + } + event = NewCommandEvent(event) + if err := validateCommandEvent(event); err != nil { + return err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + line, err := json.Marshal(event) + if err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(dir, commandLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer file.Close() + _, err = file.Write(append(line, '\n')) + return err +} + +func ReadCommandEvents(dir string) ([]CommandEvent, error) { + file, err := os.Open(filepath.Join(dir, commandLogFile)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return []CommandEvent{}, nil + } + return nil, err + } + defer file.Close() + + events := []CommandEvent{} + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + if len(scanner.Bytes()) == 0 { + continue + } + var event CommandEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + return nil, err + } + if err := validateCommandEvent(event); err != nil { + return nil, err + } + events = append(events, event) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return events, nil +} diff --git a/boatstack/internal/deliverycontrol/commandlog_test.go b/boatstack/internal/deliverycontrol/commandlog_test.go new file mode 100644 index 00000000..fbb91c27 --- /dev/null +++ b/boatstack/internal/deliverycontrol/commandlog_test.go @@ -0,0 +1,37 @@ +package deliverycontrol + +import ( + "path/filepath" + "testing" +) + +func TestCommandLogRoundTrip(t *testing.T) { + dir := filepath.Join(t.TempDir(), "flow") + event := NewCommandEvent(CommandEvent{ + Verb: "publish-pr", Category: "publication", Feature: "demo", Slice: "delivery", + Transition: "delivery.publish", StartedAt: "2026-08-09T10:00:00Z", + FinishedAt: "2026-08-09T10:00:01Z", DurationMS: 1000, ExitCode: 0, + Outcome: "succeeded", AuthorityFingerprint: "authority", OperationFingerprint: "operation", + }) + if err := AppendCommandEvent(dir, event); err != nil { + t.Fatal(err) + } + events, err := ReadCommandEvents(dir) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0] != event { + t.Fatalf("events = %+v, want %+v", events, event) + } +} + +func TestCommandLogRejectsMalformedOrSecretBearingShape(t *testing.T) { + dir := filepath.Join(t.TempDir(), "flow") + bad := CommandEvent{Verb: "x", Category: "test", StartedAt: "bad", FinishedAt: "bad", Outcome: "succeeded"} + if err := AppendCommandEvent(dir, bad); err == nil { + t.Fatal("malformed timing was accepted") + } + if events, err := ReadCommandEvents(dir); err != nil || len(events) != 0 { + t.Fatalf("failed append changed log: events=%v err=%v", events, err) + } +} diff --git a/boatstack/internal/deliverycontrol/trajectory.go b/boatstack/internal/deliverycontrol/trajectory.go index 5715c92f..efae1d25 100644 --- a/boatstack/internal/deliverycontrol/trajectory.go +++ b/boatstack/internal/deliverycontrol/trajectory.go @@ -57,10 +57,19 @@ type FlowTrajectoryReport struct { // JCoding is coding effort measured as telemetry and reported ALONGSIDE J_flow. // It is never summed into J_flow and never enters Regret — the decomposition // J = J_flow + J_coding keeps the two costs separate by construction. - JCoding int `json:"j_coding"` - Steps int `json:"steps"` - Resolution Resolution `json:"resolution"` - PositiveGapByCategory map[string]int `json:"positive_gap_by_category,omitempty"` + JCoding int `json:"j_coding"` + Steps int `json:"steps"` + Resolution Resolution `json:"resolution"` + PositiveGapByCategory map[string]int `json:"positive_gap_by_category,omitempty"` + Feature string `json:"feature,omitempty"` + CommandCoverageStatus string `json:"command_coverage_status"` + CommandEvents int `json:"command_events"` + CommandFailures int `json:"command_failures"` + ObservedCommandMS int64 `json:"observed_command_ms"` + CommandWallSpanMS int64 `json:"command_wall_span_ms"` + FirstCommandAt string `json:"first_command_at,omitempty"` + LastCommandAt string `json:"last_command_at,omitempty"` + CommandFailureByCategory map[string]int `json:"command_failure_by_category,omitempty"` } // WalkCost sums a trajectory's observed J_flow: each attempt billed at its diff --git a/boatstack/journey.go b/boatstack/journey.go index 9a84eb87..cb722378 100644 --- a/boatstack/journey.go +++ b/boatstack/journey.go @@ -158,6 +158,10 @@ func RecordJourneyResults(options JourneyResultsOptions) (JourneyResults, error) if base == "" { base = defaultPRBase(repo) } + base, err = canonicalPRBase(repo, base) + if err != nil { + return JourneyResults{}, err + } _, headCommit, diffSHA, _, err := currentDiffIdentity(repo, base, "") if err != nil { return JourneyResults{}, err diff --git a/boatstack/migrate_test.go b/boatstack/migrate_test.go index d6a3c4bb..435dbc3f 100644 --- a/boatstack/migrate_test.go +++ b/boatstack/migrate_test.go @@ -251,17 +251,11 @@ func TestDoctor_SchemaBehindAndAhead(t *testing.T) { func TestValidateUpdateWorkspace_ConformanceBlock(t *testing.T) { oldOverride := currentConfigSchemaVersionOverride - oldVersion := Version - oldSourceCommit := SourceCommit - defer func() { + t.Cleanup(func() { currentConfigSchemaVersionOverride = oldOverride - Version = oldVersion - SourceCommit = oldSourceCommit - }() + }) currentConfigSchemaVersionOverride = 1 - Version = "v0.5.0" - SourceCommit = "update-test-0.5.0" now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) withUpdateGlobals(t, "v0.5.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) diff --git a/boatstack/planning.go b/boatstack/planning.go index 610b0a0c..42f7ea9b 100644 --- a/boatstack/planning.go +++ b/boatstack/planning.go @@ -59,6 +59,10 @@ type PlanningBaseline struct { ChangedPaths []string } +// Tests may replace this seam. Nil selects the production pure installation +// health check immediately before the first feature artifact can be written. +var planningInstallationHealth func(string) error + func relativeBaselineExclusions(repo string, paths ...string) map[string]bool { excluded := map[string]bool{} for _, path := range paths { @@ -243,6 +247,13 @@ func WritePlanningArtifact(options PlanningWriteOptions) (string, error) { if err != nil { return "", err } + healthCheck := CheckInstallationHealth + if planningInstallationHealth != nil { + healthCheck = planningInstallationHealth + } + if err := healthCheck(repo); err != nil { + return "", fmt.Errorf("planning write requires a healthy Boatstack installation: %w", DoctorRepairHint(err)) + } ctx, err := ResolveWorkspaceContext(repo) if err != nil { return "", err @@ -355,12 +366,18 @@ type installLock struct { Integrations map[string]IntegrationState `json:"integrations,omitempty"` } -func Doctor(repoPath string) error { +// CheckInstallationHealth validates installed and generated state without +// changing repository, runtime, or bookkeeping state. +func CheckInstallationHealth(repoPath string) error { repo, err := ResolveRepository(repoPath) if err != nil { return err } - configPath := WorkspaceFor(repo).SourceConfigPath() + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return err + } + configPath := ctx.SourceConfigPath() config, raw, err := LoadConfig(configPath) if err != nil { return fmt.Errorf("invalid or missing .boatstack-project.json: %w", err) @@ -369,14 +386,18 @@ func Doctor(repoPath string) error { if err != nil { return err } - if err := CheckExport(WorkspaceFor(repo).ExportRoot(), bundle.Files); err != nil { + if err := CheckExport(ctx.ExportRoot(), bundle.Files); err != nil { return err } - // Best-effort hygiene: drop the orphaned clone-shared operation ledger left by - // pre-isolation versions. Never fails doctor. - pruneLegacyOperationLedger(repo) - if err := CheckHostHooks(repo, config.Adapters); err != nil { - return err + // Embedded installations own merged host settings in the repository and can + // verify them here. Detached controller state owns generated hook fragments; + // developer-level host activation is a separate, operator-visible boundary. + // CheckExport above verifies those fragments without misreading them as merged + // .cursor/.claude/.codex/.gemini configurations. + if ctx.Mode == SupervisionEmbedded { + if err := CheckHostHooks(ctx.HostActivationRoot(), config.Adapters); err != nil { + return err + } } hostAdapters := normalizedAdapters(config.Adapters) if contains(hostAdapters, "claude") { @@ -384,7 +405,7 @@ func Doctor(repoPath string) error { return fmt.Errorf("Claude Code safety hooks require Bash; install Git Bash or Bash, then rerun doctor") } } - if err := verifyGeneratedRuntime(repo); err != nil { + if err := verifyGeneratedRuntime(ctx.ExportRoot()); err != nil { return err } if _, _, err := loadSharedRuntime(repo); err != nil { @@ -412,7 +433,21 @@ func Doctor(repoPath string) error { return fmt.Errorf("%s safety hook did not fail closed on malformed input", host) } } - return verifyLocalRuntime(repo) + return verifyLocalRuntime(ctx.ExportRoot()) +} + +func Doctor(repoPath string) error { + if err := CheckInstallationHealth(repoPath); err != nil { + return err + } + repo, err := ResolveRepository(repoPath) + if err != nil { + return err + } + // Doctor keeps its legacy best-effort hygiene, but the preflight health + // boundary above remains pure. + pruneLegacyOperationLedger(repo) + return nil } func DoctorHookHosts(repoPath string) ([]string, error) { diff --git a/boatstack/planning_first_write_conformance_test.go b/boatstack/planning_first_write_conformance_test.go index 9fa979d8..84d5cb9b 100644 --- a/boatstack/planning_first_write_conformance_test.go +++ b/boatstack/planning_first_write_conformance_test.go @@ -25,6 +25,9 @@ import ( // creates the artifact. func TestFirstPlanningWriteOwnedChannelStaysOpen(t *testing.T) { repo := safetyTestRepo(t) + previousHealth := planningInstallationHealth + planningInstallationHealth = func(string) error { return nil } + t.Cleanup(func() { planningInstallationHealth = previousHealth }) for _, command := range []string{ ".product-loop/bin/boatstack-helper planning-write --repo . --feature checkout --artifact plan.md <<'BOATSTACK_PLAN_EOF'\n# Plan\nBOATSTACK_PLAN_EOF\n", diff --git a/boatstack/planning_test.go b/boatstack/planning_test.go index 03813702..e286d52e 100644 --- a/boatstack/planning_test.go +++ b/boatstack/planning_test.go @@ -2,6 +2,7 @@ package boatstack import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -12,6 +13,9 @@ import ( func planningRepo(t *testing.T) string { t.Helper() + previousHealth := planningInstallationHealth + planningInstallationHealth = func(string) error { return nil } + t.Cleanup(func() { planningInstallationHealth = previousHealth }) repo := t.TempDir() if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { t.Fatalf("git init: %v: %s", err, output) @@ -19,6 +23,18 @@ func planningRepo(t *testing.T) string { return repo } +func TestPlanningWriteBlocksBeforeArtifactWhenInstallationIsUnhealthy(t *testing.T) { + repo := planningRepo(t) + planningInstallationHealth = func(string) error { return fmt.Errorf("generated state drift") } + _, err := WritePlanningArtifact(PlanningWriteOptions{Repo: repo, Feature: "blocked-plan", Artifact: "plan.md", Content: []byte("# Plan\n")}) + if err == nil || !strings.Contains(err.Error(), "generated state drift") { + t.Fatalf("unhealthy installation did not block precisely: %v", err) + } + if _, statErr := os.Stat(filepath.Join(repo, ".product-loop", "features", "blocked-plan")); !os.IsNotExist(statErr) { + t.Fatalf("planning artifact directory exists after failed health check: %v", statErr) + } +} + func TestPlanningWriteIsBoundedMarkdownOnly(t *testing.T) { repo := planningRepo(t) path, err := WritePlanningArtifact(PlanningWriteOptions{ diff --git a/boatstack/planning_transport_conformance_test.go b/boatstack/planning_transport_conformance_test.go index 5c7549af..063d622c 100644 --- a/boatstack/planning_transport_conformance_test.go +++ b/boatstack/planning_transport_conformance_test.go @@ -2,6 +2,7 @@ package boatstack import ( "encoding/json" + "io" "os" "os/exec" "path/filepath" @@ -83,6 +84,27 @@ func buildPlanningHelper(t *testing.T, repo string) string { return buildPlanningHelperAt(t, filepath.Join(repo, ".product-loop", "bin", helperName())) } +// installPlanningTransportFixture gives execution tests the same healthy, +// generated state that production planning-write requires. Classifier-only +// tests intentionally keep using the smaller uninstalled repository fixture. +func installPlanningTransportFixture(t *testing.T, repo string) string { + t.Helper() + if !fileExists(filepath.Join(repo, "go.mod")) { + if err := os.WriteFile(filepath.Join(repo, "go.mod"), []byte("module planning-transport\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "go.mod") + runGit(t, repo, "commit", "-m", "add test command") + } + source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName())) + if err := RunInit(InitOptions{ + Repo: repo, BinaryPath: source, IntegrationChoice: "core", Yes: true, Output: io.Discard, + }); err != nil { + t.Fatalf("install healthy planning transport fixture: %v", err) + } + return WorkspaceFor(repo).HelperPath() +} + func executePlanningEnvelope(t *testing.T, repo, command string) { t.Helper() var execution *exec.Cmd @@ -113,7 +135,7 @@ func executePlanningEnvelope(t *testing.T, repo, command string) { // write), which never exercised the transport between the hook and stdin. func TestPlanningTransportRunsHookShellHelperAndSavedArtifact(t *testing.T) { repo := safetyTestRepo(t) - helper := buildPlanningHelper(t, repo) + helper := installPlanningTransportFixture(t, repo) body := "# Literal transport\n\nUnicode survives: ü 船\n`rm -rf /` and $(git reset --hard HEAD~1) are documentation.\n" var command string @@ -143,7 +165,8 @@ func TestPlanningTransportRunsHookShellHelperAndSavedArtifact(t *testing.T) { func TestPlanningTransportRunsThroughDetachedWorkspaceBinding(t *testing.T) { repo := detachedTestRepo(t, "https://github.com/acme/planning-transport.git") - result, err := AttachDetached(AttachOptions{Repo: repo}) + source := buildPlanningHelperAt(t, filepath.Join(t.TempDir(), helperName())) + result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: source}) if err != nil || result.VerificationStatus != "VERIFIED" { t.Fatalf("attach detached workspace: %+v %v", result, err) } @@ -151,7 +174,7 @@ func TestPlanningTransportRunsThroughDetachedWorkspaceBinding(t *testing.T) { if err != nil || workspace.Mode != SupervisionDetached { t.Fatalf("resolve detached workspace: %+v %v", workspace, err) } - helper := buildPlanningHelperAt(t, workspace.HelperPath()) + helper := workspace.HelperPath() body := "# Detached literal transport\n\nThe controller remains outside the product repository.\n" command := posixPlanningEnvelope(t, helper, repo, "detached-transport", "plan.md", body) if runtime.GOOS == "windows" { @@ -366,7 +389,7 @@ func TestPlanningPrescriptionQuotesRepositoryPath(t *testing.T) { t.Fatalf("guard denied the quoted repository prescription: %#v", findings) } if runtime.GOOS != "windows" { - buildPlanningHelper(t, repo) + installPlanningTransportFixture(t, repo) executePlanningEnvelope(t, repo, line) written, err := os.ReadFile(filepath.Join(repo, productLoopDirName, "features", "quoted-path", "plan.md")) if err != nil || string(written) != "test-value\n" { diff --git a/boatstack/pr.go b/boatstack/pr.go index e8278fbe..d1238179 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -15,7 +15,7 @@ import ( "time" ) -const prPreviewSchemaVersion = 3 +const prPreviewSchemaVersion = 4 var prStatusPattern = regexp.MustCompile(`(?i)^(PASS|PASS_WITH_GAPS|NOT_VERIFIED|BLOCKED)$`) @@ -62,6 +62,8 @@ type PRContext struct { PRVisualEvidenceStatus string `json:"pr_visual_evidence_status"` PRVisualEvidenceCount int `json:"pr_visual_evidence_count"` PRVisualEvidenceFingerprint string `json:"pr_visual_evidence_fingerprint"` + PRVisualPrivacyStatus string `json:"pr_visual_privacy_status"` + PRVisualPrivacyFingerprint string `json:"pr_visual_privacy_receipt_fingerprint"` PRVisualEvidenceRelevance string `json:"pr_visual_evidence_relevance"` PRVisualEvidenceSource string `json:"pr_visual_evidence_source"` // PRVisualEvidencePolicySource is "configured", or "plan-escalated" when @@ -89,6 +91,8 @@ type PRPreview struct { PRVisualEvidenceStatus string PRVisualEvidenceCount int PRVisualEvidenceFingerprint string + PRVisualPrivacyStatus string + PRVisualPrivacyFingerprint string Body string Path string Fingerprint string @@ -402,7 +406,9 @@ func gitCommand(repo string, arguments ...string) (string, error) { func defaultPRBase(repo string) string { configPath := WorkspaceFor(repo).ProjectConfigPath() if config, _, err := LoadConfig(configPath); err == nil && strings.TrimSpace(config.Project.DefaultBranch) != "" { - return strings.TrimSpace(config.Project.DefaultBranch) + if base, err := canonicalPRBaseName(config.Project.DefaultBranch); err == nil { + return base + } } if branch := strings.TrimPrefix(gitOutput(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"), "origin/"); branch != "" { return branch @@ -410,8 +416,41 @@ func defaultPRBase(repo string) string { return "main" } +// canonicalPRBaseName reduces every accepted spelling to the short branch name +// GitHub expects. Other remote/ref namespaces are rejected instead of being +// accidentally embedded below refs/remotes/origin. +// control-law: pr-base-is-one-fetched-origin-branch +func canonicalPRBaseName(value string) (string, error) { + base := strings.TrimSpace(value) + for _, prefix := range []string{"refs/remotes/origin/", "refs/heads/", "origin/"} { + if strings.HasPrefix(base, prefix) { + base = strings.TrimPrefix(base, prefix) + break + } + } + if base == "" || base == "HEAD" || strings.HasPrefix(base, "refs/") || strings.HasPrefix(base, "remotes/") { + return "", fmt.Errorf("PR base %q is not an origin branch", value) + } + return base, nil +} + +func canonicalPRBase(repo, value string) (string, error) { + base, err := canonicalPRBaseName(value) + if err != nil { + return "", err + } + if _, err := gitCommand(repo, "check-ref-format", "--branch", base); err != nil { + return "", fmt.Errorf("PR base %q is not a valid branch name", value) + } + return base, nil +} + func resolveBaseCommit(repo, base string) (string, error) { - for _, candidate := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { + canonical, err := canonicalPRBase(repo, base) + if err != nil { + return "", err + } + for _, candidate := range []string{"refs/remotes/origin/" + canonical, "refs/heads/" + canonical} { if commit, err := gitCommand(repo, "rev-parse", "--verify", candidate+"^{commit}"); err == nil { return commit, nil } @@ -419,6 +458,18 @@ func resolveBaseCommit(repo, base string) (string, error) { return "", fmt.Errorf("base branch %q is not available locally; fetch it and try again", base) } +func resolveFetchedOriginBaseCommit(repo, base string) (string, error) { + canonical, err := canonicalPRBase(repo, base) + if err != nil { + return "", err + } + candidate := "refs/remotes/origin/" + canonical + if commit, err := gitCommand(repo, "rev-parse", "--verify", candidate+"^{commit}"); err == nil { + return commit, nil + } + return "", fmt.Errorf("base branch %q is not available at origin/%s; fetch it and try again", base, canonical) +} + func previewSlug(branch string) string { value := strings.ToLower(branch) var result strings.Builder @@ -739,10 +790,14 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { base = defaultPRBase(repo) } } + base, err = canonicalPRBase(repo, base) + if err != nil { + return PRContext{}, err + } if head == base { return PRContext{}, fmt.Errorf("current branch %q is the configured base branch", head) } - baseCommit, err := resolveBaseCommit(repo, base) + baseCommit, err := resolveFetchedOriginBaseCommit(repo, base) if err != nil { return PRContext{}, err } @@ -858,10 +913,15 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil { return PRContext{}, err } + privacyStatus, privacyFingerprint, err := ResolvePRVisualPrivacyStatus(repo, visualManifest) + if err != nil { + return PRContext{}, err + } fingerprintPayload, err = MarshalJSON(map[string]any{ "base": json.RawMessage(fingerprintPayload), "pr_visual_evidence_policy": visualPolicy, "pr_visual_evidence_status": visualStatus, "pr_visual_evidence_count": visualCount, "pr_visual_evidence_fingerprint": visualFingerprint, + "pr_visual_privacy_status": privacyStatus, "pr_visual_privacy_receipt_fingerprint": privacyFingerprint, }) if err != nil { return PRContext{}, err @@ -878,6 +938,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { SafetyStatus: safety.Status, SafetyFindings: safety.Findings, PRVisualEvidencePolicy: visualPolicy, PRVisualEvidenceStatus: visualStatus, PRVisualEvidenceCount: visualCount, PRVisualEvidenceFingerprint: visualFingerprint, + PRVisualPrivacyStatus: privacyStatus, PRVisualPrivacyFingerprint: privacyFingerprint, PRVisualEvidenceRelevance: visualRelevance, PRVisualEvidenceSource: visualSource, PRVisualEvidencePolicySource: visualPolicySource, PRVisualEvidenceCaptureDetail: captureDetail, @@ -902,6 +963,7 @@ func parsePRFrontmatter(value string) (map[string]string, string, error) { "slice": true, "base": true, "head": true, "context_fingerprint": true, "pr_visual_evidence_policy": true, "pr_visual_evidence_status": true, "pr_visual_evidence_count": true, "pr_visual_evidence_fingerprint": true, + "pr_visual_privacy_status": true, "pr_visual_privacy_receipt_fingerprint": true, } for _, line := range strings.Split(frontmatter, "\n") { key, raw, found := strings.Cut(line, ":") @@ -926,7 +988,7 @@ func parsePRFrontmatter(value string) (map[string]string, string, error) { } fields[key] = decoded } - for _, key := range []string{"boatstack_pr_version", "title", "mode", "feature", "base", "head", "context_fingerprint", "pr_visual_evidence_policy", "pr_visual_evidence_status", "pr_visual_evidence_count", "pr_visual_evidence_fingerprint"} { + for _, key := range []string{"boatstack_pr_version", "title", "mode", "feature", "base", "head", "context_fingerprint", "pr_visual_evidence_policy", "pr_visual_evidence_status", "pr_visual_evidence_count", "pr_visual_evidence_fingerprint", "pr_visual_privacy_status", "pr_visual_privacy_receipt_fingerprint"} { if _, exists := fields[key]; !exists { return nil, "", fmt.Errorf("PR frontmatter is missing %s", key) } @@ -1063,7 +1125,8 @@ func ParsePRPreview(path string) (PRPreview, error) { ContextFingerprint: fields["context_fingerprint"], Body: body, Path: path, PRVisualEvidencePolicy: fields["pr_visual_evidence_policy"], PRVisualEvidenceStatus: fields["pr_visual_evidence_status"], PRVisualEvidenceFingerprint: fields["pr_visual_evidence_fingerprint"], - Fingerprint: SHA256Bytes(value), + PRVisualPrivacyStatus: fields["pr_visual_privacy_status"], PRVisualPrivacyFingerprint: fields["pr_visual_privacy_receipt_fingerprint"], + Fingerprint: SHA256Bytes(value), } preview.PRVisualEvidenceCount, err = strconv.Atoi(fields["pr_visual_evidence_count"]) if err != nil || preview.PRVisualEvidenceCount < 0 || preview.PRVisualEvidenceCount > 3 { @@ -1102,6 +1165,15 @@ func ParsePRPreview(path string) (PRPreview, error) { if len(preview.PRVisualEvidenceFingerprint) != 64 { return PRPreview{}, fmt.Errorf("PR preview requires a valid pr_visual_evidence_fingerprint") } + if !map[string]bool{"PASS": true, "REVIEW_REQUIRED": true, "NOT_APPLICABLE": true}[preview.PRVisualPrivacyStatus] { + return PRPreview{}, fmt.Errorf("unsupported pr_visual_privacy_status") + } + if preview.PRVisualPrivacyStatus == "PASS" && len(preview.PRVisualPrivacyFingerprint) != 64 { + return PRPreview{}, fmt.Errorf("PASS visual privacy review requires a valid receipt fingerprint") + } + if preview.PRVisualPrivacyStatus != "PASS" && preview.PRVisualPrivacyFingerprint != "" { + return PRPreview{}, fmt.Errorf("visual privacy receipt fingerprint is allowed only for PASS") + } for _, heading := range []string{ "## Why this change", "## What changed", "## Review order", "## Evidence", "## Operational safety", "## Known gaps and risks", "## Rollout and rollback", @@ -1172,6 +1244,12 @@ func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) preview.PRVisualEvidenceCount != context.PRVisualEvidenceCount || preview.PRVisualEvidenceFingerprint != context.PRVisualEvidenceFingerprint { return PRPreview{}, PRContext{}, fmt.Errorf("PR preview is stale or does not match the current branch context; regenerate it") } + if preview.PRVisualPrivacyStatus != context.PRVisualPrivacyStatus || preview.PRVisualPrivacyFingerprint != context.PRVisualPrivacyFingerprint { + return PRPreview{}, PRContext{}, fmt.Errorf("PR preview visual privacy review is stale; regenerate it") + } + if context.PRVisualPrivacyStatus == "REVIEW_REQUIRED" { + return PRPreview{}, PRContext{}, fmt.Errorf("PR visual evidence requires human privacy review before check or publication") + } if context.Mode == "managed" { if err := validateManagedEvidenceSources(preview.Body, context.Sources); err != nil { return PRPreview{}, PRContext{}, err @@ -1227,6 +1305,27 @@ func RecommendedPRAction(repo string) (string, string, error) { return "open", "", nil } +func revalidatePRVisualPrivacy(repo string, context PRContext) error { + if context.PRVisualPrivacyStatus == "NOT_APPLICABLE" { + return nil + } + if context.PRVisualEvidence == nil { + return fmt.Errorf("PR visual privacy status has no current evidence manifest") + } + manifest, err := LoadPRVisualEvidence(repo, context.PRVisualEvidence.Key) + if err != nil { + return err + } + status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest) + if err != nil { + return err + } + if status != "PASS" || status != context.PRVisualPrivacyStatus || fingerprint != context.PRVisualPrivacyFingerprint { + return fmt.Errorf("PR visual privacy review is missing or stale; review the exact current PNGs before GitHub mutation") + } + return nil +} + func PublishPR(options PRPublishOptions) (string, error) { repo, err := ResolveRepository(options.Repo) if err != nil { @@ -1341,6 +1440,9 @@ func PublishPR(options PRPublishOptions) (string, error) { _, _ = CompleteOperation(repo, receipt.OperationID, begin.LeaseToken, "UNKNOWN", "publication ended without a verifiable complete postcondition", observedURL) return "", cause } + if err := revalidatePRVisualPrivacy(repo, context); err != nil { + return completeUnknown(err, existingURL) + } if _, err := gitCommand(repo, "push", "--set-upstream", "origin", context.HeadBranch); err != nil { return completeUnknown(fmt.Errorf("cannot push %s without rewriting history: %w", context.HeadBranch, err), existingURL) } @@ -1357,6 +1459,9 @@ func PublishPR(options PRPublishOptions) (string, error) { if err := temporary.Close(); err != nil { return completeUnknown(err, existingURL) } + if err := revalidatePRVisualPrivacy(repo, context); err != nil { + return completeUnknown(err, existingURL) + } if options.Action == "open" { url := existingURL if !exists { @@ -1443,7 +1548,7 @@ func PRPreviewTemplate(context PRContext) string { safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack." lines := []string{ "---", - "boatstack_pr_version: 3", + fmt.Sprintf("boatstack_pr_version: %d", prPreviewSchemaVersion), "title: " + quote("Describe the product or user value of this change (e.g., 'Enable historical data migration')"), "mode: " + quote(context.Mode), "feature: " + quote(context.Feature), @@ -1455,6 +1560,8 @@ func PRPreviewTemplate(context PRContext) string { "pr_visual_evidence_status: " + quote(context.PRVisualEvidenceStatus), fmt.Sprintf("pr_visual_evidence_count: %d", context.PRVisualEvidenceCount), "pr_visual_evidence_fingerprint: " + quote(context.PRVisualEvidenceFingerprint), + "pr_visual_privacy_status: " + quote(context.PRVisualPrivacyStatus), + "pr_visual_privacy_receipt_fingerprint: " + quote(context.PRVisualPrivacyFingerprint), "---", "## Why this change", "", "Explain the user or engineering outcome.", "", "## What changed", "", "| Area | Before | After | Reviewer focus |", "|---|---|---|---|", "| | | | |", "", diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go index c014f9e8..485631c1 100644 --- a/boatstack/pr_test.go +++ b/boatstack/pr_test.go @@ -79,7 +79,7 @@ func quoted(value string) string { func previewDocument(context PRContext, title, body string) string { return strings.Join([]string{ "---", - "boatstack_pr_version: 3", + "boatstack_pr_version: 4", "title: " + quoted(title), "mode: " + quoted(context.Mode), "feature: " + quoted(context.Feature), @@ -91,6 +91,8 @@ func previewDocument(context PRContext, title, body string) string { "pr_visual_evidence_status: " + quoted(context.PRVisualEvidenceStatus), "pr_visual_evidence_count: " + strconv.Itoa(context.PRVisualEvidenceCount), "pr_visual_evidence_fingerprint: " + quoted(context.PRVisualEvidenceFingerprint), + "pr_visual_privacy_status: " + quoted(context.PRVisualPrivacyStatus), + "pr_visual_privacy_receipt_fingerprint: " + quoted(context.PRVisualPrivacyFingerprint), "---", strings.TrimSpace(body), "", @@ -118,6 +120,33 @@ func writePreview(t *testing.T, repo string, context PRContext, title, body stri return path } +func TestPRBaseCanonicalizationAcceptsOnlyOriginSpellings(t *testing.T) { + repo := prTestRepo(t) + for _, input := range []string{"main", "origin/main", "refs/remotes/origin/main", "refs/heads/main"} { + t.Run(strings.ReplaceAll(input, "/", "_"), func(t *testing.T) { + base, err := canonicalPRBase(repo, input) + if err != nil || base != "main" { + t.Fatalf("canonicalPRBase(%q) = %q, %v", input, base, err) + } + context, err := PreparePRContext(PRContextOptions{Repo: repo, Base: input}) + if err != nil { + t.Fatal(err) + } + if context.BaseBranch != "main" { + t.Fatalf("context retained noncanonical base %q", context.BaseBranch) + } + }) + } + for _, input := range []string{"refs/remotes/upstream/main", "HEAD", "-main", "refs/tags/main"} { + if _, err := canonicalPRBase(repo, input); err == nil { + t.Fatalf("malformed or foreign base %q was accepted", input) + } + } + if _, err := resolveFetchedOriginBaseCommit(repo, "missing"); err == nil || !strings.Contains(err.Error(), "origin/missing") { + t.Fatalf("unavailable origin ref did not fail precisely: %v", err) + } +} + func TestAdHocPRContextAndPreviewAreEvidenceLimited(t *testing.T) { repo := prTestRepo(t) context, err := PreparePRContext(PRContextOptions{Repo: repo}) @@ -880,6 +909,9 @@ func TestPreparePRContextAutoCapturesRelevantVisualEvidence(t *testing.T) { if context.PRVisualEvidenceStatus != "PASS" || context.PRVisualEvidenceCount != 1 { t.Fatalf("ship preparation did not capture declared evidence itself: %#v", context) } + if context.PRVisualPrivacyStatus != "REVIEW_REQUIRED" || context.PRVisualPrivacyFingerprint != "" { + t.Fatalf("automated clean capture bypassed human privacy review: %#v", context) + } if context.PRVisualEvidenceCaptureDetail != "" { t.Fatalf("successful auto-capture reported a gap: %s", context.PRVisualEvidenceCaptureDetail) } @@ -896,6 +928,58 @@ func TestPreparePRContextAutoCapturesRelevantVisualEvidence(t *testing.T) { if again.PRVisualEvidenceFingerprint != context.PRVisualEvidenceFingerprint { t.Fatal("repeat context preparation destabilized the visual fingerprint") } + review, err := RecordPRVisualPrivacyReview(repo, context.PRVisualEvidence.Key, context.PRVisualEvidence.Fingerprint, "reviewer@example.invalid") + if err != nil { + t.Fatal(err) + } + reviewed, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner}) + if err != nil { + t.Fatal(err) + } + if reviewed.PRVisualPrivacyStatus != "PASS" || reviewed.PRVisualPrivacyFingerprint != review.Fingerprint || runner.calls != 1 { + t.Fatalf("exact human review did not unlock current pixels: %#v", reviewed) + } +} + +// Bypass: neither check-pr nor publish-pr may contact GitHub while automated +// pixels still lack the exact human privacy receipt. +func TestPublishPRRunsNoGitHubCommandBeforeAutomatedPrivacyReview(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gh fixture uses a POSIX shell") + } + repo := prTestRepoConfigured(t, func(config *ProjectConfig) { + config.Workflow.PRVisualEvidence = "suggest" + config.Project.Commands["visual"] = "repo-owned-harness" + }) + activateManagedFeature(t, repo, "reviewer-ready") + runner := &stubCaptureRunner{write: func(request CaptureRequest) error { + writeTestPNG(t, request.OutputPath) + return nil + }} + context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready", CaptureRunner: runner}) + if err != nil { + t.Fatal(err) + } + previewPath := writePreview(t, repo, context, "Require exact pixel privacy review", visualEvidenceBody(managedPRBody(), context.PRVisualEvidenceStatus)) + preview, err := ParsePRPreview(previewPath) + if err != nil { + t.Fatal(err) + } + fakeDir := t.TempDir() + logPath := filepath.Join(fakeDir, "gh.log") + script := filepath.Join(fakeDir, "gh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho called >> \"$BOATSTACK_GH_LOG\"\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BOATSTACK_GH_LOG", logPath) + _, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}) + if err == nil || !strings.Contains(err.Error(), "privacy review") { + t.Fatalf("pre-review publication did not fail at privacy boundary: %v", err) + } + if _, statErr := os.Stat(logPath); !os.IsNotExist(statErr) { + t.Fatalf("GitHub command ran before privacy review: %v", statErr) + } } // Invariant: a failing harness never errors context preparation — it records diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 17223438..9156adc1 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -434,7 +434,7 @@ Project the approved feature and actual committed diff into a reviewer-ready tit Store the exact preview at `.product-loop/features//pr.md`. Its non-rendered frontmatter records the title, base/head branches, managed feature, and context fingerprint; the remaining Markdown is the exact GitHub body. The preview artifact itself is excluded from the product-diff fingerprint so committing it does not create a self-referential hash. -PR schema v3 always records `pr_visual_evidence_policy`, `pr_visual_evidence_status`, `pr_visual_evidence_count`, and `pr_visual_evidence_fingerprint`. Relevant or unresolved PRs contain a structured **Visual evidence** section. Show the exact local images and external-host privacy warning before confirmation. The state-scoped `o` or `u` authorizes the fingerprinted PR package: title, body, and one Boatstack-owned visual-evidence comment. After human privacy review, Boatstack uploads to the configured external host (Litterbox for 72 hours by default), verifies every returned URL, and writes only hosted Markdown image links. If upload, URL verification, or comment mutation fails, preserve the PR and comment identity, record `visual_pending`, and retry the same fingerprint. Never attach PNG files directly or commit them to a branch. Under `require`, do not mark managed delivery published until the hosted comment is observed. +PR schema v4 always records `pr_visual_evidence_policy`, `pr_visual_evidence_status`, `pr_visual_evidence_count`, `pr_visual_evidence_fingerprint`, `pr_visual_privacy_status`, and `pr_visual_privacy_receipt_fingerprint`. Relevant or unresolved PRs contain a structured **Visual evidence** section. Show the exact local images and external-host privacy warning before confirmation. Automated `clean` capture requires a separate human receipt from `review-pr-visual-evidence` that binds the exact manifest fingerprint and PNG hashes; changed pixels invalidate it. The state-scoped `o` or `u` authorizes the fingerprinted PR package: title, body, and one Boatstack-owned visual-evidence comment. After human privacy review, Boatstack uploads to the configured external host (Litterbox for 72 hours by default), verifies every returned URL, and writes only hosted Markdown image links. If upload, URL verification, or comment mutation fails, preserve the PR and comment identity, record `visual_pending`, and retry the same fingerprint. Never attach PNG files directly or commit them to a branch. Under `require`, do not mark managed delivery published until the hosted comment is observed. Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action. When no PR exists, render: Reply `o` to open PR. When one exists, render: Reply `u` to update PR. Only the corresponding state-scoped shortcut or compatible full reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then let the deterministic publisher perform a normal push and the selected GitHub action. It records the fingerprinted package before execution. A lost response enters reconciliation against the exact remote branch and PR; it never opens another PR blindly. Any package drift blocks publication and requires a new preview; never force-push. diff --git a/boatstack/run.go b/boatstack/run.go index 5d485394..2602635d 100644 --- a/boatstack/run.go +++ b/boatstack/run.go @@ -10,6 +10,10 @@ const runPreflightSchemaVersion = 2 var runGitCommand = gitCommand +// Tests may replace this seam. Nil selects the production pure health check +// without creating an initialization cycle through plan/readiness guards. +var runInstallationHealth func(string) error + // RunPreflight is the deterministic Git freshness boundary used before the // host-driven run operation is allowed to mutate workflow or product state. type RunPreflight struct { @@ -93,16 +97,19 @@ func runBranches(repo, explicitFeature string) (string, string, error) { return base, head, fmt.Errorf("active delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head) } } + base, err = canonicalPRBaseName(base) + if err != nil { + return "", head, err + } if head == base { return base, head, fmt.Errorf("Boatstack run requires a feature branch; current branch %s is the configured base branch", head) } return base, head, nil } -// CheckRunPreflight fetches origin and proves that the current branch contains -// the fetched base and is not behind or diverged from its configured upstream. -// It never merges, rebases, switches branches, discards changes, or pushes. -func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { +// CheckInstallationPreflight is the pure planning/readiness cut. It performs no +// fetch, repair, dependency installation, feature write, or bookkeeping write. +func CheckInstallationPreflight(repoPath string) RunPreflight { repo, err := ResolveRepository(repoPath) if err != nil { return blockedRunPreflight("", "", "", "INVALID_REPOSITORY", err.Error()) @@ -113,10 +120,34 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.") } - config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) - if configErr != nil { + if _, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()); configErr != nil { return blockedRunPreflight("", "", "", "INVALID_CONFIG", "Boatstack could not validate the project configuration.") } + healthCheck := CheckInstallationHealth + if runInstallationHealth != nil { + healthCheck = runInstallationHealth + } + if healthErr := healthCheck(repo); healthErr != nil { + return blockedRunPreflight("", "", "", "INSTALLATION_UNHEALTHY", DoctorRepairHint(healthErr).Error()) + } + return RunPreflight{ + SchemaVersion: runPreflightSchemaVersion, VerificationStatus: "VERIFIED", Relation: "INSTALLATION_HEALTHY", + AuthorityStatus: AuthorityHookGuarded, + AuthorityReason: "Installation/generated-state health is verified; external authority is evaluated by delivery preflight.", + Reason: "Boatstack installation and generated state are healthy.", + } +} + +// CheckRunPreflight fetches origin and proves that the current branch contains +// the fetched base and is not behind or diverged from its configured upstream. +// It never merges, rebases, switches branches, discards changes, or pushes. +func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { + health := CheckInstallationPreflight(repoPath) + if health.VerificationStatus != "VERIFIED" { + return health + } + repo, _ := ResolveRepository(repoPath) + config, _, _ := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) authorityStatus, authorityReason := verifyAuthorityBoundary(repo, config.Workflow.ExternalAuthority) block := func(base, head, upstream, relation, reason string) RunPreflight { return blockedRunPreflightWithAuthority(base, head, upstream, relation, reason, authorityStatus, authorityReason) diff --git a/boatstack/run_test.go b/boatstack/run_test.go index bc0ee45f..254c84c6 100644 --- a/boatstack/run_test.go +++ b/boatstack/run_test.go @@ -12,6 +12,9 @@ import ( func runTestRepo(t *testing.T) string { t.Helper() + previousHealth := runInstallationHealth + runInstallationHealth = func(string) error { return nil } + t.Cleanup(func() { runInstallationHealth = previousHealth }) repo := t.TempDir() for _, args := range [][]string{ {"init", "-b", "main"}, @@ -41,6 +44,24 @@ func runTestRepo(t *testing.T) string { return repo } +// control-law: run-mutation-requires-pure-installation-health +func TestCheckRunPreflightBlocksUnhealthyInstallationBeforeGitEffects(t *testing.T) { + repo := runTestRepo(t) + called := false + runInstallationHealth = func(string) error { + called = true + return fmt.Errorf("generated adapter is missing") + } + withRunGit(t, map[string]struct { + value string + err error + }{}) + status := CheckRunPreflight(repo, "") + if !called || status.VerificationStatus != "BLOCKED" || status.Relation != "INSTALLATION_UNHEALTHY" { + t.Fatalf("unexpected preflight: %+v", status) + } +} + func withRunGit(t *testing.T, responses map[string]struct { value string err error diff --git a/boatstack/statemap.go b/boatstack/statemap.go index af96ebef..c7e3daed 100644 --- a/boatstack/statemap.go +++ b/boatstack/statemap.go @@ -248,7 +248,7 @@ func StateRegistry() []StateEntry { }, { Name: "visual-evidence", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true, - OwnerVerbs: []string{"record-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"}, + OwnerVerbs: []string{"record-pr-visual-evidence", "review-pr-visual-evidence", "capture-evidence", "record-pr-visual-publication", "attach-evidence"}, Sample: staticSample(filepath.FromSlash(".git/boatstack/visual-evidence/sample/manifest.json")), }, { diff --git a/boatstack/update_publication.go b/boatstack/update_publication.go index 6d8b35c3..28f5867e 100644 --- a/boatstack/update_publication.go +++ b/boatstack/update_publication.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "sort" "strings" @@ -83,6 +84,33 @@ func installedGeneratedPaths(repo string) map[string]bool { return result } +// validateGeneratedCommitReadiness prevents a locally healthy update from +// publishing a lock that names an untracked file hidden by repository ignore +// rules. Tracked files remain valid even when a later broad ignore rule matches. +func validateGeneratedCommitReadiness(repo string) error { + for path := range installedGeneratedPaths(repo) { + absolute, err := resolveRepositoryRelativePath(repo, path) + if err != nil { + return err + } + if _, err := os.Lstat(absolute); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + if _, err := gitCommand(repo, "ls-files", "--error-unmatch", "--", path); err == nil { + continue + } + command := exec.Command("git", "-C", repo, "check-ignore", "-q", "--", path) + if err := command.Run(); err == nil { + return fmt.Errorf("generated update path is ignored and untracked: %s; add an explicit repository-owned ignore exception before publishing the update", path) + } else if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 1 { + return fmt.Errorf("cannot verify generated update path ignore state for %s: %w", path, err) + } + } + return nil +} + func updateOwnedPaths(repo string, config ProjectConfig) map[string]bool { owned := installedGeneratedPaths(repo) owned[".boatstack-project.json"] = true @@ -184,6 +212,9 @@ func PrepareUpdatePublication(repoPath, requestedVersion string) (UpdatePublicat if headBranch != "chore/update-boatstack-"+version { return UpdatePublicationPreview{}, fmt.Errorf("update preview requires branch chore/update-boatstack-%s; current branch is %s", version, headBranch) } + if err := validateGeneratedCommitReadiness(repo); err != nil { + return UpdatePublicationPreview{}, err + } paths, err := updateChangedPathsAgainst(repo, baseRef) if err != nil { return UpdatePublicationPreview{}, err diff --git a/boatstack/update_publication_test.go b/boatstack/update_publication_test.go index 2b3be0d2..cd1d3a13 100644 --- a/boatstack/update_publication_test.go +++ b/boatstack/update_publication_test.go @@ -91,6 +91,39 @@ func TestPrepareUpdatePublicationIsAtomicAndRejectsProductPaths(t *testing.T) { } } +// control-law: published-generated-lock-cannot-name-ignored-untracked-output +func TestPrepareUpdatePublicationRejectsIgnoredGeneratedOutput(t *testing.T) { + repo := updatePublicationTestRepo(t, "v9.8.7") + ignoredPath := ".agents/skills/build/SKILL.md" + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("build/\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".gitignore") + runGit(t, repo, "commit", "-m", "ignore build directories") + runGit(t, repo, "push", "origin", "HEAD:main") + absolute := filepath.Join(repo, filepath.FromSlash(ignoredPath)) + if err := os.MkdirAll(filepath.Dir(absolute), 0o755); err != nil { + t.Fatal(err) + } + value := []byte("generated\n") + if err := os.WriteFile(absolute, value, 0o644); err != nil { + t.Fatal(err) + } + lock, err := MarshalJSON(map[string]any{"files": map[string]string{ + ".cursor/commands/boatstack-update.md": SHA256Bytes([]byte("\nnew\n")), + ignoredPath: SHA256Bytes(value), + }}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "generated.lock.json"), lock, 0o644); err != nil { + t.Fatal(err) + } + if _, err := PrepareUpdatePublication(repo, "v9.8.7"); err == nil || !strings.Contains(err.Error(), ignoredPath) { + t.Fatalf("ignored generated output was accepted: %v", err) + } +} + func TestUpdatePreviewCarriesFingerprintRepairProvenance(t *testing.T) { repo := updatePublicationTestRepo(t, "v9.8.7") result := InstallationRepairResult{ diff --git a/boatstack/visual_evidence.go b/boatstack/visual_evidence.go index b8066fba..854f0432 100644 --- a/boatstack/visual_evidence.go +++ b/boatstack/visual_evidence.go @@ -88,6 +88,19 @@ type PRVisualEvidenceManifest struct { Fingerprint string `json:"fingerprint"` } +// PRVisualPrivacyReview is deliberately separate from the capture manifest. +// Human review authorizes the exact immutable pixels without rewriting their +// evidence identity. +type PRVisualPrivacyReview struct { + SchemaVersion int `json:"schema_version"` + Key string `json:"key"` + EvidenceFingerprint string `json:"evidence_fingerprint"` + PNGHashes []string `json:"png_sha256"` + ReviewerIdentity string `json:"reviewer_identity"` + ReviewedAt string `json:"reviewed_at"` + Fingerprint string `json:"fingerprint"` +} + type PRVisualCapabilityReceipt struct { SchemaVersion int `json:"schema_version"` BoatstackVersion string `json:"boatstack_version"` @@ -198,6 +211,166 @@ func visualEvidenceManifestPath(repo, key string) (string, error) { return filepath.Join(directory, "manifest.json"), nil } +func visualPrivacyReviewPath(repo, key string) (string, error) { + directory, err := visualEvidenceDirectory(repo, key) + if err != nil { + return "", err + } + return filepath.Join(directory, "privacy-review.json"), nil +} + +func visualPrivacyReviewFingerprint(review PRVisualPrivacyReview) (string, error) { + copy := review + copy.Fingerprint = "" + raw, err := MarshalJSON(copy) + if err != nil { + return "", err + } + return SHA256Bytes(raw), nil +} + +func manifestPNGHashes(manifest PRVisualEvidenceManifest) []string { + hashes := make([]string, 0, len(manifest.Items)) + for _, item := range manifest.Items { + hashes = append(hashes, item.SHA256) + } + sort.Strings(hashes) + return hashes +} + +func validateVisualPrivacyReview(review PRVisualPrivacyReview, manifest PRVisualEvidenceManifest) error { + if review.SchemaVersion != visualEvidenceSchemaVersion || review.Key != manifest.Key || review.EvidenceFingerprint != manifest.Fingerprint { + return fmt.Errorf("visual privacy review is stale for the current evidence manifest") + } + if strings.TrimSpace(review.ReviewerIdentity) == "" { + return fmt.Errorf("visual privacy review requires reviewer_identity") + } + if _, err := time.Parse(time.RFC3339, review.ReviewedAt); err != nil { + return fmt.Errorf("visual privacy reviewed_at must be RFC3339: %w", err) + } + expected := manifestPNGHashes(manifest) + actual := append([]string(nil), review.PNGHashes...) + sort.Strings(actual) + if len(expected) != len(actual) { + return fmt.Errorf("visual privacy review does not bind every current PNG") + } + for i := range expected { + if expected[i] != actual[i] { + return fmt.Errorf("visual privacy review is stale because screenshot pixels changed") + } + } + fingerprint, err := visualPrivacyReviewFingerprint(review) + if err != nil { + return err + } + if review.Fingerprint != fingerprint { + return fmt.Errorf("visual privacy review fingerprint is stale") + } + return nil +} + +// RecordPRVisualPrivacyReview records one human decision over the exact current +// manifest fingerprint and PNG hashes. It never modifies capture evidence. +// control-law: pixels-require-exact-human-privacy-receipt-before-github +func RecordPRVisualPrivacyReview(repo, key, evidenceFingerprint, reviewerIdentity string) (PRVisualPrivacyReview, error) { + repo, err := ResolveRepository(repo) + if err != nil { + return PRVisualPrivacyReview{}, err + } + manifest, err := LoadPRVisualEvidence(repo, key) + if err != nil { + return PRVisualPrivacyReview{}, err + } + if strings.TrimSpace(evidenceFingerprint) == "" || evidenceFingerprint != manifest.Fingerprint { + return PRVisualPrivacyReview{}, fmt.Errorf("evidence fingerprint does not match the exact visual manifest under review") + } + if len(manifest.Items) == 0 { + return PRVisualPrivacyReview{}, fmt.Errorf("visual privacy review requires at least one PNG") + } + if current, loadErr := LoadPRVisualPrivacyReview(repo, key); loadErr == nil && current.EvidenceFingerprint == manifest.Fingerprint && current.ReviewerIdentity == strings.TrimSpace(reviewerIdentity) { + return current, nil + } + review := PRVisualPrivacyReview{ + SchemaVersion: visualEvidenceSchemaVersion, Key: manifest.Key, EvidenceFingerprint: manifest.Fingerprint, + PNGHashes: manifestPNGHashes(manifest), ReviewerIdentity: strings.TrimSpace(reviewerIdentity), + ReviewedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), + } + review.Fingerprint, err = visualPrivacyReviewFingerprint(review) + if err != nil { + return PRVisualPrivacyReview{}, err + } + if err := validateVisualPrivacyReview(review, manifest); err != nil { + return PRVisualPrivacyReview{}, err + } + path, err := visualPrivacyReviewPath(repo, key) + if err != nil { + return PRVisualPrivacyReview{}, err + } + raw, err := MarshalJSON(review) + if err != nil { + return PRVisualPrivacyReview{}, err + } + if err := atomicWriteMode(path, raw, 0o600); err != nil { + return PRVisualPrivacyReview{}, err + } + return review, nil +} + +func LoadPRVisualPrivacyReview(repo, key string) (PRVisualPrivacyReview, error) { + manifest, err := LoadPRVisualEvidence(repo, key) + if err != nil { + return PRVisualPrivacyReview{}, err + } + path, err := visualPrivacyReviewPath(repo, key) + if err != nil { + return PRVisualPrivacyReview{}, err + } + raw, err := os.ReadFile(path) + if err != nil { + return PRVisualPrivacyReview{}, err + } + var review PRVisualPrivacyReview + if err := DecodeJSON("load PR visual privacy review", path, raw, &review); err != nil { + return PRVisualPrivacyReview{}, err + } + if err := validateVisualPrivacyReview(review, manifest); err != nil { + return PRVisualPrivacyReview{}, err + } + return review, nil +} + +// ResolvePRVisualPrivacyStatus keeps legacy human-reviewed imports valid while +// requiring automated clean captures to obtain a separate exact-pixel receipt. +func ResolvePRVisualPrivacyStatus(repo string, manifest *PRVisualEvidenceManifest) (string, string, error) { + if manifest == nil || manifest.Relevance != "relevant" || len(manifest.Items) == 0 { + return "NOT_APPLICABLE", "", nil + } + allHumanReviewed := true + for _, item := range manifest.Items { + allHumanReviewed = allHumanReviewed && item.PrivacyStatus == "human-reviewed" + } + if allHumanReviewed { + return "PASS", SHA256Bytes([]byte("legacy-human-reviewed\x00" + manifest.Fingerprint)), nil + } + review, err := LoadPRVisualPrivacyReview(repo, manifest.Key) + if err == nil { + return "PASS", review.Fingerprint, nil + } + path, pathErr := visualPrivacyReviewPath(repo, manifest.Key) + if pathErr == nil && os.IsNotExist(err) { + return "REVIEW_REQUIRED", "", nil + } + if pathErr == nil { + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + return "REVIEW_REQUIRED", "", nil + } + } + if strings.Contains(err.Error(), "visual privacy review is stale") || strings.Contains(err.Error(), "screenshot pixels changed") || strings.Contains(err.Error(), "does not bind every current PNG") { + return "REVIEW_REQUIRED", "", nil + } + return "", "", err +} + func visualCapabilityPath(repo string) (string, error) { common, err := gitCommonDir(repo) if err != nil { diff --git a/boatstack/visual_evidence_test.go b/boatstack/visual_evidence_test.go index a8f9dd30..f7f1dab5 100644 --- a/boatstack/visual_evidence_test.go +++ b/boatstack/visual_evidence_test.go @@ -116,6 +116,69 @@ func TestPRVisualEvidenceRequiresPrivacyReview(t *testing.T) { } } +func TestAutomatedVisualPrivacyReviewBindsExactPixelsAndReplaysIdempotently(t *testing.T) { + repo := visualTestRepo(t) + pngPath := filepath.Join(t.TempDir(), "warning.png") + writeTestPNG(t, pngPath) + manifest, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{ + Key: "automated-warning", Policy: "require", Relevance: "relevant", RelevanceSource: "repository-evidenced", + Status: "PASS", SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), ProductDiffSHA256: strings.Repeat("a", 64), + Scenarios: []PRVisualScenario{{ID: "warning", Entry: "/onboarding", State: "picker open", Viewport: "1440x900", Expected: []string{"warning visible"}}}, + Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: pngPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "clean"}}, + Publication: PRVisualPublication{State: "pending"}, + }) + if err != nil { + t.Fatal(err) + } + if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest); err != nil || status != "REVIEW_REQUIRED" || fingerprint != "" { + t.Fatalf("automated capture bypassed human review: %s %q %v", status, fingerprint, err) + } + if _, err := RecordPRVisualPrivacyReview(repo, manifest.Key, strings.Repeat("0", 64), "reviewer@example.invalid"); err == nil { + t.Fatal("mismatched evidence fingerprint was accepted") + } + review, err := RecordPRVisualPrivacyReview(repo, manifest.Key, manifest.Fingerprint, "reviewer@example.invalid") + if err != nil { + t.Fatal(err) + } + replayed, err := RecordPRVisualPrivacyReview(repo, manifest.Key, manifest.Fingerprint, "reviewer@example.invalid") + if err != nil || replayed.Fingerprint != review.Fingerprint || replayed.ReviewedAt != review.ReviewedAt { + t.Fatalf("exact review replay was not idempotent: %#v %#v %v", review, replayed, err) + } + if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &manifest); err != nil || status != "PASS" || fingerprint != review.Fingerprint { + t.Fatalf("current privacy review was not accepted: %s %q %v", status, fingerprint, err) + } + changedPath := filepath.Join(t.TempDir(), "changed.png") + file, err := os.Create(changedPath) + if err != nil { + t.Fatal(err) + } + canvas := image.NewRGBA(image.Rect(0, 0, 4, 3)) + canvas.Set(1, 1, color.RGBA{R: 10, G: 20, B: 240, A: 255}) + if err := png.Encode(file, canvas); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + changed, err := SavePRVisualEvidence(repo, PRVisualEvidenceManifest{ + Key: manifest.Key, Policy: manifest.Policy, Relevance: manifest.Relevance, RelevanceSource: manifest.RelevanceSource, + Status: manifest.Status, SourceCommit: manifest.SourceCommit, ProductDiffSHA256: manifest.ProductDiffSHA256, + Scenarios: manifest.Scenarios, + Items: []PRVisualEvidenceItem{{ScenarioID: "warning", Path: changedPath, Viewport: "1440x900", CapturedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Status: "captured", PrivacyStatus: "clean"}}, + Publication: PRVisualPublication{State: "pending"}, + }) + if err != nil { + t.Fatal(err) + } + if status, fingerprint, err := ResolvePRVisualPrivacyStatus(repo, &changed); err != nil || status != "REVIEW_REQUIRED" || fingerprint != "" { + t.Fatalf("changed current pixels did not require a fresh review: %s %q %v", status, fingerprint, err) + } + if _, err := LoadPRVisualPrivacyReview(repo, manifest.Key); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("changed pixels did not invalidate privacy review: %v", err) + } +} + func TestPRVisualCapabilityReceiptInvalidatesChangedInputs(t *testing.T) { repo := visualTestRepo(t) receipt := PRVisualCapabilityReceipt{ diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 7dccc334..6ff1f7af 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **24480 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **24542 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`53f0f064a116c6d0edbacd2b07dba935481bba96`](https://github.com/operatorstack/intelligence-flow/tree/53f0f064a116c6d0edbacd2b07dba935481bba96/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 [`4f00d6d7338b12116fd5757238dd45a2ae344237`](https://github.com/operatorstack/intelligence-flow/tree/4f00d6d7338b12116fd5757238dd45a2ae344237/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 e5cdb1fd..b7a2cd64 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "53f0f064a116c6d0edbacd2b07dba935481bba96", + "source_commit": "4f00d6d7338b12116fd5757238dd45a2ae344237", "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" }, { "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:53f0f064a116c6d0edbacd2b07dba935481bba96" + "last_verified_version": "source:4f00d6d7338b12116fd5757238dd45a2ae344237" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index a785675f..331bd092 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": "53f0f064a116c6d0edbacd2b07dba935481bba96", + "source_commit": "4f00d6d7338b12116fd5757238dd45a2ae344237", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-08-09-boatstack-delivery-frontier.md b/release-notes/2026-08-09-boatstack-delivery-frontier.md new file mode 100644 index 00000000..7b4d98bb --- /dev/null +++ b/release-notes/2026-08-09-boatstack-delivery-frontier.md @@ -0,0 +1,5 @@ +### Make delivery readiness and publication authority explicit + +Boatstack now records a secret-free command event trail, reports command coverage and timing by feature, and blocks planning before feature artifacts are written when installed or generated state is unhealthy. Update publication also names generated paths that are untracked or ignored. + +PR delivery now normalizes accepted origin-base spellings, requires an exact human privacy-review receipt for automated screenshots, and revalidates that receipt before GitHub mutation. A current PR-target autonomy receipt may drive one matching open or update action from the exact checked preview; stale or mismatched authority stops safely.