diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b676169..a1a53f1 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/eec4b62c152cc2da37579f891706640bff9cb1a6/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/719220d52b8ac1237c9169099b53a024dc583cc6/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 3ec9fcf..852f3ac 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "ff655c8e8bb64b7c06dfd123aba96cabe03f30541e5d33c338933bf3c6baf45e", + "CONTRIBUTING.md": "d036445ff05dfc40abb8a74d9bab61411474599a66c8e6678b72987e2ad9df2d", "README.md": "6b7402c5cef5b3b9b739281d3d4d576cdc995796ff127fc6aefb97c5743e0bac", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,7 +40,8 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/flow.go": "795e6129351600cd81ffa86db5e1016dfae894c2d0e9b75615f37eb5fdf5f939", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "8ece156fa5c341e5ceb41d0a7abe4b7da378a0db9244bdb25b348b02e2c39b0a", + "boatstack/cmd/boatstack-helper/flow.go": "5ab24541d3f85c2f442730d3122d18eb6676600bc11fde4805e803a25a8300c7", "boatstack/cmd/boatstack-helper/main.go": "a4a29e53d803cd1d8ec35e105bab17000e2855f978393031f1516aab26c732b6", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", @@ -49,22 +50,29 @@ "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", - "boatstack/delivery.go": "3d1580512c1922ae4ce349a790acfb3356e3c10105af7871457e37fffda39416", + "boatstack/delivery.go": "772793e493b97348c198692a282035a40257b7ca8e21bdea66a881a5c805c22a", "boatstack/delivery_boundary_conformance_test.go": "800cd722d8d2a696a0529e8343d3523e453bb052f0917c8a2cad2990296ac1b3", + "boatstack/delivery_migrate.go": "7566e49f9c1838d4d563866e941c7aacd61ac918c9e886222282398d287ca780", + "boatstack/delivery_migrate_conformance_test.go": "b8ba53681e1d0361ac62b06586c62b7763d55a65b5427976b5289e1fb1503bdc", "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", - "boatstack/deliverycontrol_parity_test.go": "027c04471c6037fc585a8af8646171addcc548e43147c5c6dbbe76bad9f5a10c", + "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", "boatstack/export.go": "9cb23234e6cd79441ff6f39f88ed66d6d47ef7c27901404439a3572b03fdf881", "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", - "boatstack/flow_control.go": "9eaa1188f86c307bf0f31a5ca465637e5fd6792f42206dce10d0272a1d1ea36b", + "boatstack/flow_control.go": "58e721c4704d260511eaf05b190b8fd97914931e3b74ccd896c88fc2564564a3", "boatstack/flow_control_test.go": "d52e095f2e0f18067abe03e3b5f7c98bc30f8b1c8f5230103797c599aec95013", + "boatstack/flow_drive.go": "a501ceda390dfd3605e22cf7ecfa15f9d50240b3fac6ebb2bb2d80c615d0a9fc", + "boatstack/flow_drive_conformance_test.go": "23edea926c271a1f5718fb9dae1da11e4bf03cceb1357290cd61cd8ffb73beda", "boatstack/flow_guard.go": "dd18524d95f4a220cfd3d11b11003dacc52120785ee0ccdbeceb2307fab55872", "boatstack/flow_guard_test.go": "8ba75f11ddd080427c15bd7e25f7d03c1b746a2f587c212cea0e710337d1c0e1", + "boatstack/flow_prescribe_conformance_test.go": "e2287aa079b7aafaf822e1f752a1bb5017acda811363e576eeff793347d0d5c8", "boatstack/flow_report.go": "9e58cec51c6c903847f3ebc79cd6bf25e2f91d14b81811e82e5f4e026a7b71a3", "boatstack/flow_report_test.go": "eae00f2b8ead4f1ec20e1f1bc47db4c53a36048bb84eca9bea4f1a2105bdcdde", + "boatstack/flow_tasks.go": "690db05d345dabdb24965015c94198aa3f93d2d9691599ae8e6a2ac3aafb9d44", + "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", "boatstack/flow_trace.go": "95b5a99f5f557a27a3eca7152de9ec18459f2a88d9749924432463031536a96c", "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", @@ -90,7 +98,7 @@ "boatstack/internal/deliverycontrol/liveness_test.go": "7a148075d9d2c5df468fecb710bdd38226c4cd4b37584a810b6848909a0f3292", "boatstack/internal/deliverycontrol/oracle.go": "80765b1946d6c863f0e635a99b68d3ccafa7ff235360fba774811b5b0de791da", "boatstack/internal/deliverycontrol/oracle_test.go": "ce320a71f0c9440c5a7bc1b742d0f74c6a46759845e919ab36bde5ff8fabb311", - "boatstack/internal/deliverycontrol/registry.go": "aa89cef9eec8d715c06d2f61a472df20bb9334c950a6d751a1e15d67b567c433", + "boatstack/internal/deliverycontrol/registry.go": "d77c9ceea1feb576b857c3d1f4fc517afbf3f38cb43525a8a139206a6802980d", "boatstack/internal/deliverycontrol/registry_test.go": "473ab5e5d33f84d34c29a219db867abfc6eb3ad4489f3d5d0c7dc09b06d193f3", "boatstack/internal/deliverycontrol/state.go": "2551624bbcbd8f9dd897a1e2240cef2cc1895d117a4030525d88f1d62f6e395e", "boatstack/internal/deliverycontrol/trajectory.go": "4469a0c35b40f8e2a37060e9b26fcbda7d34d020088c31de367dd5cf6e7721dc", @@ -104,7 +112,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "d45f4e5b3ab7072e700725cab5b6eac83b1a460cd08f07b55370de5ee4d8ee59", + "boatstack/next.go": "39d813c96a6a5119efcabda0f59ce7c6faf91e5e76c77c7be999bf85f43de284", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "62f97bf2091f33eb2ca91915bf08bee73d53387611b673e849355bfd516ca467", @@ -167,10 +175,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "e42fd1e704cc9d60d7fcb79c53e248944caf4984b02df1c496aceec5a5e51072", + "docs/evidence-engineered-coding.md": "f8abc34bc426482e0fa0f7e30fc0c5b6cdbfc03b25fdc81963b1e718e8736922", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "52529747f11523647ca794a4d992008537e51d473935cf644144f95a4207603d", + "docs/public-claims.json": "178cdeb2738ef09101e056591760ecf450666ad039931134987cb225512ffd81", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -184,7 +192,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": "30ac25c13348433f4be8c4f3df06b51431a434b427ddd6d1f4f26f14ad550f6c", + "labs/diagram-json/plan.lock.json": "f3e4094f2ac5731cf8774986c3e8233d1dbc843d5684efc9b2ab7ba245cea340", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -268,11 +276,15 @@ "release-notes/2026-07-25-boatstack-banner.md": "28e83f294de606211cfdc91b2586aa834e004dee76d5c4bee08859986ae86b5b", "release-notes/2026-07-25-delivery-control-inventory.md": "1f359bcf4071dd47bd1011c877db573bd26309d28683abea8389c353f1c6c88d", "release-notes/2026-07-25-delivery-flow-navigation-model.md": "b2d805fae30100a7de4e76760341247237cc2476fdcc57d99074825bf47d6450", + "release-notes/2026-07-25-deliverycontrol-change-safety-net.md": "1cdf220b40d3577cb6e2b98b9c3dcd41e54fdf905746814b074f4e1aa9b751d1", "release-notes/2026-07-25-deliverycontrol-coding-telemetry.md": "a229c737bfc9cc5ceddf109c246c41f00619e8a3fef0e0e78870e4255131741b", "release-notes/2026-07-25-deliverycontrol-flow-check-advisory.md": "80ffb0fb64958d41f0b05fb3ef25ad464e63d1bc8021561c7360827859b6da77", "release-notes/2026-07-25-deliverycontrol-flow-control.md": "4aeb013d9f0c54370a364b5fc9ee3aa30ec305d00ab77575b873bc1fd8da6ebf", + "release-notes/2026-07-25-deliverycontrol-flow-next-execute.md": "3dd4c2997c5fef0750f5a14cdfccfbdd535ddd618424bbdba8f9974e57b570c1", + "release-notes/2026-07-25-deliverycontrol-flow-next-prescribes-command.md": "63c6949c2955bbc80950a6716df15851282f5b74c460513032bd55a5c861c1bb", "release-notes/2026-07-25-deliverycontrol-flow-oracle.md": "04e64e27638b32a90a132f615f896bf20ee805f3c75bf5e3ab088f3c55e641a7", "release-notes/2026-07-25-deliverycontrol-flow-report.md": "86c519e7debd72362f8ef6ef21ec443912aa705669f396259ba771303925bbb5", + "release-notes/2026-07-25-deliverycontrol-flow-tasks-subaction.md": "f5fb40312e4f3084d352492805a1a9d2f23ee1a0a38057d2c696a995044101b4", "release-notes/2026-07-25-deliverycontrol-shadow-registry.md": "e7f8ca4e4f188eda3088e46cba77369d8ff0d903f29e43846103d986e79a2273", "release-notes/2026-07-25-evidence-path-resolution.md": "b32cb8a6e69f397f751c3a7fb62be254a7407a28bed25ae9108d6d773c863d11", "release-notes/2026-07-25-published-slice-correction-routing.md": "129cdd62c80c8b93060726027d68ba3abdb0bca1a1ce9e64d6053271af3fd082", @@ -281,7 +293,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "eec4b62c152cc2da37579f891706640bff9cb1a6", + "commit": "719220d52b8ac1237c9169099b53a024dc583cc6", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go new file mode 100644 index 0000000..c7c5bf1 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "sort" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// control-law: registry-covers-real-delivery-machine +// +// The deliverycontrol registry is the authoritative projection of the real +// delivery state machine: the flow commands resolve the prescribed CLI command +// through it (Transition(id).CLIVerb). For that projection to be trustworthy, the +// registry must cover EXACTLY the real delivery machine — every registry CLIVerb +// must name a real dispatch verb, and every real delivery-mutation/observe +// dispatch verb must have a registry row. Nothing asserted this before, so a new +// delivery CLI verb could ship green with no registry row, invisible to the +// liveness/deadlock guarantee. These tests close that gap in both directions. +// +// The real dispatch inventory is read from the actual `run()` switch in main.go +// by parsing its AST, so it cannot drift from what the binary really accepts. +// Every dispatch verb must be classified: either it is a delivery-machine verb +// (a registry CLIVerb) or it is explicitly declared out of the delivery machine +// in nonDeliveryVerbs below. A new verb that is neither fails the suite — the +// author must consciously register it or declare it non-delivery (the Bypass +// guard). This list is behavior describing, not behavior defining: it names the +// dispatch verbs that are not transitions of the delivery state machine. +var nonDeliveryVerbs = map[string]bool{ + // Update / release / distribution lifecycle (not the per-feature delivery machine). + "init": true, + "update": true, + "check-update": true, + "prepare-update-pr": true, + "publish-update-pr": true, + "release-classify": true, + "next-patch": true, + "export": true, + "migrate-config": true, + "hydrate-runtime": true, + "version": true, + // Planning phase, before a plan is activated into a delivery. + "check-source-plan": true, + "check-plan": true, + "planning-write": true, + "record-approval": true, + // Read-only status / diagnostics (observe helpers, not modeled transitions). + "repair-status": true, + "operation-status": true, + "mutation-status": true, + "run-preflight": true, + "check-safety": true, + "doctor": true, + "diagnose-hook": true, + "workspace-status": true, + // Evidence / capability substrate (a separate tenant, not the delivery graph). + "record-pr-visual-evidence": true, + "capture-evidence": true, + "provision-capability": true, + "capability-register": true, + "record-pr-visual-publication": true, + // PR construction / verification helpers reached around the ship gate. + "check-pr": true, + // Safety hooks and workspace management (guard/scaffold, not delivery moves). + "safety-hook": true, + "bootstrap-safety-hook": true, + "workspace-cut": true, + "workspace-cleanup": true, + "workspace-sync": true, + // Flow layer itself is read-only navigation over the machine, not a transition. + "flow": true, +} + +// dispatchVerbs parses main.go and returns the set of command verbs the run() +// switch actually dispatches. It fails loudly rather than returning an empty set, +// so the coverage guarantee can never silently pass by finding nothing. +func dispatchVerbs(t *testing.T) map[string]bool { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + verbs := map[string]bool{} + ast.Inspect(file, func(n ast.Node) bool { + fn, ok := n.(*ast.FuncDecl) + if !ok || fn.Name.Name != "run" { + return true + } + ast.Inspect(fn.Body, func(inner ast.Node) bool { + sw, ok := inner.(*ast.SwitchStmt) + if !ok || !switchesOnArgs(sw.Tag) { + return true + } + for _, stmt := range sw.Body.List { + clause, ok := stmt.(*ast.CaseClause) + if !ok { + continue + } + for _, expr := range clause.List { + if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING { + verbs[mustUnquote(t, lit.Value)] = true + } + } + } + return false + }) + return false + }) + if len(verbs) < 10 { + t.Fatalf("dispatch switch parse found only %d verbs; expected the full run() command set — the coverage guard would be vacuous", len(verbs)) + } + return verbs +} + +// 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 { + index, ok := tag.(*ast.IndexExpr) + if !ok { + return false + } + sel, ok := index.X.(*ast.SelectorExpr) + return ok && sel.Sel.Name == "Args" +} + +func mustUnquote(t *testing.T, quoted string) string { + t.Helper() + if len(quoted) < 2 { + t.Fatalf("malformed string literal %q in dispatch switch", quoted) + } + return quoted[1 : len(quoted)-1] +} + +func registryVerbs() map[string]bool { + verbs := map[string]bool{} + for _, tr := range deliverycontrol.Transitions() { + if tr.CLIVerb != "" { + verbs[tr.CLIVerb] = true + } + } + return verbs +} + +// Positive: every CLIVerb the registry declares names a real dispatch verb, so +// the prescribed command a resolver emits through Transition(id).CLIVerb is +// always a command the binary actually accepts. +func TestRegistryCLIVerbsAreRealDispatchVerbs(t *testing.T) { + dispatch := dispatchVerbs(t) + for verb := range registryVerbs() { + if !dispatch[verb] { + t.Errorf("registry declares CLIVerb %q that main.go does not dispatch (prescribed command would be unrunnable)", verb) + } + } +} + +// Bypass / Negative: every dispatch verb must be classified — a delivery-machine +// verb (registry CLIVerb) or explicitly non-delivery. A new delivery CLI verb +// added to the dispatch switch without a registry row (or a conscious +// non-delivery declaration) fails here; it cannot ship invisibly to the machine. +func TestEveryDispatchVerbIsClassified(t *testing.T) { + dispatch := dispatchVerbs(t) + registry := registryVerbs() + for verb := range dispatch { + if registry[verb] { + continue + } + if nonDeliveryVerbs[verb] { + continue + } + t.Errorf("dispatch verb %q is neither a registry delivery transition nor declared in nonDeliveryVerbs; register it or classify it before shipping", verb) + } +} + +// Relation: the delivery-machine dispatch verbs (all dispatch verbs minus the +// declared non-delivery ones) equal the registry CLIVerb set exactly — the two +// inventories agree with no orphan on either side. +func TestDeliveryDispatchVerbsEqualRegistry(t *testing.T) { + dispatch := dispatchVerbs(t) + registry := registryVerbs() + + deliveryDispatch := map[string]bool{} + for verb := range dispatch { + if !nonDeliveryVerbs[verb] { + deliveryDispatch[verb] = true + } + } + if diff := symmetricDiff(deliveryDispatch, registry); len(diff) != 0 { + sort.Strings(diff) + t.Errorf("delivery dispatch verbs and registry CLIVerbs disagree: %v", diff) + } + + // A non-delivery declaration must name a verb that is actually dispatched; + // a stale entry (verb renamed/removed) is drift and must be cleaned up. + for verb := range nonDeliveryVerbs { + if !dispatch[verb] { + t.Errorf("nonDeliveryVerbs names %q which main.go no longer dispatches (stale allowlist entry)", verb) + } + } +} + +func symmetricDiff(a, b map[string]bool) []string { + var diff []string + for k := range a { + if !b[k] { + diff = append(diff, "only-in-dispatch:"+k) + } + } + for k := range b { + if !a[k] { + diff = append(diff, "only-in-registry:"+k) + } + } + return diff +} diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go index 2397d75..a846448 100644 --- a/boatstack/cmd/boatstack-helper/flow.go +++ b/boatstack/cmd/boatstack-helper/flow.go @@ -14,7 +14,7 @@ import ( // gate, authority, or exit code. func flowCommand(arguments []string) int { if len(arguments) == 0 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") return 2 } switch arguments[0] { @@ -22,6 +22,8 @@ func flowCommand(arguments []string) int { return flowCheckCommand(arguments[1:]) case "next": return flowNextCommand(arguments[1:]) + case "tasks": + return flowTasksCommand(arguments[1:]) case "report": return flowReportCommand(arguments[1:]) default: @@ -56,12 +58,15 @@ func flowCheckCommand(arguments []string) int { // flowNextCommand advises the lowest-cost next move toward a published delivery. // It is purely advisory and never fails on flow position — an unresolved flow -// still prints the authoritative recommendation. +// still prints the authoritative recommendation. With --execute (opt-in, +// default-off), it additionally runs any move the driver proves safe and fully +// state-derivable, and prescribes-and-stops at the first human/evidence gate. func flowNextCommand(arguments []string) int { flags := flag.NewFlagSet("flow next", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose delivery flow should be advised") feature := flags.String("feature", "", "optional specific managed feature to advise") jsonOutput := flags.Bool("json", false, "print the structured advisory") + execute := flags.Bool("execute", false, "opt in to auto-running safe, state-derivable moves (default off; stops at human/evidence gates)") if err := flags.Parse(arguments); err != nil { return 2 } @@ -78,6 +83,95 @@ func flowNextCommand(arguments []string) int { } else { fmt.Print(boatstack.FormatFlowNext(next)) } + if *execute { + return driveExecute(*repo, *feature, next) + } + return 0 +} + +// driveExecute runs the opt-in execute driver: it takes the oracle's lowest-cost +// edge and runs it only when the driver proves the move is safe and fully +// state-derivable, re-resolving after each executed step. At the first move that +// owes human input, is off the auto-drive allowlist, or when the kill switch is +// set, it prescribes-and-stops. It never fabricates evidence, a gate status, a +// preview fingerprint, or reviewer identity. The loop is bounded as a backstop +// against any cycle in the model. +func driveExecute(repo, feature string, next boatstack.FlowNext) int { + const maxDriveSteps = 32 + for step := 0; step < maxDriveSteps; step++ { + decision := boatstack.DecideDrive(next, true, boatstack.FlowDriveKilled()) + switch decision.Action { + case boatstack.DriveNone: + fmt.Println("drive: nothing to run —", decision.Reason) + return 0 + case boatstack.DrivePrescribe: + fmt.Println("drive: stopping —", decision.Reason) + if decision.Command != nil { + fmt.Println("drive: run by hand:", decision.Command.CommandLine()) + } + return 0 + case boatstack.DriveExecute: + fmt.Println("drive: running", decision.Command.CommandLine()) + if err := executePrescribed(decision.Command); err != nil { + return fail(err) + } + // Re-resolve from ground truth so the next decision reflects the mutation + // the executed command committed, never an assumed position. + resolved, err := boatstack.NextControl(repo, feature) + if err != nil { + return fail(err) + } + next = resolved + default: + return fail(fmt.Errorf("drive: unknown decision %q", decision.Action)) + } + } + fmt.Println("drive: step budget exhausted; stopping") + return 0 +} + +// 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 +// 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. + default: + return fmt.Errorf("no registered auto-executor for verb %q; run it by hand: %s", cmd.Verb, cmd.CommandLine()) + } +} + +// flowTasksCommand renders the active delivery slice's sub-actions from the +// compiled plan task DAG, in dependency order, with the one to start pointed at. +// It is read-only and never fails on flow position — an unresolved slice or an +// unreadable task graph prints its reason rather than a guessed sub-action. +func flowTasksCommand(arguments []string) int { + flags := flag.NewFlagSet("flow tasks", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose active-slice sub-actions should be listed") + feature := flags.String("feature", "", "managed Boatstack feature slug") + jsonOutput := flags.Bool("json", false, "print the structured task ordering") + if err := flags.Parse(arguments); err != nil { + return 2 + } + tasks, err := boatstack.FlowTasksForActiveSlice(*repo, *feature) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(tasks) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowTasks(tasks)) + } return 0 } diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 37b481b..d25c0d6 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -13,6 +13,27 @@ import ( const deliveryStateSchemaVersion = 1 +// Slice-status lifecycle literals — the canonical string values stored in +// DeliverySlice.Status. These are the single source for the slice-status +// vocabulary; DeliverySliceStatuses returns the full set so the deliverycontrol +// registry parity test can pin its mirror against them instead of a +// hand-maintained list (control-law: registry-covers-real-delivery-machine). +// ResumeStage and WorkflowStage are separate vocabularies, not slice statuses. +const ( + StatusPending = "PENDING" + StatusBuild = "BUILD" + StatusTestPassed = "TEST_PASSED" + StatusReviewPassed = "REVIEW_PASSED" + StatusPublished = "PUBLISHED" +) + +// DeliverySliceStatuses returns the canonical slice-status lifecycle set, in +// order. It is the single source the registry conformance test mirrors, so a new +// slice status cannot be introduced without appearing here. +func DeliverySliceStatuses() []string { + return []string{StatusPending, StatusBuild, StatusTestPassed, StatusReviewPassed, StatusPublished} +} + type DeliverySlice struct { ID string `json:"id"` Title string `json:"title"` @@ -163,7 +184,7 @@ func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { } } } - return []DeliverySlice{{ID: "delivery", Title: "Feature delivery", TaskIDs: taskIDs, AcceptanceCriteria: criteria, Status: "BUILD"}}, nil + return []DeliverySlice{{ID: "delivery", Title: "Feature delivery", TaskIDs: taskIDs, AcceptanceCriteria: criteria, Status: StatusBuild}}, nil } items, ok := objectSlice(plan["delivery_slices"]) if !ok || len(items) == 0 { @@ -225,11 +246,11 @@ func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { } result = append(result, DeliverySlice{ ID: id, Title: title, TaskIDs: mapped, AcceptanceCriteria: criteria, AffectedPaths: affectedPaths, - Status: "PENDING", BaseBranch: strings.TrimSpace(stringValue(item["base_branch"])), + Status: StatusPending, BaseBranch: strings.TrimSpace(stringValue(item["base_branch"])), HeadBranch: strings.TrimSpace(stringValue(item["head_branch"])), }) if index == 0 { - result[index].Status = "BUILD" + result[index].Status = StatusBuild } } unassigned := []string{} @@ -315,6 +336,14 @@ func LoadDeliveryState(repo, feature string) (DeliveryState, error) { if err != nil { return DeliveryState{}, fmt.Errorf("managed delivery state is missing: %w", err) } + // Route the raw document through the schema-migration hook before decoding. At + // the current schema version this is a byte-for-byte pass-through; it exists so a + // future version has a defined upgrade path and so state from a newer Boatstack + // fails closed with an actionable message rather than as generic corruption. + value, _, err = migrateDeliveryStateBytes(value) + if err != nil { + return DeliveryState{}, err + } var state DeliveryState if err := DecodeJSON("load managed delivery state", path, value, &state); err != nil { return DeliveryState{}, err @@ -447,9 +476,9 @@ func reconcileAmendedDeliveryState(existing DeliveryState, newSlices []DeliveryS for i := existing.ActiveIndex; i < len(newSlices); i++ { slice := newSlices[i] if i == existing.ActiveIndex { - slice.Status = "BUILD" + slice.Status = StatusBuild } else { - slice.Status = "PENDING" + slice.Status = StatusPending } merged = append(merged, slice) } @@ -651,9 +680,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } } if resume == "REVIEW_GATE" { - slice.Status = "TEST_PASSED" + slice.Status = StatusTestPassed } else { - slice.Status = "BUILD" + slice.Status = StatusBuild } if err := saveDeliveryState(repo, state); err != nil { return ChangeObservation{}, DeliveryState{}, err @@ -687,9 +716,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio } } if resume == "REVIEW_GATE" { - slice.Status = "TEST_PASSED" + slice.Status = StatusTestPassed } else { - slice.Status = "BUILD" + slice.Status = StatusBuild } } if err := saveDeliveryState(repo, state); err != nil { @@ -973,7 +1002,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head) } if gate == "review" { - if slice.Status != "TEST_PASSED" { + if slice.Status != StatusTestPassed { return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s must pass its test gate before review", slice.ID) } testReceipt, receiptErr := readDeliveryReceipt(repo, options.Feature, slice.ID, "test") @@ -1047,12 +1076,12 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error state.Slices[sliceIndex].BaseBranch = base state.Slices[sliceIndex].HeadBranch = head if gate == "test" { - state.Slices[sliceIndex].Status = "TEST_PASSED" + state.Slices[sliceIndex].Status = StatusTestPassed if reviewPath, pathErr := deliveryReceiptPath(repo, options.Feature, slice.ID, "review"); pathErr == nil { _ = os.Remove(reviewPath) } } else { - state.Slices[sliceIndex].Status = "REVIEW_PASSED" + state.Slices[sliceIndex].Status = StatusReviewPassed state.Mode = "NORMAL" state.ResumeStage = "" state.ActiveObservationID = "" @@ -1081,7 +1110,7 @@ func CheckDeliveryReadyForShip(repo, feature, sliceID, base, head, diffHash stri // A published-open slice that has been re-gated in place is REVIEW_PASSED again; // an already-PUBLISHED slice that has not been re-gated is still shippable as an // idempotent --action update of its open PR. - if slice.Status != "REVIEW_PASSED" && slice.Status != "PUBLISHED" { + if slice.Status != StatusReviewPassed && slice.Status != StatusPublished { return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery slice %s has not passed test and review gates", slice.ID) } if err := validateDeliveryScope(feature, slice, changed); err != nil { @@ -1128,17 +1157,17 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { // Re-publishing an already-PUBLISHED, non-terminal slice is an idempotent // --action update of its still-open PR: refresh the recorded PR URL and keep // PRState OPEN, but do NOT advance the BUILD pointer a second time. - if slice.Status == "PUBLISHED" { + if slice.Status == StatusPublished { state.Slices[sliceIndex].PRURL = url if strings.TrimSpace(state.Slices[sliceIndex].PRState) == "" { state.Slices[sliceIndex].PRState = "OPEN" } return saveDeliveryState(repo, state) } - if slice.Status != "REVIEW_PASSED" { + if slice.Status != StatusReviewPassed { return fmt.Errorf("delivery slice %s is not ready to publish", sliceID) } - state.Slices[sliceIndex].Status = "PUBLISHED" + state.Slices[sliceIndex].Status = StatusPublished state.Slices[sliceIndex].PRURL = url state.Slices[sliceIndex].PRState = "OPEN" // Only a first publication of the active slice advances the BUILD pointer to @@ -1146,7 +1175,7 @@ func MarkDeliveryPublished(repo, feature, sliceID, url string) error { if sliceIndex == state.ActiveIndex { state.ActiveIndex++ if state.ActiveIndex < len(state.Slices) { - state.Slices[state.ActiveIndex].Status = "BUILD" + state.Slices[state.ActiveIndex].Status = StatusBuild state.RepairAttempt = 0 state.ActiveObservationID = "" state.ResumeStage = "" diff --git a/boatstack/delivery_migrate.go b/boatstack/delivery_migrate.go new file mode 100644 index 0000000..9de38b0 --- /dev/null +++ b/boatstack/delivery_migrate.go @@ -0,0 +1,103 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "strings" +) + +// deliveryStateMigration upgrades a managed delivery-state document one schema +// version forward. `from` and `to` bound the step; `apply` transforms the decoded +// document. The registered set is empty today — schema version 1 is the only +// version that has ever been written — so migration is a behavior-preserving +// pass-through. It exists so a future deliveryStateSchemaVersion bump has a +// defined, tested upgrade path instead of silently failing old state closed. +type deliveryStateMigration struct { + from int + to int + apply func(map[string]any) (map[string]any, error) +} + +// deliveryStateMigrations is the ordered, one-step-at-a-time upgrade chain for the +// managed delivery state. It is intentionally empty at schema version 1; a schema +// bump adds one entry per version step, each covered by conformance. +var deliveryStateMigrations []deliveryStateMigration + +// migrateDeliveryStateBytes upgrades raw managed-delivery-state JSON to the current +// deliveryStateSchemaVersion using the registered migration chain. It is the +// production wrapper over the pure migrateDeliveryStateBytesWith. +func migrateDeliveryStateBytes(raw []byte) (upgraded []byte, changed bool, err error) { + return migrateDeliveryStateBytesWith(raw, deliveryStateMigrations, deliveryStateSchemaVersion) +} + +// migrateDeliveryStateBytesWith is the pure migration engine, taking its migration +// chain and target version as parameters so it is testable independently of the +// production (empty) chain. It mirrors MigrateConfigBytes exactly: +// +// - blank input is a no-op pass-through (nothing to migrate); +// - a document already at the target version passes through byte-for-byte +// unchanged (the only path that runs today at version 1); +// - an older document is walked forward one registered step at a time and its +// schema_version stamped to the target; +// - a document written by a NEWER Boatstack fail-closes with an actionable +// message rather than being silently treated as corrupt; +// - a missing step in the chain fail-closes rather than guessing. +func migrateDeliveryStateBytesWith(raw []byte, migrations []deliveryStateMigration, target int) (upgraded []byte, changed bool, err error) { + if len(strings.TrimSpace(string(raw))) == 0 { + return raw, false, nil + } + + var partial map[string]any + if err := json.Unmarshal(raw, &partial); err != nil { + return nil, false, fmt.Errorf("failed to parse delivery state JSON: %w", err) + } + + fromVer := 1 // a document with no schema_version predates versioning: treat as v1. + if v, ok := partial["schema_version"]; ok { + switch val := v.(type) { + case float64: + fromVer = int(val) + case int: + fromVer = val + default: + return nil, false, fmt.Errorf("delivery state schema_version must be an integer") + } + } + + if fromVer > target { + return nil, false, fmt.Errorf("managed delivery state was written by a newer Boatstack (schema %d > %d); update Boatstack", fromVer, target) + } + if fromVer == target { + return raw, false, nil + } + + current := fromVer + data := partial + for current < target { + var found *deliveryStateMigration + for i := range migrations { + if migrations[i].from == current { + found = &migrations[i] + break + } + } + if found == nil { + return nil, false, fmt.Errorf("no delivery-state migration found from schema %d toward %d", current, target) + } + if found.to <= current { + return nil, false, fmt.Errorf("invalid delivery-state migration path from %d to %d", found.from, found.to) + } + data, err = found.apply(data) + if err != nil { + return nil, false, fmt.Errorf("failed to apply delivery-state migration from %d to %d: %w", found.from, found.to, err) + } + current = found.to + } + + data["schema_version"] = target + upgraded, err = MarshalJSON(data) + if err != nil { + return nil, false, fmt.Errorf("failed to marshal migrated delivery state: %w", err) + } + return upgraded, true, nil +} diff --git a/boatstack/delivery_migrate_conformance_test.go b/boatstack/delivery_migrate_conformance_test.go new file mode 100644 index 0000000..91c76f0 --- /dev/null +++ b/boatstack/delivery_migrate_conformance_test.go @@ -0,0 +1,106 @@ +package boatstack + +import ( + "encoding/json" + "strings" + "testing" +) + +// control-law: delivery-state-migrates-forward-or-fails-closed +// +// The managed delivery state carries a schema version. The migration hook upgrades +// an older document forward through a registered one-step chain and stamps it to the +// target (Positive/Relation); a document already at the target passes through +// byte-for-byte unchanged so today's behavior is preserved exactly (Negative); a +// document written by a NEWER Boatstack, or one with a gap in the chain, fails +// closed with an actionable error rather than being loaded or silently upgraded +// (Bypass/Failure-state). Blank input is a no-op. + +// bumpSchema is a synthetic one-step migration (v1 -> v2) used only by these tests, +// so the forward-walk is exercised without a real schema bump. +func bumpSchema() []deliveryStateMigration { + return []deliveryStateMigration{{ + from: 1, to: 2, + apply: func(m map[string]any) (map[string]any, error) { + m["migrated_marker"] = true + return m, nil + }, + }} +} + +// Negative: a document already at the target version is returned byte-for-byte +// unchanged — the production path at schema version 1 must not rewrite state. +func TestMigrateDeliveryStateCurrentIsPassThrough(t *testing.T) { + raw := []byte(`{"schema_version":1,"feature":"demo"}`) + out, changed, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 1) + if err != nil { + t.Fatalf("current-version document must not error: %v", err) + } + if changed { + t.Error("a current-version document must report no change") + } + if string(out) != string(raw) { + t.Errorf("current-version document must pass through unchanged; got %s", out) + } +} + +// Positive + Relation: an older document is walked forward through the registered +// step, the migration's transform is applied, and schema_version is stamped to the +// target. +func TestMigrateDeliveryStateWalksForward(t *testing.T) { + raw := []byte(`{"schema_version":1,"feature":"demo"}`) + out, changed, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 2) + if err != nil { + t.Fatalf("forward migration must succeed: %v", err) + } + if !changed { + t.Error("a forward migration must report a change") + } + var got map[string]any + if err := json.Unmarshal(out, &got); err != nil { + t.Fatalf("migrated output must be valid JSON: %v", err) + } + if v, _ := got["schema_version"].(float64); int(v) != 2 { + t.Errorf("schema_version must be stamped to the target 2, got %v", got["schema_version"]) + } + if marker, _ := got["migrated_marker"].(bool); !marker { + t.Error("the migration's transform must have been applied") + } +} + +// Bypass: a document written by a newer Boatstack is never loaded or downgraded — +// it fails closed with a message that tells the operator to update. +func TestMigrateDeliveryStateNewerFailsClosed(t *testing.T) { + raw := []byte(`{"schema_version":99,"feature":"demo"}`) + _, _, err := migrateDeliveryStateBytesWith(raw, bumpSchema(), 1) + if err == nil { + t.Fatal("a newer-schema document must fail closed, not load") + } + if !strings.Contains(err.Error(), "newer Boatstack") { + t.Errorf("error must point at updating Boatstack, got %q", err) + } +} + +// Failure-state: a gap in the migration chain fails closed rather than guessing a +// path forward. +func TestMigrateDeliveryStateMissingStepFailsClosed(t *testing.T) { + raw := []byte(`{"schema_version":1,"feature":"demo"}`) + _, _, err := migrateDeliveryStateBytesWith(raw, nil, 3) // no migrations registered + if err == nil { + t.Fatal("a missing migration step must fail closed") + } + if !strings.Contains(err.Error(), "no delivery-state migration") { + t.Errorf("error must name the missing step, got %q", err) + } +} + +// Blank input is a no-op pass-through — there is nothing to migrate. +func TestMigrateDeliveryStateBlankIsNoOp(t *testing.T) { + out, changed, err := migrateDeliveryStateBytesWith([]byte(" "), bumpSchema(), 2) + if err != nil || changed { + t.Fatalf("blank input must be a silent no-op; changed=%v err=%v", changed, err) + } + if strings.TrimSpace(string(out)) != "" { + t.Errorf("blank input must pass through, got %q", out) + } +} diff --git a/boatstack/deliverycontrol_parity_test.go b/boatstack/deliverycontrol_parity_test.go index 2dc26df..f6278b8 100644 --- a/boatstack/deliverycontrol_parity_test.go +++ b/boatstack/deliverycontrol_parity_test.go @@ -41,12 +41,15 @@ func TestRegistryHandlerRefsAreRealFunctions(t *testing.T) { } func TestRegistrySliceStatusMatchesRealLiterals(t *testing.T) { - // The real slice lifecycle literals, from DeliverySlice.Status assignments in - // delivery.go and the nextForDelivery switch in next.go. If the real machine - // gains or renames a slice status, update both the machine and the registry. - realLiterals := map[string]bool{ - "PENDING": true, "BUILD": true, "TEST_PASSED": true, - "REVIEW_PASSED": true, "PUBLISHED": true, + // The real slice lifecycle literals derive from a single source — + // DeliverySliceStatuses() in delivery.go, which the real DeliverySlice.Status + // assignments and the nextForDelivery switch use as named constants. A new + // slice status therefore cannot be introduced as a raw literal that this + // hand-maintained test would miss: it must appear in DeliverySliceStatuses(), + // and the registry's SliceStatusStates() must then match it. + realLiterals := map[string]bool{} + for _, s := range DeliverySliceStatuses() { + realLiterals[s] = true } registryStatus := map[string]bool{} for _, s := range deliverycontrol.SliceStatusStates() { diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go index a7da8d3..ded3aaf 100644 --- a/boatstack/flow_control.go +++ b/boatstack/flow_control.go @@ -2,6 +2,7 @@ package boatstack import ( "fmt" + "path/filepath" "strings" "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" @@ -92,6 +93,80 @@ type FlowNext struct { OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"` RemainingCost int `json:"remaining_flow_cost"` Reason string `json:"reason"` + // Prescribed is the exact runnable command for the oracle's lowest-cost next + // move. It is non-nil ONLY when the flow position resolves and the transition + // can be assembled faithfully; an unresolved position prescribes nothing rather + // than a guessed command. + Prescribed *PrescribedCommand `json:"prescribed,omitempty"` + // SubAction is the read-only next coding sub-action from the plan's task DAG, + // surfaced only while the active slice is in BUILD (where "build" is otherwise + // opaque). It is a pointer into the slice's dependency-ordered tasks; it is nil + // when the slice is not building or the task graph cannot be read. It carries no + // completion state and prescribes no command — coding work is never a modeled + // transition, only an ordered pointer. + SubAction *FlowTask `json:"sub_action,omitempty"` +} + +// PrescribedCommand is the exact next command that makes the oracle's lowest-cost +// move. Args carries only auto-derivable flags (repo/feature/slice/gate/preview +// path/action). RequiresHumanInput names the flags that must be supplied by a +// human/CI and must NEVER be fabricated (evidence, gate status, the human-confirmed +// preview fingerprint, reviewer identity); those flags are deliberately absent from +// Args. AutoDerivable is true exactly when RequiresHumanInput is empty — the only +// commands the opt-in execute driver may run. +type PrescribedCommand struct { + Verb string `json:"verb"` + Args []string `json:"args,omitempty"` + RequiresHumanInput []string `json:"requires_human_input,omitempty"` + AutoDerivable bool `json:"auto_derivable"` + Transition deliverycontrol.TransitionID `json:"transition"` +} + +// CommandLine renders the auto-derivable part of the prescribed command as a +// runnable string. Human-required flags are appended as explicit +// placeholders so the rendering is never a fabricated, runnable-as-is command +// when input is still owed. +func (p PrescribedCommand) CommandLine() string { + parts := append([]string{"boatstack-helper", p.Verb}, p.Args...) + for _, flag := range p.RequiresHumanInput { + parts = append(parts, flag, "") + } + return strings.Join(parts, " ") +} + +// prescribeCommand assembles the runnable command for a forward delivery +// transition. It returns (nil, false) for any transition it cannot assemble +// 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. +func prescribeCommand(repo, feature string, status NextStatus, transition deliverycontrol.TransitionID) (*PrescribedCommand, bool) { + desc, ok := deliverycontrol.Transition(transition) + if !ok || desc.CLIVerb == "" { + return nil, false + } + cmd := &PrescribedCommand{Verb: desc.CLIVerb, Transition: transition} + var repoArgs []string + if repo != "" && repo != "." { + repoArgs = []string{"--repo", repo} + } + switch transition { + case deliverycontrol.TransitionID("delivery.record_gate_test"): + cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "test") + cmd.RequiresHumanInput = []string{"--status", "--evidence"} + case deliverycontrol.TransitionID("delivery.record_gate_review"): + cmd.Args = append(repoArgs, "--feature", feature, "--slice", status.ActiveSlice, "--gate", "review") + cmd.RequiresHumanInput = []string{"--status", "--evidence", "--reviewer-identity", "--review-method"} + case PublishTransition: + preview := filepath.Join(repo, ".product-loop", "features", feature, "pr.md") + cmd.Args = append(repoArgs, "--preview", preview, "--action", "open") + cmd.RequiresHumanInput = []string{"--preview-fingerprint"} + default: + // Recovery/observe/rework transitions are not prescribed as a forward move; + // emit nothing rather than a command whose arguments we cannot derive. + return nil, false + } + cmd.AutoDerivable = len(cmd.RequiresHumanInput) == 0 + return cmd, true } // NextControl composes the authoritative read-only recommendation (ResolveNext) @@ -124,6 +199,19 @@ func NextControl(repo, feature string) (FlowNext, error) { out.Resolved = true out.OracleNext = advice.NextTransition out.RemainingCost = advice.RemainingCost + if advice.NextTransition != "" { + if prescribed, ok := prescribeCommand(repo, feature, status, advice.NextTransition); ok { + out.Prescribed = prescribed + } + } + // While the slice is building, "build" is opaque; surface the read-only + // dependency-ordered next sub-action from the plan's task DAG as a hint. + if state == deliverycontrol.StateBuild { + if tasks, err := FlowTasksForActiveSlice(repo, feature); err == nil && tasks.Resolved && len(tasks.Ordered) > 0 { + hint := tasks.Ordered[0] + out.SubAction = &hint + } + } } return out, nil } @@ -141,6 +229,21 @@ func FormatFlowNext(next FlowNext) string { if next.Resolved { fmt.Fprintf(&b, "Flow state: %s -> goal %s\n", next.State, next.Goal) fmt.Fprintf(&b, "Advisory (flow oracle): next %s, remaining cost %d\n", next.OracleNext, next.RemainingCost) + if next.Prescribed != nil { + fmt.Fprintf(&b, "Run: %s\n", next.Prescribed.CommandLine()) + if next.Prescribed.AutoDerivable { + fmt.Fprintf(&b, " (auto-derivable — all arguments follow from state)\n") + } else { + fmt.Fprintf(&b, " You must supply: %s (never auto-filled)\n", strings.Join(next.Prescribed.RequiresHumanInput, " ")) + } + } + if next.SubAction != nil { + title := next.SubAction.Title + if title != "" { + title = " — " + title + } + fmt.Fprintf(&b, "Next sub-action: %s%s (from the plan task DAG; see `flow tasks`)\n", next.SubAction.ID, title) + } } else { fmt.Fprintf(&b, "Flow state: unresolved (no oracle advisory)\n") } diff --git a/boatstack/flow_drive.go b/boatstack/flow_drive.go new file mode 100644 index 0000000..1c56117 --- /dev/null +++ b/boatstack/flow_drive.go @@ -0,0 +1,96 @@ +package boatstack + +import ( + "os" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// flowDriveKillSwitch is the org-level master off switch for the opt-in execute +// driver. Execution is OFF by default — it happens only when the caller passes +// --execute per invocation — and setting this switch to "0" refuses execution +// even then, falling back to prescribe-and-stop. It never affects the read-only +// advisory (`flow next` without --execute). +const flowDriveKillSwitch = "BOATSTACK_FLOW_DRIVE" + +// autoDrivableTransitions is the conservative allowlist of transitions the execute +// driver may run without any human input. A transition qualifies ONLY if its full +// 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 +// 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{} + +// DriveAction is the driver's decision for one step. +type DriveAction string + +const ( + // DriveNone: there is no prescribed next move (unresolved or terminal flow). + DriveNone DriveAction = "none" + // DrivePrescribe: emit the exact command for a human/CI to run, and stop. + DrivePrescribe DriveAction = "prescribe" + // DriveExecute: the move is safe and fully derivable; the driver may run it. + DriveExecute DriveAction = "execute" +) + +// DriveDecision is what the execute driver should do for one flow step. Command +// is the prescribed command from the flow oracle (nil only when Action is +// DriveNone); Reason explains the decision for the operator and telemetry. +type DriveDecision struct { + Action DriveAction `json:"action"` + Command *PrescribedCommand `json:"command,omitempty"` + Reason string `json:"reason"` +} + +// canAutoDrive reports whether the driver may run a prescribed command with no +// human input. Both conditions must hold: the command owes no human input +// (AutoDerivable) AND its transition is on the allowlist. A derivable command off +// the allowlist is not driven, and an allowlisted transition that still owes input +// is not driven. +func canAutoDrive(cmd *PrescribedCommand, allowlist map[deliverycontrol.TransitionID]bool) bool { + if cmd == nil || !cmd.AutoDerivable { + return false + } + return allowlist[cmd.Transition] +} + +// decideDrive is the pure decision at the heart of the execute driver. It never +// executes an unresolved, human-gated, or off-allowlist move: such moves are +// prescribed-and-stopped so the operator supplies what only a human can. The +// allowlist is a parameter so the decision is testable independently of the +// production set. +func decideDrive(next FlowNext, executeOptIn, killed bool, allowlist map[deliverycontrol.TransitionID]bool) DriveDecision { + if next.Prescribed == nil { + return DriveDecision{Action: DriveNone, Reason: "no prescribed next move (flow position unresolved or already at goal)"} + } + if !executeOptIn { + return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "execute not requested; run the prescribed command by hand"} + } + if killed { + return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "execute disabled by " + flowDriveKillSwitch + "=0; prescribe-and-stop"} + } + if canAutoDrive(next.Prescribed, allowlist) { + return DriveDecision{Action: DriveExecute, Command: next.Prescribed, Reason: "auto-derivable allowlisted move"} + } + return DriveDecision{Action: DrivePrescribe, Command: next.Prescribed, Reason: "move owes human input or is off the auto-drive allowlist; prescribe-and-stop"} +} + +// DecideDrive is the production decision for one flow step, using the conservative +// package allowlist. executeOptIn is the per-invocation --execute flag; killed is +// the BOATSTACK_FLOW_DRIVE=0 kill switch. +func DecideDrive(next FlowNext, executeOptIn, killed bool) DriveDecision { + return decideDrive(next, executeOptIn, killed, autoDrivableTransitions) +} + +// FlowDriveKilled reports whether the execute kill switch is set to "0". Exposed so +// CLI wrappers in other packages can read the switch without importing the constant. +func FlowDriveKilled() bool { + return os.Getenv(flowDriveKillSwitch) == "0" +} diff --git a/boatstack/flow_drive_conformance_test.go b/boatstack/flow_drive_conformance_test.go new file mode 100644 index 0000000..48b5c6c --- /dev/null +++ b/boatstack/flow_drive_conformance_test.go @@ -0,0 +1,133 @@ +package boatstack + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// control-law: execute-drives-only-safe-derivable-moves +// +// The opt-in execute driver runs a move ONLY when three things hold at once: the +// caller asked to execute (--execute), the move owes no human input +// (AutoDerivable), and its transition is on the auto-drive allowlist. Any of those +// missing means prescribe-and-stop — the operator supplies what only a human can. +// The contract, by class: an allowlisted derivable move with execute on runs +// (Positive); an unresolved position never runs anything (Negative); a derivable +// move whose transition is off the allowlist does not run (Relation); an +// allowlisted move that still owes human input is never run — the driver cannot +// fabricate that input (Bypass); and the kill switch or an execute-off invocation +// forces prescribe even for an otherwise-runnable move (Failure-state). + +// syntheticTransition is an allowlisted transition used only by these tests, so the +// decision logic can be exercised without depending on the production allowlist +// (which is intentionally empty of forward moves). +const syntheticTransition = deliverycontrol.TransitionID("test.synthetic_safe_move") + +func syntheticAllowlist() map[deliverycontrol.TransitionID]bool { + return map[deliverycontrol.TransitionID]bool{syntheticTransition: true} +} + +// derivableMove is a resolved FlowNext whose prescribed command owes no human input +// and rides an allowlisted transition — the only shape the driver may execute. +func derivableMove() FlowNext { + return FlowNext{ + Resolved: true, + Prescribed: &PrescribedCommand{ + Verb: "observe", + AutoDerivable: true, + Transition: syntheticTransition, + }, + } +} + +// Positive: execute requested, kill switch off, move derivable and allowlisted -> +// the driver executes it. +func TestDecideDriveExecutesDerivableAllowlisted(t *testing.T) { + d := decideDrive(derivableMove(), true, false, syntheticAllowlist()) + if d.Action != DriveExecute { + t.Fatalf("expected DriveExecute for a derivable allowlisted move, got %s (%s)", d.Action, d.Reason) + } + if d.Command == nil { + t.Error("an executable decision must carry the command it drives") + } +} + +// Negative: an unresolved position (no prescribed command) never drives anything, +// even with execute on — there is nothing legal to run. +func TestDecideDriveUnresolvedDrivesNothing(t *testing.T) { + d := decideDrive(FlowNext{Resolved: false}, true, false, syntheticAllowlist()) + if d.Action != DriveNone { + t.Fatalf("unresolved flow must decide DriveNone, got %s", d.Action) + } + if d.Command != nil { + t.Error("DriveNone must carry no command") + } +} + +// Relation: only allowlisted transitions execute. A derivable move whose transition +// is NOT on the allowlist is prescribed, not run — allowlisting is necessary. +func TestDecideDriveOffAllowlistPrescribes(t *testing.T) { + next := derivableMove() + d := decideDrive(next, true, false, map[deliverycontrol.TransitionID]bool{}) + if d.Action != DrivePrescribe { + t.Fatalf("a derivable move off the allowlist must prescribe, got %s", d.Action) + } + if !canAutoDrive(next.Prescribed, syntheticAllowlist()) { + t.Error("sanity: the same move on the allowlist should be auto-drivable") + } + if canAutoDrive(next.Prescribed, map[deliverycontrol.TransitionID]bool{}) { + t.Error("a move off the allowlist must never report auto-drivable") + } +} + +// Bypass: a move that still owes human input is never executed, even when its +// transition is allowlisted — the driver cannot fabricate the missing input, so it +// prescribes-and-stops. This is the core safety property. +func TestDecideDriveHumanGatedNeverExecutes(t *testing.T) { + next := FlowNext{ + Resolved: true, + Prescribed: &PrescribedCommand{ + Verb: "record-gate", + AutoDerivable: false, + RequiresHumanInput: []string{"--status", "--evidence"}, + Transition: syntheticTransition, // allowlisted, yet still owes input + }, + } + d := decideDrive(next, true, false, syntheticAllowlist()) + if d.Action != DrivePrescribe { + t.Fatalf("a human-gated move must prescribe even when allowlisted, got %s", d.Action) + } + if canAutoDrive(next.Prescribed, syntheticAllowlist()) { + t.Error("a move owing human input must never be auto-drivable") + } +} + +// 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{ + deliverycontrol.TransitionID("delivery.record_gate_test"), + deliverycontrol.TransitionID("delivery.record_gate_review"), + PublishTransition, + } + for _, tr := range forward { + if autoDrivableTransitions[tr] { + t.Errorf("forward transition %s must not be on the auto-drive allowlist", tr) + } + } +} + +// Failure-state: the kill switch (BOATSTACK_FLOW_DRIVE=0) and an execute-off +// invocation both force prescribe-and-stop, even for an otherwise-executable move. +func TestDecideDriveKillSwitchAndOptOutPrescribe(t *testing.T) { + killed := decideDrive(derivableMove(), true, true, syntheticAllowlist()) + if killed.Action != DrivePrescribe { + t.Errorf("kill switch must force prescribe, got %s", killed.Action) + } + optOut := decideDrive(derivableMove(), false, false, syntheticAllowlist()) + if optOut.Action != DrivePrescribe { + t.Errorf("execute-off must prescribe, got %s", optOut.Action) + } +} diff --git a/boatstack/flow_prescribe_conformance_test.go b/boatstack/flow_prescribe_conformance_test.go new file mode 100644 index 0000000..039c5a5 --- /dev/null +++ b/boatstack/flow_prescribe_conformance_test.go @@ -0,0 +1,138 @@ +package boatstack + +import ( + "strings" + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// control-law: prescribed-command-is-legal-lowest-cost-or-none +// +// `flow next` now emits the exact runnable command for the oracle's lowest-cost +// next move. The contract these tests hold: the prescribed command is always the +// registry CLIVerb of the oracle's chosen (and therefore legal) transition; it +// never fabricates human-owed inputs (evidence, gate status, preview fingerprint, +// reviewer identity); and when the flow position cannot be assembled faithfully it +// prescribes NOTHING rather than a guess. + +// forwardStates pairs each resolvable slice-lifecycle state with the transition +// the oracle takes toward the published goal from it. +var forwardStates = []struct { + state deliverycontrol.StateID + transition deliverycontrol.TransitionID + verb string +}{ + {deliverycontrol.StateBuild, "delivery.record_gate_test", "record-delivery-gate"}, + {deliverycontrol.StateTestPassed, "delivery.record_gate_review", "record-delivery-gate"}, + {deliverycontrol.StateReviewPassed, "delivery.publish", "publish-pr"}, +} + +func syntheticStatus() NextStatus { + return NextStatus{ActiveSlice: "slice-a", Feature: "demo"} +} + +// Positive: from REVIEW_PASSED the prescribed command is publish-pr for the +// delivery.publish transition, carrying the derivable preview/action and owing +// only the human-confirmed fingerprint. +func TestPrescribePublishFromReviewPassed(t *testing.T) { + cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), PublishTransition) + if !ok { + t.Fatal("expected a prescribed command for delivery.publish") + } + if cmd.Verb != "publish-pr" || cmd.Transition != PublishTransition { + t.Errorf("verb/transition = %s/%s, want publish-pr/delivery.publish", cmd.Verb, cmd.Transition) + } + if cmd.AutoDerivable { + t.Error("publish must not be auto-derivable: it owes a human-confirmed fingerprint") + } + if !contains(cmd.RequiresHumanInput, "--preview-fingerprint") { + t.Errorf("publish must require --preview-fingerprint; got %v", cmd.RequiresHumanInput) + } + if !contains(cmd.Args, "--preview") || !contains(cmd.Args, "--action") { + t.Errorf("publish should derive --preview and --action; got %v", cmd.Args) + } +} + +// 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 +// from the oracle. +func TestPrescribedVerbMatchesRegistryAndOracle(t *testing.T) { + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + for _, fs := range forwardStates { + advice := graph.Advise(fs.state, flowGoal) + if advice.Resolution != deliverycontrol.Resolved { + t.Fatalf("oracle could not resolve from %s", fs.state) + } + if advice.NextTransition != fs.transition { + t.Errorf("oracle next from %s = %s, want %s", fs.state, advice.NextTransition, fs.transition) + } + cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), advice.NextTransition) + if !ok { + t.Fatalf("no prescription for oracle edge %s", advice.NextTransition) + } + desc, _ := deliverycontrol.Transition(advice.NextTransition) + if cmd.Verb != desc.CLIVerb { + t.Errorf("prescribed verb %q != registry CLIVerb %q for %s", cmd.Verb, desc.CLIVerb, advice.NextTransition) + } + if cmd.Verb != fs.verb { + t.Errorf("prescribed verb %q != expected %q for %s", cmd.Verb, fs.verb, fs.state) + } + } +} + +// Negative: the prescribed transition is always a LEGAL move from the state it is +// prescribed at — never a friction/illegal move the real machine would reject. +func TestPrescribedMoveIsLegalFromItsState(t *testing.T) { + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + for _, fs := range forwardStates { + if !graph.IsLegalMove(fs.state, fs.transition) { + t.Errorf("prescribed transition %s is not legal from %s (would be friction)", fs.transition, fs.state) + } + } +} + +// Bypass: no human-owed flag is ever placed in Args (never fabricated); the gate +// commands owe evidence + status, and the review gate additionally owes reviewer +// identity/method. The auto-derivable flag is true iff nothing is owed. +func TestPrescribeNeverFabricatesHumanInput(t *testing.T) { + for _, fs := range forwardStates { + cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), fs.transition) + if !ok { + t.Fatalf("no prescription for %s", fs.transition) + } + for _, owed := range cmd.RequiresHumanInput { + if contains(cmd.Args, owed) { + t.Errorf("%s: human-owed flag %q leaked into auto-derived Args %v", fs.transition, owed, cmd.Args) + } + } + if cmd.AutoDerivable != (len(cmd.RequiresHumanInput) == 0) { + t.Errorf("%s: AutoDerivable=%t but RequiresHumanInput=%v", fs.transition, cmd.AutoDerivable, cmd.RequiresHumanInput) + } + // None of the forward gate/publish moves are auto-drivable today: each owes + // evidence, status, or a fingerprint that must not be fabricated. + if cmd.AutoDerivable { + t.Errorf("%s: no forward gate/publish move should be auto-derivable (all owe human input)", fs.transition) + } + } + // The CommandLine rendering surfaces owed inputs as explicit + // placeholders, so it is never a fabricated, runnable-as-is command. + cmd, _ := prescribeCommand(".", "demo", syntheticStatus(), PublishTransition) + if !strings.Contains(cmd.CommandLine(), "--preview-fingerprint ") { + t.Errorf("CommandLine must mark owed input as ; got %q", cmd.CommandLine()) + } +} + +// Failure-state: a transition that is not a faithfully-assemblable forward move +// prescribes nothing — the caller emits no command rather than a guess. +func TestPrescribeEmitsNothingForUnassemblableTransition(t *testing.T) { + for _, id := range []deliverycontrol.TransitionID{ + "delivery.undo", "delivery.record_change", "delivery.discard_delivery", + "delivery.status", "delivery.recovery_status", "not.a.transition", + } { + if cmd, ok := prescribeCommand("/repo", "demo", syntheticStatus(), id); ok { + t.Errorf("transition %s should not be prescribed as a forward move; got %+v", id, cmd) + } + } +} diff --git a/boatstack/flow_tasks.go b/boatstack/flow_tasks.go new file mode 100644 index 0000000..9a72ffe --- /dev/null +++ b/boatstack/flow_tasks.go @@ -0,0 +1,198 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FlowTask is one read-only sub-action from the compiled plan's task DAG, scoped +// to a delivery slice. It carries only what is needed to order and name the work; +// it holds no completion state. +type FlowTask struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + DependsOn []string `json:"depends_on,omitempty"` +} + +// FlowTasks is the read-only ordering of the active slice's sub-actions. It adds +// no state and tracks no completion: it orders the plan's tasks by their DAG, +// scoped to the active slice, and points at the one to start. The agent decides +// done-ness. Resolved is false when the flow position or the compiled task graph +// cannot be read faithfully, in which case nothing is ordered or pointed at +// (never a guessed sub-action). +type FlowTasks struct { + Resolved bool `json:"resolved"` + Feature string `json:"feature,omitempty"` + Slice string `json:"slice,omitempty"` + SliceStatus string `json:"slice_status,omitempty"` + Ordered []FlowTask `json:"ordered,omitempty"` + StartHere string `json:"start_here,omitempty"` + Reason string `json:"reason"` +} + +// FlowTasksForActiveSlice reads the compiled task DAG for a feature, scopes it to +// the active delivery slice, and returns the sub-actions in dependency order. It +// is read-only and best-effort on the inputs: a missing delivery state, a fully +// published delivery, or an unreadable task graph resolves to an Unresolved result +// with a reason rather than an error, so it is safe to request at any time. +func FlowTasksForActiveSlice(repo, feature string) (FlowTasks, error) { + state, err := LoadDeliveryState(repo, feature) + if err != nil { + return FlowTasks{Reason: "no readable delivery state for this feature"}, nil + } + slice, err := activeDeliverySlice(state) + if err != nil { + return FlowTasks{Feature: feature, Reason: err.Error()}, nil + } + out := FlowTasks{Feature: feature, Slice: slice.ID, SliceStatus: slice.Status} + if len(slice.TaskIDs) == 0 { + out.Reason = "active slice declares no task ids to order" + return out, nil + } + allTasks, ok := readCompiledTasks(repo, feature) + if !ok { + out.Reason = "no readable compiled task graph (compiled/tasks.json)" + return out, nil + } + ordered := orderSliceTasks(slice.TaskIDs, allTasks) + if len(ordered) == 0 { + out.Reason = "active slice task ids resolve to no compiled tasks" + return out, nil + } + out.Ordered = ordered + out.StartHere = ordered[0].ID + out.Resolved = true + out.Reason = fmt.Sprintf("%d sub-action(s) for slice %s in dependency order", len(ordered), slice.ID) + return out, nil +} + +// readCompiledTasks loads the tasks array from the feature's compiled task graph +// through the shared dual-layout artifact resolver. The boolean is false whenever +// the graph is absent or malformed, so the caller stays Unresolved rather than +// ordering a guess. +func readCompiledTasks(repo, feature string) ([]FlowTask, bool) { + directory := filepath.Join(repo, ".product-loop", "features", feature) + tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json") + raw, err := os.ReadFile(tasksPath) + if err != nil { + return nil, false + } + var graph struct { + Tasks []struct { + ID string `json:"id"` + Title string `json:"title"` + DependsOn []string `json:"depends_on"` + } `json:"tasks"` + } + if err := json.Unmarshal(raw, &graph); err != nil { + return nil, false + } + tasks := make([]FlowTask, 0, len(graph.Tasks)) + for _, t := range graph.Tasks { + if strings.TrimSpace(t.ID) == "" { + continue + } + tasks = append(tasks, FlowTask{ID: t.ID, Title: t.Title, DependsOn: t.DependsOn}) + } + return tasks, true +} + +// orderSliceTasks scopes the plan's tasks to the active slice's task ids and +// returns them in dependency order. Ordering is a Kahn topological sort over the +// intra-scope depends_on edges only — a dependency on an earlier slice's task is +// treated as already satisfied, and a dependency outside the scope never pulls a +// foreign task in. Ties are broken by the tasks' order in the compiled plan, so +// the result is deterministic and plan-faithful. The compile step already +// guarantees the DAG is acyclic; if a cycle somehow remained, the unresolved +// remainder is appended in plan order rather than dropped. +func orderSliceTasks(sliceTaskIDs []string, allTasks []FlowTask) []FlowTask { + scope := map[string]bool{} + for _, id := range sliceTaskIDs { + scope[id] = true + } + + // Scoped tasks in plan order, with dependencies restricted to the scope. + var scoped []FlowTask + planIndex := map[string]int{} + indegree := map[string]int{} + dependents := map[string][]string{} + for _, task := range allTasks { + if !scope[task.ID] { + continue + } + intra := make([]string, 0, len(task.DependsOn)) + for _, dep := range task.DependsOn { + if scope[dep] { + intra = append(intra, dep) + } + } + planIndex[task.ID] = len(scoped) + indegree[task.ID] = len(intra) + scoped = append(scoped, FlowTask{ID: task.ID, Title: task.Title, DependsOn: intra}) + for _, dep := range intra { + dependents[dep] = append(dependents[dep], task.ID) + } + } + + emitted := map[string]bool{} + var ordered []FlowTask + for len(ordered) < len(scoped) { + // Among not-yet-emitted tasks with no unmet intra-scope dependency, pick the + // earliest in plan order. + next := -1 + for _, task := range scoped { + if emitted[task.ID] || indegree[task.ID] != 0 { + continue + } + if next == -1 || planIndex[task.ID] < planIndex[scoped[next].ID] { + next = planIndex[task.ID] + } + } + if next == -1 { + // Defensive: a residual cycle (should be impossible post-compile). Append + // the remaining tasks in plan order rather than silently dropping them. + for _, task := range scoped { + if !emitted[task.ID] { + ordered = append(ordered, task) + emitted[task.ID] = true + } + } + break + } + chosen := scoped[next] + ordered = append(ordered, chosen) + emitted[chosen.ID] = true + for _, dependent := range dependents[chosen.ID] { + indegree[dependent]-- + } + } + return ordered +} + +// FormatFlowTasks renders the scoped, ordered sub-actions as human-facing lines. +// An unresolved result prints its reason rather than any task, so it is never +// mistaken for an empty plan. +func FormatFlowTasks(tasks FlowTasks) string { + var b strings.Builder + if !tasks.Resolved { + fmt.Fprintf(&b, "Flow tasks: unresolved (%s)\n", tasks.Reason) + return b.String() + } + fmt.Fprintf(&b, "Sub-actions for slice %s (%s), in dependency order:\n", tasks.Slice, tasks.SliceStatus) + for i, task := range tasks.Ordered { + marker := " " + if task.ID == tasks.StartHere { + marker = "->" + } + title := task.Title + if title != "" { + title = " — " + title + } + fmt.Fprintf(&b, "%s %d. %s%s\n", marker, i+1, task.ID, title) + } + fmt.Fprintf(&b, "Start here: %s\n", tasks.StartHere) + return b.String() +} diff --git a/boatstack/flow_tasks_conformance_test.go b/boatstack/flow_tasks_conformance_test.go new file mode 100644 index 0000000..2a96c53 --- /dev/null +++ b/boatstack/flow_tasks_conformance_test.go @@ -0,0 +1,113 @@ +package boatstack + +import "testing" + +// control-law: sub-action-respects-plan-dag-and-slice-scope +// +// `flow tasks` (and the sub_action hint on `flow next`) orders the active slice's +// sub-actions from the compiled plan DAG. The contract: the ordering is a valid +// topological order of the intra-slice depends_on edges (Positive); it never +// surfaces a task outside the active slice, nor pulls in a foreign dependency +// (Relation/Bypass); the pointed-at "start here" task has no unmet intra-slice +// dependency (Negative); and a missing DAG or position resolves to nothing, never +// a guessed sub-action (Failure-state). + +// A representative plan: two slices, an intentionally out-of-plan-order pair, a +// cross-slice dependency, and a dependency on an earlier slice's task. +func sampleTasks() []FlowTask { + return []FlowTask{ + {ID: "a2", DependsOn: []string{"a1"}}, // listed before its dependency + {ID: "a1", DependsOn: nil}, // root of slice A + {ID: "b1", DependsOn: []string{"a2"}}, // slice B — later slice + {ID: "a3", DependsOn: []string{"x0", "a1"}}, // x0 is an earlier slice's (out-of-scope) task + } +} + +var sliceAScope = []string{"a1", "a2", "a3"} + +// assertTopoValid checks that every intra-scope dependency of each task appears +// before it in the ordering. +func assertTopoValid(t *testing.T, ordered []FlowTask) { + t.Helper() + position := map[string]int{} + for i, task := range ordered { + position[task.ID] = i + } + for i, task := range ordered { + for _, dep := range task.DependsOn { + depPos, ok := position[dep] + if !ok { + t.Errorf("task %s retains out-of-scope dependency %s", task.ID, dep) + continue + } + if depPos >= i { + t.Errorf("task %s at %d precedes its dependency %s at %d", task.ID, i, dep, depPos) + } + } + } +} + +// Positive: the ordering is a valid topological order and starts at the slice's +// dependency root, even though the plan listed a2 before a1. +func TestOrderSliceTasksIsTopological(t *testing.T) { + ordered := orderSliceTasks(sliceAScope, sampleTasks()) + if len(ordered) != 3 { + t.Fatalf("expected 3 scoped tasks, got %d: %+v", len(ordered), ordered) + } + if ordered[0].ID != "a1" { + t.Errorf("start-here should be the dependency root a1, got %s", ordered[0].ID) + } + assertTopoValid(t, ordered) +} + +// Relation + Bypass: every ordered task is in the active slice's scope; the +// later-slice task b1 never appears, and the out-of-scope dependency x0 is dropped +// rather than pulling a foreign task into the ordering. +func TestOrderSliceTasksStaysInScope(t *testing.T) { + scope := map[string]bool{} + for _, id := range sliceAScope { + scope[id] = true + } + ordered := orderSliceTasks(sliceAScope, sampleTasks()) + for _, task := range ordered { + if !scope[task.ID] { + t.Errorf("ordered task %s is outside the active slice scope", task.ID) + } + if task.ID == "b1" { + t.Error("later-slice task b1 must never be ordered for slice A") + } + for _, dep := range task.DependsOn { + if !scope[dep] { + t.Errorf("task %s kept out-of-scope dependency %s", task.ID, dep) + } + } + } +} + +// Negative: the pointed-at start task has no unmet intra-scope dependency (it is a +// legal place to begin), and no later-slice task is ever the start. +func TestOrderSliceTasksStartIsBeginnable(t *testing.T) { + ordered := orderSliceTasks(sliceAScope, sampleTasks()) + if len(ordered) == 0 { + t.Fatal("expected a non-empty ordering") + } + start := ordered[0] + if len(start.DependsOn) != 0 { + t.Errorf("start-here task %s still has unmet intra-scope dependencies %v", start.ID, start.DependsOn) + } +} + +// Failure-state: with no delivery state and no compiled task graph, the reader +// resolves to nothing — never a fabricated ordering. +func TestFlowTasksUnresolvedWithoutDAG(t *testing.T) { + tasks, err := FlowTasksForActiveSlice(t.TempDir(), "demo") + if err != nil { + t.Fatalf("read-only reader must not error on a missing feature: %v", err) + } + if tasks.Resolved || len(tasks.Ordered) != 0 || tasks.StartHere != "" { + t.Errorf("missing DAG must be unresolved with no tasks; got %+v", tasks) + } + if tasks.Reason == "" { + t.Error("an unresolved result must carry a reason") + } +} diff --git a/boatstack/internal/deliverycontrol/registry.go b/boatstack/internal/deliverycontrol/registry.go index 4cd5f2c..039d7bd 100644 --- a/boatstack/internal/deliverycontrol/registry.go +++ b/boatstack/internal/deliverycontrol/registry.go @@ -2,8 +2,13 @@ package deliverycontrol // registry is the single declaration of Boatstack's delivery transitions, // mirroring ../../notes/delivery-control-inventory.md. Each HandlerRef names a -// real exported function in package boatstack; the parity conformance test keeps -// this faithful. This is a shadow catalog — nothing consumes it at runtime yet. +// real exported function in package boatstack, and each CLIVerb names a real +// dispatch verb in cmd/boatstack-helper; the conformance tests keep both +// faithful in each direction. The registry is the authoritative projection +// source: the flow commands consume it at runtime (NextControl resolves the +// prescribed command through Transition(id).CLIVerb), and the coverage +// conformance test asserts it covers exactly the real delivery machine — no real +// mutation verb without a row, no row without a real verb. var registry = []TransitionDescriptor{ { ID: "delivery.activate", From: []StateID{StateUninitialized}, To: StateBuild, @@ -68,8 +73,8 @@ var registry = []TransitionDescriptor{ { ID: "delivery.check_ship", From: []StateID{StateReviewPassed, StatePublished}, To: "", Kind: KindQuery, CostClass: CostQuery, Reversible: false, - HandlerRef: "CheckDeliveryReadyForShip", CLIVerb: "ship-gate", - Note: "Re-checks receipt freshness and gate policy for the addressable slice and returns its PR sources. No state change.", + HandlerRef: "CheckDeliveryReadyForShip", CLIVerb: "pr-context", + Note: "Re-checks receipt freshness and gate policy for the addressable slice and returns its PR sources via pr-context. No state change.", }, { ID: "delivery.next", From: nil, To: "", diff --git a/boatstack/next.go b/boatstack/next.go index 51ab8a0..fbf333a 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -116,13 +116,13 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { SliceIndex: state.ActiveIndex + 1, TotalSlices: len(state.Slices), } switch slice.Status { - case "BUILD": + case StatusBuild: status.NextOperation = "build" status.Reason = "The approved delivery slice is active and has no current test-gate receipt." - case "TEST_PASSED": + case StatusTestPassed: status.NextOperation = "review-gate" status.Reason = "The active delivery slice has current test evidence and still requires review." - case "REVIEW_PASSED": + case StatusReviewPassed: previewPath := filepath.Join(repo, ".product-loop", "features", feature, "pr.md") if preview, previewErr := ParsePRPreview(previewPath); previewErr == nil && preview.Feature == feature && preview.SliceID == slice.ID { status.ObservedStage = "PR_PREVIEW" diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 8458eeb..7e3398b 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`eec4b62c152cc2da37579f891706640bff9cb1a6`](https://github.com/operatorstack/intelligence-flow/tree/eec4b62c152cc2da37579f891706640bff9cb1a6/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 [`719220d52b8ac1237c9169099b53a024dc583cc6`](https://github.com/operatorstack/intelligence-flow/tree/719220d52b8ac1237c9169099b53a024dc583cc6/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 a55dfd9..2fd36ae 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "eec4b62c152cc2da37579f891706640bff9cb1a6", + "source_commit": "719220d52b8ac1237c9169099b53a024dc583cc6", "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" }, { "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:eec4b62c152cc2da37579f891706640bff9cb1a6" + "last_verified_version": "source:719220d52b8ac1237c9169099b53a024dc583cc6" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 56a7772..c7bf546 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": "eec4b62c152cc2da37579f891706640bff9cb1a6", + "source_commit": "719220d52b8ac1237c9169099b53a024dc583cc6", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-25-deliverycontrol-change-safety-net.md b/release-notes/2026-07-25-deliverycontrol-change-safety-net.md new file mode 100644 index 0000000..d84dbbb --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-change-safety-net.md @@ -0,0 +1,18 @@ +### Change-safety net — the delivery model can grow without breaking what ships today + +The delivery machine is now described in one authoritative registry, and two safety nets keep that +description honest as new features land: + +- **Coverage conformance.** A test parses the CLI's real delivery dispatch and asserts every delivery + verb it handles is classified — either mirrored by a registry transition or on an explicit + non-delivery allowlist. A new delivery verb that forgets to declare itself in the registry now fails + the build instead of silently drifting out of the model the driver navigates. + +- **Schema-migration hook.** Managed delivery state is routed through a versioned migration step before + it is loaded. At the current schema version this is a byte-for-byte pass-through, so today's behavior + is unchanged; when the schema next changes, old state has a defined, tested upgrade path, and state + written by a newer Boatstack fails closed with "update Boatstack" rather than as generic corruption. + +Both are covered by control-law conformance suites, so the guarantees are enforced, not aspirational. +Together they mean the delivery-flow surface can gain moves and fields without stranding an in-flight +delivery or letting a new verb escape the model. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-next-execute.md b/release-notes/2026-07-25-deliverycontrol-flow-next-execute.md new file mode 100644 index 0000000..5a426bd --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-next-execute.md @@ -0,0 +1,17 @@ +### `boatstack flow next --execute` — run only the moves that are provably safe + +`flow next` already prescribes the exact next command toward a published delivery. It now takes an +opt-in `--execute` flag that lets the driver *run* that move for you — but only when the move is proven +safe: its arguments follow entirely from state, and there is nothing to fabricate. At the first move +that owes human input — a gate's status and evidence, a reviewer's identity, the human-confirmed PR +preview fingerprint — the driver prescribes-and-stops and hands you the exact command to run. + +Execute is off by default; it happens only when you pass `--execute`, and `BOATSTACK_FLOW_DRIVE=0` +refuses execution even then. Today every forward delivery move owes human input, so the driver +correctly prescribes-and-stops at the very first step — the shipped auto-drive allowlist is +deliberately empty of forward moves. This release is the mechanism and its guardrails: a move runs only +if it is BOTH on the allowlist AND has an explicitly registered executor, so nothing runs by accident, +and the driver never invents evidence, a gate status, a fingerprint, or an identity. + +The driver re-reads ground truth after each executed step, so it never acts on an assumed position, and +a step budget bounds the loop as a backstop against any cycle in the model. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-next-prescribes-command.md b/release-notes/2026-07-25-deliverycontrol-flow-next-prescribes-command.md new file mode 100644 index 0000000..42fc66c --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-next-prescribes-command.md @@ -0,0 +1,16 @@ +### `boatstack flow next` — the exact command, not just the next state + +`flow next` now prints the exact command to run for the lowest-cost next move toward a published +delivery, not just the name of the next state. The command is resolved through the delivery registry — +the same single declaration the conformance and liveness checks hold faithful — so the verb it emits is +always a real command the binary accepts, and always the legal next transition the oracle chose. + +Arguments that follow from state (the feature, the addressable slice, the gate, the preview path) are +filled in for you. Arguments that must come from a human or CI — the gate status, the evidence ledger, +the human-confirmed preview fingerprint, the reviewer identity — are listed explicitly as required and +are never fabricated: they appear as `` placeholders in the printed command, and the `--json` +form carries them in a separate `requires_human_input` field alongside an `auto_derivable` flag. When +the flow position cannot be placed, `flow next` prescribes nothing rather than guessing a command. + +This turns the delivery flow from something the operator has to reassemble each step into a single +readable instruction: the next command is derivable from the recorded state, so the tool states it. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-tasks-subaction.md b/release-notes/2026-07-25-deliverycontrol-flow-tasks-subaction.md new file mode 100644 index 0000000..18d2997 --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-tasks-subaction.md @@ -0,0 +1,16 @@ +### `boatstack flow tasks` — the ordered sub-actions inside a building slice + +While a delivery slice is building, the next move was only ever named "build" — an opaque instruction +that left the operator to reconstruct the order of the work by hand. `flow tasks` now reads the +compiled plan's task graph, scopes it to the active slice, and prints the slice's sub-actions in +dependency order with the one to start pointed at. The same first sub-action is surfaced as a hint on +`flow next` whenever the slice is building, so "build" is no longer a dead end. + +The ordering is a topological sort of the slice's own `depends_on` edges: a dependency on an earlier +slice's task is treated as already done, and a dependency outside the slice never pulls a foreign task +into the list. The reader adds no state and tracks no completion — it orders and points; the operator +decides when a sub-action is done. When there is no compiled task graph or the flow position cannot be +placed, it resolves to nothing with a reason rather than inventing a sub-action. + +Coding work is still never a modeled transition — this is a read-only pointer over the plan, not a new +part of the delivery state machine.