diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb99b68..b676169 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/fac1f242bb58f88e94da3f7cbb50ba6159d02790/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/eec4b62c152cc2da37579f891706640bff9cb1a6/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 a6ea8c0..3ec9fcf 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "762db520d51872c6b6b6ec7c312f54c7e1ef5c14fdf61ed17673d65a525a7e01", + "CONTRIBUTING.md": "ff655c8e8bb64b7c06dfd123aba96cabe03f30541e5d33c338933bf3c6baf45e", "README.md": "6b7402c5cef5b3b9b739281d3d4d576cdc995796ff127fc6aefb97c5743e0bac", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,8 +40,9 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "ac174deb556f9a0b5e033154f2fc966715b3bff81d26ef97a23df2f901c56652", - "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", + "boatstack/cmd/boatstack-helper/flow.go": "795e6129351600cd81ffa86db5e1016dfae894c2d0e9b75615f37eb5fdf5f939", + "boatstack/cmd/boatstack-helper/main.go": "a4a29e53d803cd1d8ec35e105bab17000e2855f978393031f1516aab26c732b6", + "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/compiled_artifact_resolution_test.go": "0748d67643263e698211eb04d46464e1dd3db15d94537f5fd5092b5aa689745b", @@ -56,6 +57,14 @@ "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_test.go": "d52e095f2e0f18067abe03e3b5f7c98bc30f8b1c8f5230103797c599aec95013", + "boatstack/flow_guard.go": "dd18524d95f4a220cfd3d11b11003dacc52120785ee0ccdbeceb2307fab55872", + "boatstack/flow_guard_test.go": "8ba75f11ddd080427c15bd7e25f7d03c1b746a2f587c212cea0e710337d1c0e1", + "boatstack/flow_report.go": "9e58cec51c6c903847f3ebc79cd6bf25e2f91d14b81811e82e5f4e026a7b71a3", + "boatstack/flow_report_test.go": "eae00f2b8ead4f1ec20e1f1bc47db4c53a36048bb84eca9bea4f1a2105bdcdde", "boatstack/flow_trace.go": "95b5a99f5f557a27a3eca7152de9ec18459f2a88d9749924432463031536a96c", "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", @@ -70,14 +79,21 @@ "boatstack/installation_repair.go": "6574f7133a9644843c9260b9b9daede641a14438f7357bae42fb8ec188890446", "boatstack/installation_repair_test.go": "ae5a5ea1110836bd78cf20ade863a4d32cfd63d282559f92786f57b31869bd14", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", + "boatstack/internal/deliverycontrol/advise.go": "4f3a53a785a34f6c34858236a57d4114091141a463b51e5aaefae3336557ae5a", + "boatstack/internal/deliverycontrol/advise_test.go": "6e0d3bda302cb5d26dd42a0253281ac6d1bb6749496b1a7df9c2aa9f53ea13a1", + "boatstack/internal/deliverycontrol/coding.go": "fe312c1aee11edcdbf24d7d525f4baecbac59c0a57125b1a5e356df9d295d657", + "boatstack/internal/deliverycontrol/coding_test.go": "58ba20bc4b1ca97f841523b73075dc953598263cd888f1d13c24e081fb0f8750", + "boatstack/internal/deliverycontrol/codinglog.go": "35cc491c1684b064cb260b0b6e889bc64f9f416a2a300dfb6ac5bccb2188ec8b", "boatstack/internal/deliverycontrol/cost.go": "a0a22292b8ed55cbfce9808599449d5128ae5b67ef6adc4881e604db0897f3f0", "boatstack/internal/deliverycontrol/graph.go": "13367b068d0004e0f2e857e7b6e9d19e758ef345070b3fbc008644be27438902", + "boatstack/internal/deliverycontrol/liveness.go": "23601670005085c62b9e7f4bd7eab74f340d622b2d80588f5cc90126307c8a8a", + "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_test.go": "473ab5e5d33f84d34c29a219db867abfc6eb3ad4489f3d5d0c7dc09b06d193f3", "boatstack/internal/deliverycontrol/state.go": "2551624bbcbd8f9dd897a1e2240cef2cc1895d117a4030525d88f1d62f6e395e", - "boatstack/internal/deliverycontrol/trajectory.go": "e25eeb092fb2255bd82477141d5b094627c1d779bef3616b89752046ae08e7c3", + "boatstack/internal/deliverycontrol/trajectory.go": "4469a0c35b40f8e2a37060e9b26fcbda7d34d020088c31de367dd5cf6e7721dc", "boatstack/internal/deliverycontrol/trajectory_test.go": "df5be9a8f55b09a94b0f6b94d2847180d39d357619eb4d1d11181015935c2f96", "boatstack/internal/deliverycontrol/trajectorylog.go": "a1da7e7252b33f63f232c101de683b4a515e80243801caf69fda53d24233e42f", "boatstack/internal/deliverycontrol/trajectorylog_test.go": "227dd6ed9ce181d517a37b67ef4d64dd93779a533eae804798ab54de35c7f13e", @@ -151,10 +167,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "bc8fcd561d1bc57f0cdbe79c537143e005b00995cbc8fe690982d00efdaf64d5", + "docs/evidence-engineered-coding.md": "e42fd1e704cc9d60d7fcb79c53e248944caf4984b02df1c496aceec5a5e51072", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "476511c6c2b2c4159f507b4e9c8f2c726b8a08a004db51a0967e73e92439050a", + "docs/public-claims.json": "52529747f11523647ca794a4d992008537e51d473935cf644144f95a4207603d", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -168,7 +184,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": "2a120ef6977a3e028e66c534244d9cfcf5a347cde3738ad176038b6c57a1c370", + "labs/diagram-json/plan.lock.json": "30ac25c13348433f4be8c4f3df06b51431a434b427ddd6d1f4f26f14ad550f6c", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -252,7 +268,11 @@ "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-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-oracle.md": "04e64e27638b32a90a132f615f896bf20ee805f3c75bf5e3ab088f3c55e641a7", + "release-notes/2026-07-25-deliverycontrol-flow-report.md": "86c519e7debd72362f8ef6ef21ec443912aa705669f396259ba771303925bbb5", "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", @@ -261,7 +281,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "fac1f242bb58f88e94da3f7cbb50ba6159d02790", + "commit": "eec4b62c152cc2da37579f891706640bff9cb1a6", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go new file mode 100644 index 0000000..2397d75 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/flow.go @@ -0,0 +1,108 @@ +package main + +import ( + "flag" + "fmt" + "os" + + boatstack "github.com/operatorstack/boatstack/boatstack" +) + +// flowCommand is the read-only entry point for delivery-flow navigation: +// `flow check` gates the owned model, `flow next` advises the lowest-cost next +// move. Both are additive and side-effect free; they change no existing command, +// gate, authority, or exit code. +func flowCommand(arguments []string) int { + if len(arguments) == 0 { + fmt.Fprintln(os.Stderr, "usage: boatstack-helper flow ") + return 2 + } + switch arguments[0] { + case "check": + return flowCheckCommand(arguments[1:]) + case "next": + return flowNextCommand(arguments[1:]) + case "report": + return flowReportCommand(arguments[1:]) + default: + fmt.Fprintln(os.Stderr, "unknown flow subcommand:", arguments[0]) + return 2 + } +} + +// flowCheckCommand runs the static conformance + liveness gate over the delivery +// model and exits non-zero on drift. It reads no repository state. +func flowCheckCommand(arguments []string) int { + flags := flag.NewFlagSet("flow check", flag.ContinueOnError) + jsonOutput := flags.Bool("json", false, "print the structured check result") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result := boatstack.FlowCheck() + if *jsonOutput { + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowCheck(result)) + } + if !result.OK { + return 1 + } + return 0 +} + +// 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. +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") + if err := flags.Parse(arguments); err != nil { + return 2 + } + next, err := boatstack.NextControl(*repo, *feature) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(next) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowNext(next)) + } + return 0 +} + +// flowReportCommand renders the session's flow-navigation regret and coding-effort +// telemetry from the shadow logs. It is read-only and never fails on an empty +// session — it simply reports zero steps. +func flowReportCommand(arguments []string) int { + flags := flag.NewFlagSet("flow report", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose flow session should be reported") + jsonOutput := flags.Bool("json", false, "print the structured report") + if err := flags.Parse(arguments); err != nil { + return 2 + } + report, err := boatstack.FlowReport(*repo) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(report) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowReport(report)) + } + return 0 +} diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index d3d2997..ba66165 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -414,7 +414,14 @@ func recordDeliveryGateCommand(arguments []string) int { if options.Feature == "" || options.SliceID == "" || options.Gate == "" || options.Status == "" { return fail(fmt.Errorf("record-delivery-gate requires --feature, --slice, --gate, and --status")) } + transition := boatstack.GateTransition(options.Gate) + guard := boatstack.GuardFlowMove(options.Repo, options.Feature, transition) + if !guard.Allow { + boatstack.RecordFlowTransition(options.Repo, guard.Transition, guard.From, false) + return fail(fmt.Errorf("%s", guard.Message)) + } receipt, err := boatstack.RecordDeliveryGate(options) + boatstack.RecordFlowTransition(options.Repo, transition, guard.From, err == nil) if err != nil { return fail(err) } @@ -768,6 +775,9 @@ func recordChangeCommand(arguments []string) int { if err != nil { return fail(err) } + // A recorded correction is the honest moment coding rework is initiated; + // record one unit of coding effort as telemetry (never a gate, never J_flow). + boatstack.RecordCodingEffort(options.Repo, 1, string(observation.Classification)) fmt.Printf("PASS: change observation recorded\nOBSERVATION_ID=%s\nCLASSIFICATION=%s\nOUTCOME=%s\nMODE=%s\nRESUME_STAGE=%s\n", observation.ID, observation.Classification, observation.Outcome, state.Mode, state.ResumeStage) if observation.Outcome == "CORRECTIVE_CHILD_REQUIRED" { fmt.Printf("PARENT_DELIVERY=%s\nSUGGESTED_FEATURE_ID=%s\n", observation.ParentDelivery, observation.SuggestedFeatureID) @@ -1088,10 +1098,20 @@ func publishPRCommand(arguments []string) int { if *previewPath == "" || *fingerprint == "" || *action == "" { return fail(fmt.Errorf("publish-pr requires --preview, --preview-fingerprint, and --action")) } + feature := "" + if preview, previewErr := boatstack.ParsePRPreview(*previewPath); previewErr == nil { + feature = preview.Feature + } + guard := boatstack.GuardFlowMove(*repo, feature, boatstack.PublishTransition) + if !guard.Allow { + boatstack.RecordFlowTransition(*repo, guard.Transition, guard.From, false) + return fail(fmt.Errorf("%s", guard.Message)) + } url, err := boatstack.PublishPR(boatstack.PRPublishOptions{ Repo: *repo, PreviewPath: *previewPath, ExpectedFingerprint: *fingerprint, Action: *action, VisualPublisher: boatstack.SelectVisualPublisher(*repo), }) + boatstack.RecordFlowTransition(*repo, boatstack.PublishTransition, guard.From, err == nil) if err != nil { return fail(err) } @@ -1101,10 +1121,6 @@ func publishPRCommand(arguments []string) int { } fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url) - feature := "" - if preview, err := boatstack.ParsePRPreview(*previewPath); err == nil { - feature = preview.Feature - } if update, ok := boatstack.PostShipUpdateNotice(*repo, feature); ok { fmt.Printf("UPDATE_AVAILABLE=%s\nUPDATE_RELEASE_URL=%s\n", update.LatestVersion, update.ReleaseURL) } @@ -1205,7 +1221,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1299,6 +1315,8 @@ func run() int { return workspaceSyncCommand(os.Args[2:]) case "migrate-config": return migrateConfigCommand(os.Args[2:]) + case "flow": + return flowCommand(os.Args[2:]) case "version": fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit) return 0 diff --git a/boatstack/cmd/boatstack-helper/main_test.go b/boatstack/cmd/boatstack-helper/main_test.go index ddd2767..0d548cc 100644 --- a/boatstack/cmd/boatstack-helper/main_test.go +++ b/boatstack/cmd/boatstack-helper/main_test.go @@ -49,6 +49,46 @@ func TestBootstrapFailureUsesBlockingExitCode(t *testing.T) { } } +// captureStdout runs fn with os.Stdout redirected and returns what it printed, +// so read-only CLI verbs can be asserted without polluting test output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + fn() + writer.Close() + os.Stdout = previous + out, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func TestFlowCheckCommandPassesOnShippedModel(t *testing.T) { + var code int + out := captureStdout(t, func() { code = flowCheckCommand(nil) }) + if code != 0 { + t.Fatalf("flow check exit = %d, want 0; output:\n%s", code, out) + } + if !strings.HasPrefix(out, "PASS:") { + t.Errorf("flow check output should lead with PASS:\n%s", out) + } +} + +func TestFlowCommandRejectsUnknownSubcommand(t *testing.T) { + if code := flowCommand([]string{"nonsense"}); code != 2 { + t.Errorf("unknown flow subcommand exit = %d, want 2", code) + } + if code := flowCommand(nil); code != 2 { + t.Errorf("flow with no subcommand exit = %d, want 2", code) + } +} + func liveHostArguments(host, prompt string) []string { switch host { case "cursor": diff --git a/boatstack/flow_coding.go b/boatstack/flow_coding.go new file mode 100644 index 0000000..1ba8f4b --- /dev/null +++ b/boatstack/flow_coding.go @@ -0,0 +1,30 @@ +package boatstack + +import ( + "os" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// RecordCodingEffort appends a best-effort coding-effort signal to the shadow +// log. Like the flow-transition recorder it never returns an error, never panics +// into a caller, and writes nothing when disabled — it honors the same +// BOATSTACK_FLOW_TRACE kill switch. Coding effort is telemetry only: it is never +// gated, never optimized, and never added to J_flow. It is stored in its own log +// so J_coding cannot be conflated with flow navigation cost. +// +// units is the coding effort to record (non-positive counts as one unit); note is +// an optional free-text marker for what the effort was. +func RecordCodingEffort(repo string, units int, note string) { + // Telemetry must never take down a command. + defer func() { _ = recover() }() + + if os.Getenv(flowTraceKillSwitch) == "0" { + return + } + directory, err := flowLogDirectory(repo) + if err != nil { + return + } + _ = deliverycontrol.AppendCodingSignal(directory, deliverycontrol.CodingSignal{Units: units, Note: note}) +} diff --git a/boatstack/flow_coding_test.go b/boatstack/flow_coding_test.go new file mode 100644 index 0000000..e886c5c --- /dev/null +++ b/boatstack/flow_coding_test.go @@ -0,0 +1,45 @@ +package boatstack + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +func TestRecordCodingEffortRoundTrips(t *testing.T) { + repo := prTestRepo(t) + + RecordCodingEffort(repo, 2, "implementation_repair") + RecordCodingEffort(repo, 0, "amend") // bare marker -> one unit + + dir, err := flowLogDirectory(repo) + if err != nil { + t.Fatal(err) + } + signals, err := deliverycontrol.ReadCodingSignals(dir) + if err != nil { + t.Fatal(err) + } + if got := deliverycontrol.TallyCoding(signals); got.JCoding != 3 || got.Signals != 2 { + t.Errorf("recorded coding effort = %+v, want J_coding 3 over 2 signals", got) + } +} + +func TestRecordCodingEffortHonorsKillSwitch(t *testing.T) { + t.Setenv(flowTraceKillSwitch, "0") + repo := prTestRepo(t) + + RecordCodingEffort(repo, 5, "should-not-write") + + dir, err := flowLogDirectory(repo) + if err != nil { + t.Fatal(err) + } + signals, err := deliverycontrol.ReadCodingSignals(dir) + if err != nil { + t.Fatal(err) + } + if len(signals) != 0 { + t.Errorf("kill switch must suppress coding telemetry; got %+v", signals) + } +} diff --git a/boatstack/flow_control.go b/boatstack/flow_control.go new file mode 100644 index 0000000..a7da8d3 --- /dev/null +++ b/boatstack/flow_control.go @@ -0,0 +1,148 @@ +package boatstack + +import ( + "fmt" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// flowGoal is the accepted end state of a delivery flow — the sink the oracle +// scores paths toward. +const flowGoal = deliverycontrol.StatePublished + +// FlowCheck runs the static conformance + liveness gate over the owned delivery +// model. It reads no repository state — it validates the single declaration and +// its graph — so the CLI gate is deterministic and side-effect free. +func FlowCheck() deliverycontrol.CheckResult { + return deliverycontrol.Check() +} + +// FormatFlowCheck renders a FlowCheck result as human-facing lines. A sound model +// reports PASS; drift lists the registry issues and any deadlocked or +// goal-unreachable states so the fault is actionable, not just a red exit code. +func FormatFlowCheck(result deliverycontrol.CheckResult) string { + var b strings.Builder + if result.OK { + fmt.Fprintf(&b, "PASS: delivery flow model is conformant and live (goal %s)\n", result.Liveness.Goal) + } else { + fmt.Fprintf(&b, "BLOCKED: delivery flow model failed the static check (goal %s)\n", result.Liveness.Goal) + } + for _, issue := range result.RegistryIssues { + fmt.Fprintf(&b, "REGISTRY_ISSUE=%s\n", issue) + } + for _, s := range result.Liveness.Deadlocks { + fmt.Fprintf(&b, "DEADLOCK=%s\n", s) + } + for _, s := range result.Liveness.GoalUnreachable { + fmt.Fprintf(&b, "GOAL_UNREACHABLE=%s\n", s) + } + fmt.Fprintf(&b, "REACHABLE=%d LIVE=%t\n", len(result.Liveness.Reachable), result.Liveness.Live) + return b.String() +} + +// flowStateFromStage maps a read-only NextStatus.ObservedStage to a +// delivery-flow StateID. It resolves ONLY the concrete slice-lifecycle stages, +// where the position is unambiguous; every planning, ambiguous, or invalid stage +// returns false so callers fall back to existing behavior rather than act on a +// guessed position. This conservative mapping is what keeps flow control from +// ever interfering with pre-activation or ambiguous flows. +func flowStateFromStage(stage string) (deliverycontrol.StateID, bool) { + switch stage { + case "BUILD": + return deliverycontrol.StateBuild, true + case "TEST_PASSED": + return deliverycontrol.StateTestPassed, true + case "REVIEW_PASSED", "PR_PREVIEW": + return deliverycontrol.StateReviewPassed, true + case "PUBLISHED", "FEATURE_COMPLETE": + return deliverycontrol.StatePublished, true + default: + return "", false + } +} + +// CurrentFlowState resolves the delivery-flow state of the addressable slice via +// the read-only ResolveNext projection. The boolean is false whenever the +// position cannot be trusted — an error, a non-VERIFIED status (blocked, +// ambiguous, uninitialized), or a stage that is not a concrete slice-lifecycle +// state. Callers must treat false as "unknown" and never fabricate a position. +func CurrentFlowState(repo, feature string) (deliverycontrol.StateID, bool) { + status, err := ResolveNext(repo, feature) + if err != nil { + return "", false + } + if status.VerificationStatus != "VERIFIED" { + return "", false + } + return flowStateFromStage(status.ObservedStage) +} + +// FlowNext is the advisory answer for `flow next`: the current delivery-flow +// state, the real recommended operation (from ResolveNext — the authoritative +// next-move table), and the oracle's lowest-cost next control plus the remaining +// cost to the goal. It is purely advisory; it changes no command, gate, or +// authority. Resolved is false when the oracle cannot place the flow, in which +// case only the real recommendation is meaningful. +type FlowNext struct { + Resolved bool `json:"resolved"` + State deliverycontrol.StateID `json:"state,omitempty"` + Goal deliverycontrol.StateID `json:"goal"` + RecommendedOp string `json:"recommended_operation"` + OracleNext deliverycontrol.TransitionID `json:"oracle_next_transition,omitempty"` + RemainingCost int `json:"remaining_flow_cost"` + Reason string `json:"reason"` +} + +// NextControl composes the authoritative read-only recommendation (ResolveNext) +// with the deterministic oracle to advise the lowest-cost next move toward a +// published delivery. It performs no mutation and is safe to call at any time. +func NextControl(repo, feature string) (FlowNext, error) { + status, err := ResolveNext(repo, feature) + if err != nil { + return FlowNext{}, err + } + out := FlowNext{ + Goal: flowGoal, + RecommendedOp: status.NextOperation, + Reason: status.Reason, + } + + var state deliverycontrol.StateID + resolved := false + if status.VerificationStatus == "VERIFIED" { + state, resolved = flowStateFromStage(status.ObservedStage) + } + if !resolved { + return out, nil + } + out.State = state + + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + advice := graph.Advise(state, flowGoal) + if advice.Resolution == deliverycontrol.Resolved { + out.Resolved = true + out.OracleNext = advice.NextTransition + out.RemainingCost = advice.RemainingCost + } + return out, nil +} + +// FormatFlowNext renders a FlowNext advisory as human-facing lines. The +// recommended operation always comes from the authoritative next-move table; the +// oracle line is shown only when the flow position resolves, and is explicitly +// labeled advisory so it is never mistaken for a gate. +func FormatFlowNext(next FlowNext) string { + var b strings.Builder + fmt.Fprintf(&b, "Recommended: %s\n", next.RecommendedOp) + if next.Reason != "" { + fmt.Fprintf(&b, "Reason: %s\n", next.Reason) + } + 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) + } else { + fmt.Fprintf(&b, "Flow state: unresolved (no oracle advisory)\n") + } + return b.String() +} diff --git a/boatstack/flow_control_test.go b/boatstack/flow_control_test.go new file mode 100644 index 0000000..d8e2cbc --- /dev/null +++ b/boatstack/flow_control_test.go @@ -0,0 +1,79 @@ +package boatstack + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// FlowCheck must pass on the shipped model: the CLI gate would otherwise block +// every invocation. +func TestFlowCheckPassesOnShippedModel(t *testing.T) { + result := FlowCheck() + if !result.OK { + t.Fatalf("shipped flow model failed static check: registry=%v deadlocks=%v goal-unreachable=%v", + result.RegistryIssues, result.Liveness.Deadlocks, result.Liveness.GoalUnreachable) + } + if !result.Liveness.Live { + t.Error("shipped model must be live") + } +} + +// CurrentFlowState resolves the concrete slice-lifecycle position from the +// read-only projection; NextControl advises the lowest-cost next move that +// matches the oracle. +func TestNextControlAdvisesFromBuild(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + + state, ok := CurrentFlowState(repo, feature) + if !ok || state != deliverycontrol.StateBuild { + t.Fatalf("current flow state = %s (ok=%t), want BUILD", state, ok) + } + + next, err := NextControl(repo, feature) + if err != nil { + t.Fatalf("NextControl: %v", err) + } + if next.RecommendedOp == "" { + t.Error("expected an authoritative recommended operation") + } + if !next.Resolved { + t.Fatal("expected the oracle to resolve a BUILD flow") + } + if next.OracleNext != "delivery.record_gate_test" { + t.Errorf("oracle next = %s, want delivery.record_gate_test", next.OracleNext) + } + if next.RemainingCost != 3 { + t.Errorf("remaining flow cost = %d, want 3", next.RemainingCost) + } +} + +// After the review gate the slice is REVIEW_PASSED, one low-cost publish from the +// goal. +func TestNextControlAdvisesFromReviewPassed(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + gateSlice(t, repo, feature, "phase-one") + + state, ok := CurrentFlowState(repo, feature) + if !ok || state != deliverycontrol.StateReviewPassed { + t.Fatalf("current flow state = %s (ok=%t), want REVIEW_PASSED", state, ok) + } + + next, err := NextControl(repo, feature) + if err != nil { + t.Fatalf("NextControl: %v", err) + } + if !next.Resolved || next.OracleNext != "delivery.publish" || next.RemainingCost != 1 { + t.Errorf("advisory from REVIEW_PASSED = %+v, want publish at cost 1", next) + } +} + +// A stage that is not a concrete slice-lifecycle position must resolve to unknown +// so callers fall back rather than act on a guess. +func TestFlowStateFromStageIsConservative(t *testing.T) { + for _, stage := range []string{"NOT_STARTED", "AMBIGUOUS", "INVALID_STATE", "POLICY_READY", "DRAFT_PLAN", ""} { + if _, ok := flowStateFromStage(stage); ok { + t.Errorf("stage %q must not resolve to a flow state", stage) + } + } +} diff --git a/boatstack/flow_guard.go b/boatstack/flow_guard.go new file mode 100644 index 0000000..2da5989 --- /dev/null +++ b/boatstack/flow_guard.go @@ -0,0 +1,102 @@ +package boatstack + +import ( + "fmt" + "os" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// flowControlKillSwitch disables active flow control when set to "0". Control is +// ON by default: the controller pre-denies a committed mutation the delivery +// graph proves is friction — illegal from the current state, so the real state +// machine would reject it anyway — and points at the low-cost move instead. +// Setting the switch to "0" restores the pre-control behavior exactly: the real +// handler still rejects the move, just without the early guidance. +const flowControlKillSwitch = "BOATSTACK_FLOW_CONTROL" + +// enforceableFrictions is the conservative allowlist of committed mutations whose +// graph legality provably matches the real state-machine guard, so pre-denying an +// illegal attempt changes no outcome (the handler would reject it too) and only +// improves the message and the trajectory record: +// +// - delivery.publish requires REVIEW_PASSED (CheckDeliveryReadyForShip); +// publishing earlier is the canonical friction the flow model targets. +// - delivery.record_gate_review requires TEST_PASSED; a review gate before the +// test gate is rejected by the same guard. +// +// Verbs whose real guards are mode-sensitive or re-entrant — record-change, undo, +// discard, ignore, and re-recording the test gate — are deliberately excluded. +// They are never pre-denied here; the real handler remains the sole authority. +var enforceableFrictions = map[deliverycontrol.TransitionID]bool{ + deliverycontrol.TransitionID("delivery.publish"): true, + deliverycontrol.TransitionID("delivery.record_gate_review"): true, +} + +// PublishTransition is the registry transition for publishing a delivery PR, +// exported so CLI wrappers can guard it without importing the internal package. +var PublishTransition = deliverycontrol.TransitionID("delivery.publish") + +// FlowGuard is the decision of the flow-control choke point for one committed +// delivery mutation. Allow=false means the move was pre-denied as proven +// friction; Message carries the guidance to surface. From/Transition/Resolved +// describe the resolved flow position for the caller's trajectory record. +type FlowGuard struct { + Allow bool + Message string + From deliverycontrol.StateID + Transition deliverycontrol.TransitionID + Resolved bool +} + +// GuardFlowMove is the pure decision the CLI choke point consults before running +// an enforced committed mutation. It is conservative by construction and has no +// side effects (recording is the caller's job, so the outcome reflects the real +// handler): +// +// - unresolved flow position -> Allow (never act on a guessed state); +// - transition outside the enforceable-friction allowlist -> Allow; +// - kill switch BOATSTACK_FLOW_CONTROL=0 -> Allow (restores prior behavior); +// - legal (productive) move from the current state -> Allow; +// - otherwise -> pre-deny with guidance toward the low-cost move. +// +// A pre-denied move is one the real state machine would reject anyway, so +// enforcement changes guidance and telemetry, never the outcome of a move the +// machine would have allowed. +func GuardFlowMove(repo, feature string, transition deliverycontrol.TransitionID) FlowGuard { + from, resolved := CurrentFlowState(repo, feature) + guard := FlowGuard{Allow: true, From: from, Transition: transition, Resolved: resolved} + + if !resolved || !enforceableFrictions[transition] || os.Getenv(flowControlKillSwitch) == "0" { + return guard + } + + graph := deliverycontrol.RegistryGraph(deliverycontrol.DefaultFlowCostWeights()) + if graph.IsLegalMove(from, transition) { + return guard + } + + guard.Allow = false + advice := graph.Advise(from, flowGoal) + if advice.Resolution == deliverycontrol.Resolved && advice.NextTransition != "" { + guard.Message = fmt.Sprintf("flow control: %s is not available from %s; the low-cost next move is %s (set %s=0 to disable)", + transition, from, advice.NextTransition, flowControlKillSwitch) + } else { + guard.Message = fmt.Sprintf("flow control: %s is not available from %s (set %s=0 to disable)", + transition, from, flowControlKillSwitch) + } + return guard +} + +// GateTransition maps a record-delivery-gate --gate value to its registry +// transition, or "" when the gate is not one the controller reasons about. +func GateTransition(gate string) deliverycontrol.TransitionID { + switch gate { + case "test": + return deliverycontrol.TransitionID("delivery.record_gate_test") + case "review": + return deliverycontrol.TransitionID("delivery.record_gate_review") + default: + return "" + } +} diff --git a/boatstack/flow_guard_test.go b/boatstack/flow_guard_test.go new file mode 100644 index 0000000..a8d6a9b --- /dev/null +++ b/boatstack/flow_guard_test.go @@ -0,0 +1,89 @@ +package boatstack + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// Positive: a productive move is always allowed. Publishing from REVIEW_PASSED is +// the legal out-edge, so the controller must not interfere. +func TestGuardAllowsProductiveMove(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + gateSlice(t, repo, feature, "phase-one") + + guard := GuardFlowMove(repo, feature, PublishTransition) + if !guard.Allow { + t.Fatalf("publish from REVIEW_PASSED must be allowed; got deny %q", guard.Message) + } + if guard.From != deliverycontrol.StateReviewPassed { + t.Errorf("guard.From = %s, want REVIEW_PASSED", guard.From) + } +} + +// Negative: publishing before review is proven friction — illegal from BUILD, so +// the real machine would reject it too. The controller pre-denies with guidance. +func TestGuardDeniesPublishBeforeReview(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + + guard := GuardFlowMove(repo, feature, PublishTransition) + if guard.Allow { + t.Fatal("publish from BUILD must be pre-denied as friction") + } + if guard.From != deliverycontrol.StateBuild { + t.Errorf("guard.From = %s, want BUILD", guard.From) + } + if guard.Message == "" { + t.Error("a denied guard must carry guidance") + } +} + +// Negative: a review gate before the test gate is friction from BUILD. +func TestGuardDeniesReviewGateBeforeTest(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + + guard := GuardFlowMove(repo, feature, GateTransition("review")) + if guard.Allow { + t.Fatal("review gate from BUILD must be pre-denied as friction") + } +} + +// Relation: the kill switch restores prior behavior exactly — the controller +// allows the move and defers entirely to the real handler. +func TestGuardKillSwitchAllows(t *testing.T) { + t.Setenv(flowControlKillSwitch, "0") + repo, feature := activateTwoSliceDelivery(t) + + if guard := GuardFlowMove(repo, feature, PublishTransition); !guard.Allow { + t.Fatal("kill switch must restore prior (allow) behavior") + } +} + +// Bypass guard: verbs outside the enforceable-friction allowlist are never +// pre-denied, even when illegal in the graph — the real handler stays the sole +// authority for mode-sensitive/re-entrant moves. +func TestGuardNeverDeniesNonEnforceableVerb(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + + // record_change is illegal from BUILD in the graph, but it is not enforceable. + guard := GuardFlowMove(repo, feature, deliverycontrol.TransitionID("delivery.record_change")) + if !guard.Allow { + t.Fatal("non-enforceable verb must never be pre-denied") + } + // The test gate is legal from BUILD and also not enforced. + if guard := GuardFlowMove(repo, feature, GateTransition("test")); !guard.Allow { + t.Fatal("test gate from BUILD must be allowed") + } +} + +// Failure-state: an unresolved flow position falls back to allow — the controller +// never acts on a guessed state. +func TestGuardUnresolvedAllows(t *testing.T) { + repo := prTestRepo(t) + + if guard := GuardFlowMove(repo, "", PublishTransition); !guard.Allow { + t.Fatal("unresolved flow must fall back to allow") + } else if guard.Resolved { + t.Error("expected the guard to report the position unresolved") + } +} diff --git a/boatstack/flow_report.go b/boatstack/flow_report.go new file mode 100644 index 0000000..930f142 --- /dev/null +++ b/boatstack/flow_report.go @@ -0,0 +1,55 @@ +package boatstack + +import ( + "fmt" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// FlowReport reads the session's shadow trajectory and coding-effort logs for a +// repo and measures them against the oracle toward a published delivery. It is +// read-only and best-effort on the logs — a missing log is an empty session, not +// an error — so the report is safe to request at any time. The result keeps +// J_flow, its oracle baseline J_flow*, the regret between them, and J_coding as +// independent figures; regret is derived purely from flow navigation. +func FlowReport(repo string) (deliverycontrol.FlowTrajectoryReport, error) { + dir, err := flowLogDirectory(repo) + if err != nil { + return deliverycontrol.FlowTrajectoryReport{}, err + } + trajectory, err := deliverycontrol.ReadTrajectory(dir) + if err != nil { + return deliverycontrol.FlowTrajectoryReport{}, err + } + signals, err := deliverycontrol.ReadCodingSignals(dir) + if err != nil { + return deliverycontrol.FlowTrajectoryReport{}, err + } + weights := deliverycontrol.DefaultFlowCostWeights() + graph := deliverycontrol.RegistryGraph(weights) + return deliverycontrol.ComputeReportWithCoding(trajectory, graph, weights, flowGoal, signals), nil +} + +// FormatFlowReport renders a session flow report as human-facing lines. When the +// oracle cannot place the session's start against the goal the regret line is +// withheld rather than fabricated, and coding effort is always shown as a +// separate figure so it is never read as part of the flow regret. +func FormatFlowReport(report deliverycontrol.FlowTrajectoryReport) string { + var b strings.Builder + fmt.Fprintf(&b, "Flow report: %d steps, start %s -> goal %s\n", report.Steps, startLabel(report.Start), report.Goal) + if report.Resolution == deliverycontrol.Resolved { + fmt.Fprintf(&b, "J_flow=%d J_flow*=%d regret=%d\n", report.JFlow, report.JFlowStar, report.Regret) + } else { + fmt.Fprintf(&b, "J_flow=%d regret=unresolved (no oracle baseline for this start)\n", report.JFlow) + } + fmt.Fprintf(&b, "J_coding=%d (telemetry, separate from flow)\n", report.JCoding) + return b.String() +} + +func startLabel(start deliverycontrol.StateID) string { + if start == "" { + return "(none)" + } + return string(start) +} diff --git a/boatstack/flow_report_test.go b/boatstack/flow_report_test.go new file mode 100644 index 0000000..a1e0840 --- /dev/null +++ b/boatstack/flow_report_test.go @@ -0,0 +1,57 @@ +package boatstack + +import ( + "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/deliverycontrol" +) + +// A recorded session with one friction attempt followed by the productive path +// reports the observed cost, the oracle baseline, the flow regret between them, +// and coding effort as a separate figure. +func TestFlowReportMeasuresSession(t *testing.T) { + repo := prTestRepo(t) + + // Friction: publish attempted (and denied) from BUILD -> billed at 3. + RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateBuild, false) + // Then the productive path, each move billed at 1. + RecordFlowTransition(repo, GateTransition("test"), deliverycontrol.StateBuild, true) + RecordFlowTransition(repo, GateTransition("review"), deliverycontrol.StateTestPassed, true) + RecordFlowTransition(repo, PublishTransition, deliverycontrol.StateReviewPassed, true) + + RecordCodingEffort(repo, 2, "repair") + RecordCodingEffort(repo, 0, "amend") // bare marker -> one unit + + report, err := FlowReport(repo) + if err != nil { + t.Fatal(err) + } + if report.Steps != 4 { + t.Errorf("steps = %d, want 4", report.Steps) + } + if report.JFlow != 6 { // 3 + 1 + 1 + 1 + t.Errorf("J_flow = %d, want 6", report.JFlow) + } + if report.Resolution != deliverycontrol.Resolved || report.JFlowStar != 3 || report.Regret != 3 { + t.Errorf("oracle baseline wrong: %+v (want J_flow*=3, regret=3)", report) + } + if report.JCoding != 3 { // 2 + 1, never folded into regret + t.Errorf("J_coding = %d, want 3", report.JCoding) + } +} + +// An empty session is well-defined: zero steps, no fabricated oracle baseline. +func TestFlowReportEmptySession(t *testing.T) { + repo := prTestRepo(t) + + report, err := FlowReport(repo) + if err != nil { + t.Fatal(err) + } + if report.Steps != 0 || report.JFlow != 0 || report.JCoding != 0 { + t.Errorf("empty session should be all zero: %+v", report) + } + if report.Resolution != deliverycontrol.Unresolved { + t.Errorf("empty session has no start, so the oracle must be unresolved; got %s", report.Resolution) + } +} diff --git a/boatstack/internal/deliverycontrol/advise.go b/boatstack/internal/deliverycontrol/advise.go new file mode 100644 index 0000000..49b587b --- /dev/null +++ b/boatstack/internal/deliverycontrol/advise.go @@ -0,0 +1,49 @@ +package deliverycontrol + +// Advice is the oracle's recommendation from a state: the lowest-cost next +// control to take toward the goal, and the remaining cost from here. It is +// purely advisory — a projection of the shortest path's first step — and is +// Unresolved (with no recommendation) whenever the oracle cannot resolve the +// endpoints, so a caller never acts on a fabricated route. +type Advice struct { + From StateID + Goal StateID + NextTransition TransitionID + NextTo StateID + NextCostClass TransitionCostClass + RemainingCost int + Resolution Resolution +} + +// Advise returns the recommended next move from a state toward a goal. When the +// start already equals the goal, the advice is Resolved with no next move (the +// walk is complete). +func (g *Graph) Advise(from, goal StateID) Advice { + advice := Advice{From: from, Goal: goal, Resolution: Unresolved} + path := g.ShortestFlow(from, goal) + if path.Resolution != Resolved { + return advice + } + advice.Resolution = Resolved + advice.RemainingCost = path.Cost + if len(path.Edges) > 0 { + first := path.Edges[0] + advice.NextTransition = first.Transition + advice.NextTo = first.To + advice.NextCostClass = first.CostClass + } + return advice +} + +// IsLegalMove reports whether a transition is a legal out-edge from a state in +// this graph — i.e. taking it now would advance rather than hit friction. The +// controller uses this to distinguish a productive move from a friction move +// without re-deriving the state machine's guards. +func (g *Graph) IsLegalMove(from StateID, transition TransitionID) bool { + for _, edge := range g.Out(from) { + if edge.Transition == transition { + return true + } + } + return false +} diff --git a/boatstack/internal/deliverycontrol/advise_test.go b/boatstack/internal/deliverycontrol/advise_test.go new file mode 100644 index 0000000..d9cab64 --- /dev/null +++ b/boatstack/internal/deliverycontrol/advise_test.go @@ -0,0 +1,53 @@ +package deliverycontrol + +import "testing" + +func TestCheckPasses(t *testing.T) { + result := Check() + if !result.OK { + t.Fatalf("static flow check failed: registry=%v live=%v", result.RegistryIssues, result.Liveness.Live) + } +} + +func TestAdviseRecommendsFirstOracleStep(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + + advice := g.Advise(StateBuild, StatePublished) + if advice.Resolution != Resolved { + t.Fatalf("expected resolved advice, got %s", advice.Resolution) + } + if advice.NextTransition != "delivery.record_gate_test" { + t.Errorf("next move from BUILD = %s, want delivery.record_gate_test", advice.NextTransition) + } + if advice.NextTo != StateTestPassed { + t.Errorf("next state = %s, want TEST_PASSED", advice.NextTo) + } + if advice.RemainingCost != 3 { + t.Errorf("remaining cost = %d, want 3", advice.RemainingCost) + } +} + +func TestAdviseSameStateHasNoNextMove(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + advice := g.Advise(StatePublished, StatePublished) + if advice.Resolution != Resolved || advice.NextTransition != "" || advice.RemainingCost != 0 { + t.Errorf("goal-reached advice should be resolved with no move: %+v", advice) + } +} + +func TestAdviseUnresolved(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + if advice := g.Advise(StateID("BOGUS"), StatePublished); advice.Resolution != Unresolved || advice.NextTransition != "" { + t.Errorf("unknown state must not recommend a move: %+v", advice) + } +} + +func TestIsLegalMove(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + if !g.IsLegalMove(StateReviewPassed, "delivery.publish") { + t.Error("publish should be legal from REVIEW_PASSED") + } + if g.IsLegalMove(StateBuild, "delivery.publish") { + t.Error("publish must not be legal from BUILD (that is friction)") + } +} diff --git a/boatstack/internal/deliverycontrol/coding.go b/boatstack/internal/deliverycontrol/coding.go new file mode 100644 index 0000000..84a6529 --- /dev/null +++ b/boatstack/internal/deliverycontrol/coding.go @@ -0,0 +1,36 @@ +package deliverycontrol + +// CodingSignal is one recorded unit of coding effort — the work of writing a fix, +// distinct from navigating the delivery flow. It is telemetry only: coding effort +// is never modeled as a graph, never optimized, and never gated. Keeping it in its +// own record type is what guarantees J_coding can never leak into J_flow or +// regret, per the J = J_flow + J_coding decomposition. +type CodingSignal struct { + Sequence int `json:"sequence"` + Units int `json:"units"` + Note string `json:"note,omitempty"` +} + +// CodingEffort is the tally of coding signals in a session: the summed units +// (J_coding) and how many signals produced it. It stands beside J_flow in a +// report; the two figures are never added together. +type CodingEffort struct { + JCoding int `json:"j_coding"` + Signals int `json:"signals"` +} + +// TallyCoding sums coding signals into J_coding. A signal with non-positive units +// counts as a single unit, so a bare "coding work happened" marker still +// registers exactly one unit of effort. +func TallyCoding(signals []CodingSignal) CodingEffort { + effort := CodingEffort{} + for _, s := range signals { + units := s.Units + if units <= 0 { + units = 1 + } + effort.JCoding += units + effort.Signals++ + } + return effort +} diff --git a/boatstack/internal/deliverycontrol/coding_test.go b/boatstack/internal/deliverycontrol/coding_test.go new file mode 100644 index 0000000..ac795e4 --- /dev/null +++ b/boatstack/internal/deliverycontrol/coding_test.go @@ -0,0 +1,55 @@ +package deliverycontrol + +import "testing" + +func TestTallyCodingSumsUnits(t *testing.T) { + effort := TallyCoding([]CodingSignal{{Units: 3}, {Units: 2}}) + if effort.JCoding != 5 || effort.Signals != 2 { + t.Errorf("tally = %+v, want J_coding 5 over 2 signals", effort) + } +} + +func TestTallyCodingBareMarkerCountsOne(t *testing.T) { + effort := TallyCoding([]CodingSignal{{Units: 0}, {Units: -4}}) + if effort.JCoding != 2 { + t.Errorf("bare/negative markers should each count one unit; J_coding = %d, want 2", effort.JCoding) + } +} + +func TestCodingLogRoundTrip(t *testing.T) { + dir := t.TempDir() + if signals, err := ReadCodingSignals(dir); err != nil || len(signals) != 0 { + t.Fatalf("empty coding log must read clean: %v %v", signals, err) + } + if err := AppendCodingSignal(dir, CodingSignal{Units: 2, Note: "repair"}); err != nil { + t.Fatal(err) + } + if err := AppendCodingSignal(dir, CodingSignal{Units: 1, Note: "amend"}); err != nil { + t.Fatal(err) + } + signals, err := ReadCodingSignals(dir) + if err != nil { + t.Fatal(err) + } + if len(signals) != 2 || signals[0].Note != "repair" || signals[1].Units != 1 { + t.Errorf("round-trip mismatch: %+v", signals) + } +} + +// Coding effort must be reported ALONGSIDE flow regret, never folded into it. +func TestComputeReportWithCodingKeepsRegretFlowOnly(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + // A single friction attempt: J_flow = 3, oracle from BUILD = 3, regret = 0. + walk := Trajectory{{From: StateBuild, Transition: "delivery.publish", Outcome: OutcomeDenied, CostClass: CostFriction}} + signals := []CodingSignal{{Units: 7}} + + report := ComputeReportWithCoding(walk, g, DefaultFlowCostWeights(), StatePublished, signals) + if report.JCoding != 7 { + t.Errorf("J_coding = %d, want 7", report.JCoding) + } + // Regret is derived purely from flow; coding effort must not perturb it. + bare := ComputeReport(walk, g, DefaultFlowCostWeights(), StatePublished) + if report.Regret != bare.Regret || report.JFlow != bare.JFlow { + t.Errorf("coding telemetry leaked into flow: with-coding=%+v flow-only=%+v", report, bare) + } +} diff --git a/boatstack/internal/deliverycontrol/codinglog.go b/boatstack/internal/deliverycontrol/codinglog.go new file mode 100644 index 0000000..a9a2a30 --- /dev/null +++ b/boatstack/internal/deliverycontrol/codinglog.go @@ -0,0 +1,74 @@ +package deliverycontrol + +import ( + "bufio" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" +) + +// codingLogFile is the append-only record of coding-effort signals within a +// flow-log directory, held separate from the trajectory log so J_coding and +// J_flow can never be conflated at the storage layer. One JSON object per line. +const codingLogFile = "coding.jsonl" + +// AppendCodingSignal appends one coding-effort signal under dir, creating the +// directory and file as needed. Like the trajectory writer it returns an error so +// tests can assert round-trips, while the live recorder swallows every error as +// best-effort — telemetry must never change command behavior. +func AppendCodingSignal(dir string, signal CodingSignal) error { + if dir == "" { + return errors.New("coding log directory is empty") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + line, err := json.Marshal(signal) + if err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(dir, codingLogFile), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer file.Close() + if _, err := file.Write(append(line, '\n')); err != nil { + return err + } + return nil +} + +// ReadCodingSignals reads the append-only coding log under dir in write order. A +// missing log is an empty slice, not an error, so a first read before any write +// is well-defined. +func ReadCodingSignals(dir string) ([]CodingSignal, error) { + file, err := os.Open(filepath.Join(dir, codingLogFile)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return []CodingSignal{}, nil + } + return nil, err + } + defer file.Close() + + var signals []CodingSignal + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var signal CodingSignal + if err := json.Unmarshal(line, &signal); err != nil { + return nil, err + } + signals = append(signals, signal) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return signals, nil +} diff --git a/boatstack/internal/deliverycontrol/liveness.go b/boatstack/internal/deliverycontrol/liveness.go new file mode 100644 index 0000000..2275bfb --- /dev/null +++ b/boatstack/internal/deliverycontrol/liveness.go @@ -0,0 +1,159 @@ +package deliverycontrol + +import ( + "fmt" + "sort" +) + +// TerminalStates are delivery-flow states from which no further move is expected: +// PUBLISHED is the accepted goal, DISCARDED is the archived end. A reachable +// state that is neither terminal nor able to move is a deadlock. +func TerminalStates() []StateID { + return []StateID{StatePublished, StateDiscarded} +} + +// EntryStates are where a delivery flow can begin: a fresh delivery +// (UNINITIALIZED) and the recovery entry (INVALID, re-entered via repair). The +// liveness check reaches the rest of the graph from these. +func EntryStates() []StateID { + return []StateID{StateUninitialized, StateInvalid} +} + +// LivenessResult reports deadlock-freedom over the delivery graph: every state +// reachable from the entries either is terminal, is the goal, or can still move +// and still reach the goal. +type LivenessResult struct { + Goal StateID `json:"goal"` + Reachable []StateID `json:"reachable"` + Deadlocks []StateID `json:"deadlocks"` // reachable, non-terminal, non-goal states with no out-edge + GoalUnreachable []StateID `json:"goal_unreachable"` // reachable, non-terminal, non-goal states from which the goal is Unresolved + Live bool `json:"live"` +} + +// CheckLiveness verifies the delivery graph is free of deadlocks and that the +// goal stays reachable. From every state reachable from the entries, a +// non-terminal non-goal state must have at least one out-edge (it can move) and +// the oracle must resolve a path from it to the goal (it is not stranded). The +// result is deterministic: all reported slices are sorted. +func CheckLiveness(g *Graph, entries []StateID, goal StateID, terminals []StateID) LivenessResult { + terminal := map[StateID]bool{} + for _, s := range terminals { + terminal[s] = true + } + + // Breadth-first reachability from the entries, following out-edges. + seen := map[StateID]bool{} + queue := append([]StateID{}, entries...) + for _, e := range entries { + seen[e] = true + } + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, edge := range g.Out(current) { + if !seen[edge.To] { + seen[edge.To] = true + queue = append(queue, edge.To) + } + } + } + + result := LivenessResult{Goal: goal, Live: true} + for s := range seen { + result.Reachable = append(result.Reachable, s) + if s == goal || terminal[s] { + continue + } + if len(g.Out(s)) == 0 { + result.Deadlocks = append(result.Deadlocks, s) + result.Live = false + continue + } + if g.ShortestFlow(s, goal).Resolution != Resolved { + result.GoalUnreachable = append(result.GoalUnreachable, s) + result.Live = false + } + } + sort.Slice(result.Reachable, func(i, j int) bool { return result.Reachable[i] < result.Reachable[j] }) + sort.Slice(result.Deadlocks, func(i, j int) bool { return result.Deadlocks[i] < result.Deadlocks[j] }) + sort.Slice(result.GoalUnreachable, func(i, j int) bool { return result.GoalUnreachable[i] < result.GoalUnreachable[j] }) + return result +} + +// CheckResult is the outcome of the runtime flow-check gate: registry +// well-formedness plus deadlock-freedom over the delivery graph. +type CheckResult struct { + RegistryIssues []string `json:"registry_issues"` + Liveness LivenessResult `json:"liveness"` + OK bool `json:"ok"` +} + +// Check runs the full static gate over the single declaration and its graph. It +// takes no repository state — it validates the owned model itself, so the CLI +// gate is deterministic and side-effect free. +func Check() CheckResult { + result := CheckResult{RegistryIssues: CheckRegistry()} + graph := RegistryGraph(DefaultFlowCostWeights()) + result.Liveness = CheckLiveness(graph, EntryStates(), StatePublished, TerminalStates()) + result.OK = len(result.RegistryIssues) == 0 && result.Liveness.Live + return result +} + +// CheckRegistry validates the single declaration at runtime: unique ids, valid +// kinds and cost classes with defined weights, declared endpoint states, and a +// non-empty registry. It returns a sorted list of human-readable issues, empty +// when the registry is well-formed. This is the runtime half of the conformance +// gate; the compile-time parity test in package boatstack guarantees the handler +// references name real functions. +func CheckRegistry() []string { + weights := DefaultFlowCostWeights() + states := map[StateID]bool{} + for _, s := range States() { + states[s] = true + } + kinds := map[TransitionKind]bool{} + for _, k := range AllKinds() { + kinds[k] = true + } + classes := map[TransitionCostClass]bool{} + for _, c := range AllCostClasses() { + classes[c] = true + } + + var issues []string + seen := map[TransitionID]bool{} + for _, tr := range Transitions() { + if tr.ID == "" { + issues = append(issues, "transition with empty ID") + continue + } + if seen[tr.ID] { + issues = append(issues, fmt.Sprintf("%s: duplicate transition ID", tr.ID)) + } + seen[tr.ID] = true + if !kinds[tr.Kind] { + issues = append(issues, fmt.Sprintf("%s: undeclared kind %q", tr.ID, tr.Kind)) + } + if !classes[tr.CostClass] { + issues = append(issues, fmt.Sprintf("%s: undeclared cost class %q", tr.ID, tr.CostClass)) + } else if _, ok := weights.Cost(tr.CostClass); !ok { + issues = append(issues, fmt.Sprintf("%s: cost class %q has no weight", tr.ID, tr.CostClass)) + } + for _, from := range tr.From { + if !states[from] { + issues = append(issues, fmt.Sprintf("%s: undeclared From state %q", tr.ID, from)) + } + } + if tr.To != "" && !states[tr.To] { + issues = append(issues, fmt.Sprintf("%s: undeclared To state %q", tr.ID, tr.To)) + } + if tr.HandlerRef == "" { + issues = append(issues, fmt.Sprintf("%s: empty HandlerRef", tr.ID)) + } + } + if len(seen) == 0 { + issues = append(issues, "registry is empty") + } + sort.Strings(issues) + return issues +} diff --git a/boatstack/internal/deliverycontrol/liveness_test.go b/boatstack/internal/deliverycontrol/liveness_test.go new file mode 100644 index 0000000..7d12f1d --- /dev/null +++ b/boatstack/internal/deliverycontrol/liveness_test.go @@ -0,0 +1,68 @@ +package deliverycontrol + +import "testing" + +// control-law: delivery-graph-is-live +// Deadlock-freedom: from every state reachable in the real registry graph, the +// flow can still move and still reach the goal (PUBLISHED). A regression that +// stranded a state — an out-edge removed, a goal made unreachable — fails here. +func TestRegistryGraphIsLive(t *testing.T) { + g := RegistryGraph(DefaultFlowCostWeights()) + result := CheckLiveness(g, EntryStates(), StatePublished, TerminalStates()) + + if !result.Live { + t.Fatalf("delivery graph is not live: deadlocks=%v goal-unreachable=%v", result.Deadlocks, result.GoalUnreachable) + } + // The core lifecycle states must all be reachable from the entries. + want := []StateID{StateUninitialized, StateBuild, StateTestPassed, StateReviewPassed, StatePublished} + reachable := map[StateID]bool{} + for _, s := range result.Reachable { + reachable[s] = true + } + for _, s := range want { + if !reachable[s] { + t.Errorf("expected %q reachable from entries", s) + } + } +} + +// A hand-built graph with a genuine dead end is caught. +func TestCheckLivenessDetectsDeadlock(t *testing.T) { + g := NewGraph(DefaultFlowCostWeights()) + g.AddEdge(StateUninitialized, StateBuild, "activate", CostMutation) + // BUILD has no way forward and is not terminal → a deadlock. + result := CheckLiveness(g, []StateID{StateUninitialized}, StatePublished, TerminalStates()) + if result.Live { + t.Fatal("expected a deadlock to be reported") + } + found := false + for _, s := range result.Deadlocks { + if s == StateBuild { + found = true + } + } + if !found { + t.Errorf("BUILD should be a deadlock; got %v", result.Deadlocks) + } +} + +// A reachable non-terminal state that cannot reach the goal is reported as +// goal-unreachable (not a deadlock — it can move, just not to the goal). +func TestCheckLivenessDetectsGoalUnreachable(t *testing.T) { + g := NewGraph(DefaultFlowCostWeights()) + g.AddEdge(StateUninitialized, StateBuild, "activate", CostMutation) + g.AddEdge(StateBuild, StateDiscarded, "discard", CostMutation) // moves, but only to a terminal + result := CheckLiveness(g, []StateID{StateUninitialized}, StatePublished, TerminalStates()) + if result.Live { + t.Fatal("expected goal-unreachable to make the graph non-live") + } + if len(result.GoalUnreachable) == 0 { + t.Errorf("BUILD cannot reach PUBLISHED; expected it reported, got %v", result.GoalUnreachable) + } +} + +func TestCheckRegistryClean(t *testing.T) { + if issues := CheckRegistry(); len(issues) != 0 { + t.Errorf("registry should be well-formed; issues: %v", issues) + } +} diff --git a/boatstack/internal/deliverycontrol/trajectory.go b/boatstack/internal/deliverycontrol/trajectory.go index 45aa5dc..536269a 100644 --- a/boatstack/internal/deliverycontrol/trajectory.go +++ b/boatstack/internal/deliverycontrol/trajectory.go @@ -48,13 +48,17 @@ func ChargedCostClass(kind TransitionKind, declared TransitionCostClass, outcome // is left at zero (there is no baseline to regret against — never a fabricated // one). type FlowTrajectoryReport struct { - Start StateID - Goal StateID - JFlow int - JFlowStar int - Regret int - Steps int - Resolution Resolution + Start StateID `json:"start"` + Goal StateID `json:"goal"` + JFlow int `json:"j_flow"` + JFlowStar int `json:"j_flow_star"` + Regret int `json:"regret"` + // JCoding is coding effort measured as telemetry and reported ALONGSIDE J_flow. + // It is never summed into J_flow and never enters Regret — the decomposition + // J = J_flow + J_coding keeps the two costs separate by construction. + JCoding int `json:"j_coding"` + Steps int `json:"steps"` + Resolution Resolution `json:"resolution"` } // WalkCost sums a trajectory's observed J_flow: each attempt billed at its @@ -92,3 +96,13 @@ func ComputeReport(t Trajectory, g *Graph, weights FlowCostWeights, goal StateID } return report } + +// ComputeReportWithCoding measures flow regret exactly as ComputeReport and then +// attaches coding effort as a SEPARATE figure. J_coding is summed from telemetry +// signals — never from the graph — and is never folded into J_flow or Regret, so +// optimizing flow can never be confused with reducing coding effort. +func ComputeReportWithCoding(t Trajectory, g *Graph, weights FlowCostWeights, goal StateID, signals []CodingSignal) FlowTrajectoryReport { + report := ComputeReport(t, g, weights, goal) + report.JCoding = TallyCoding(signals).JCoding + return report +} diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 908f0d6..8458eeb 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 [`fac1f242bb58f88e94da3f7cbb50ba6159d02790`](https://github.com/operatorstack/intelligence-flow/tree/fac1f242bb58f88e94da3f7cbb50ba6159d02790/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 [`eec4b62c152cc2da37579f891706640bff9cb1a6`](https://github.com/operatorstack/intelligence-flow/tree/eec4b62c152cc2da37579f891706640bff9cb1a6/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 a7ce558..a55dfd9 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "fac1f242bb58f88e94da3f7cbb50ba6159d02790", + "source_commit": "eec4b62c152cc2da37579f891706640bff9cb1a6", "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" }, { "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:fac1f242bb58f88e94da3f7cbb50ba6159d02790" + "last_verified_version": "source:eec4b62c152cc2da37579f891706640bff9cb1a6" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 7d0745e..56a7772 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": "fac1f242bb58f88e94da3f7cbb50ba6159d02790", + "source_commit": "eec4b62c152cc2da37579f891706640bff9cb1a6", "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-coding-telemetry.md b/release-notes/2026-07-25-deliverycontrol-coding-telemetry.md new file mode 100644 index 0000000..3d4db3f --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-coding-telemetry.md @@ -0,0 +1,11 @@ +### Coding-effort telemetry, held separate from flow regret (J = J_flow + J_coding) + +Delivery reports can now carry coding effort (J_coding) beside navigation cost (J_flow), recorded from +its own best-effort telemetry log rather than derived from the delivery graph. A recorded correction +marks one unit of coding effort; the two costs are stored separately and reported side by side. + +The separation is enforced by construction: J_coding is never summed into J_flow, never enters the +regret figure, and is never modeled as a graph, optimized, or gated. Only navigation of the +deterministic delivery state machine is ever optimized; the work of writing a fix is measured, not +steered. The telemetry honors the same kill switch as the trajectory trace and never changes any +command's behavior or exit code. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-check-advisory.md b/release-notes/2026-07-25-deliverycontrol-flow-check-advisory.md new file mode 100644 index 0000000..1ab0bd4 --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-check-advisory.md @@ -0,0 +1,14 @@ +### `boatstack flow check` gate and `flow next` advisory over the delivery graph (read-only) + +A new read-only `flow` command surfaces the delivery-flow model. `flow check` runs a static gate over +the owned transition declaration: it confirms the registry is well-formed and that the delivery graph +is live — from every reachable state the flow can still move and still reach a published delivery, with +no deadlocks and no stranded states — and exits non-zero on drift. It reads no repository state, so the +check is deterministic and side-effect free. + +`flow next` composes the authoritative next-move projection with the shortest-path oracle to advise the +lowest-cost next move toward a published delivery, and the remaining navigation cost from where the +delivery sits now. The advice is explicit about being advisory and resolves the flow position only from +concrete slice-lifecycle stages; an ambiguous or pre-activation stage prints the authoritative +recommendation with no oracle route rather than guessing one. Both subcommands are additive — they +change no existing command, gate, authority, evidence, or exit code. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-control.md b/release-notes/2026-07-25-deliverycontrol-flow-control.md new file mode 100644 index 0000000..42671f6 --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-control.md @@ -0,0 +1,15 @@ +### Flow control steers committed mutations away from proven friction (on by default, kill switch) + +Committed delivery mutations now pass through a flow-control choke point before their handler runs. +When the delivery graph proves a move is friction — illegal from the current state, so the real state +machine would reject it anyway — the controller pre-denies it and points at the low-cost next move +instead of letting the attempt fail with a bare error. This is on by default and reversible with the +`BOATSTACK_FLOW_CONTROL=0` kill switch, which restores the prior behavior exactly. + +Enforcement is deliberately conservative. It acts only on moves whose graph legality provably matches +the real guard — publishing before the review gate, and recording a review gate before the test gate — +so a pre-denied move is always one the state machine would have rejected regardless: guidance and +telemetry change, outcomes do not. It never acts when the flow position is unresolved, never touches +recovery or read-only verbs, and leaves mode-sensitive or re-entrant moves entirely to their existing +handlers. Every guarded attempt is recorded to the shadow trajectory log, so the friction the model was +built to measure is now measured on the real forward path. diff --git a/release-notes/2026-07-25-deliverycontrol-flow-report.md b/release-notes/2026-07-25-deliverycontrol-flow-report.md new file mode 100644 index 0000000..6a12780 --- /dev/null +++ b/release-notes/2026-07-25-deliverycontrol-flow-report.md @@ -0,0 +1,12 @@ +### `boatstack flow report` — per-session flow regret and coding effort + +A new read-only `flow report` subcommand renders the current session's delivery-flow navigation from +the shadow logs: how many moves were taken, the observed navigation cost (J_flow), the oracle's cost for +the same start and goal (J_flow*), and the regret between them — with coding effort (J_coding) shown as +a separate figure and never folded into the regret. When the oracle cannot place the session's start +against the goal, the regret line is withheld rather than fabricated. + +The report reads only the append-only logs and never fails on an empty session. Its `--json` form is a +stable, public-safe surface suitable for a downstream retro to consume when attributing where a +session's regret concentrated — closing the observe → derive → compare → advise → control loop with a +measurement an operator can actually read.