diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45dd833..a668518 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/d3c22e162d0b3ff1c81f117d7c4643dedc79a627/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/5850912e0d947f9fbee5048d9ed6a79a5d614ff1/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 8ef8b4b..a21eb22 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 85503, - "estimated_tokens": 21376, + "characters": 86089, + "estimated_tokens": 21523, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,14 +12,14 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "cefcf0e9a1d895daa2f41e2bc14a6e47efc4517e354511edb4b016f66b07722b", + "CONTRIBUTING.md": "16a84cf6a740fe70a920537a534dfb11f00273204d83a9f8f23661dca7d5331d", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", "assets/boatstack-portability.svg": "66dfdfa85db857b3bd18b32047a6975f1fbbfc4dc091158e8277193f9969a346", "boatstack/AGENTS.md": "bc76221e1fe90a91afbacd7c6bc9b41a70e6c10fc128c275a6a0b9bc094d9506", "boatstack/BUG-worktree-delivery-state.md": "02469cf51c3849dad5743783e248e5c04583e4240507fbef0e3f890cd6a95724", - "boatstack/SKILL.md": "1eb959a55c5462143c54f0016963542e7b28ceb5ed6ce1cfba942fde9595348a", + "boatstack/SKILL.md": "c7c02b3012d455ae5675e60bca5c9fdd624bf865137561243c0887cc9709a6bd", "boatstack/activation.go": "deb7712d20aed37371254596613bfaccfc05fcb59634408b03378d1a0bf693ea", "boatstack/agents/gemini.yaml": "cbf43b387399e456fa6178f86d83e6e35567e6142ff800f8de6ffca306fa963e", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", @@ -43,7 +43,7 @@ "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "347810fec8cc65300ad58cf84570040a001f6dffd9d464f21038034bec6f00e9", - "boatstack/cmd/boatstack-helper/flow.go": "448b8c3065db347bf8b23e17c056e84ee95dd51af7028947e863b06d2bafe4b6", + "boatstack/cmd/boatstack-helper/flow.go": "0d41c7a86b49004e897f59551780220841d5941396202c81de97a4d52f593520", "boatstack/cmd/boatstack-helper/main.go": "9e0712b0a3936a3066a33b9c97f92d8ebc07f6e200664b21e6a3af03a00d9f3b", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", @@ -92,6 +92,8 @@ "boatstack/flow_tasks_conformance_test.go": "fbc4d672536051f8e20a2e6c07c5b10f78cd84fc04765eee11cbed7a459b8e4b", "boatstack/flow_trace.go": "1a69f9dd53db313235c74f7ae4f821a6aed023f780aea57210c721ea8f11f2fc", "boatstack/flow_trace_test.go": "99f89a831e904f6a8ef710b6977ed3a808ce1c7ddfaba457b292d84f2ddca51b", + "boatstack/flow_watch.go": "3baca52f0e2a0e4e4ad90f5f6f30369fe28f382efbdf0e7f2153dafba2538051", + "boatstack/flow_watch_conformance_test.go": "aba71d94844a23bbd0be4bede038e006ae5f2eaaf5df662b8eb96fcc0572461b", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", "boatstack/hooks.go": "a3881c69dd88025c914ebaaa43362a2b1c47565f0ec16074e078d16cb6cfb853", @@ -167,7 +169,7 @@ "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "f370167fbd82ceadf23e21a49e0662bd1fcf8e6eefcda88e987f51496a74aaee", + "boatstack/references/workflow.md": "aae65f93ddd1aa7b962e07d3bd9f40816e206f550e475d95ad11a2faaa515384", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", @@ -208,10 +210,10 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", - "docs/evidence-engineered-coding.md": "0a78679e87b821cc47d78af524d89fb1d3cd57f132f54843cf33ff46e6783808", + "docs/evidence-engineered-coding.md": "f0567d8bb1fdf42d78ca7dac28603042a78e2290487a6fce8518ff16b8307fd2", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "51c2823f21e35140d31e6d5083dc4b89fddd24721ac6acc474154a4da53ee9f8", - "docs/public-claims.json": "ce20eadaff1e1ec07dd5c082eb1862aa7545e7829439727f98729addbdd12da4", + "docs/public-claims.json": "ff6bba756adaa499998016d2e4986c7fdfe7bc6f5d3ae7531bbc4471a6371a8b", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -225,7 +227,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": "9ba74ea99fff5c8dffe7422251359c2e8e5941538d81b9b92987c4fb9e1e2af7", + "labs/diagram-json/plan.lock.json": "73fe60031dd4c5b25b28fbbb29654416359a4040b1d7bf124618aede0e482633", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -355,6 +357,7 @@ "release-notes/2026-07-27-sandboxed-migration-grading.md": "03cebc372bbdfed37cc70d18f3b6374d1aa5e585bafefa073dbcced58bd0336a", "release-notes/2026-07-27-state-ownership-map.md": "d032547aafc1a4acbeb520f6cbb59d7757de4f33fe824701d7b5ea8cd8c8b9e7", "release-notes/2026-07-28-flow-frontier-dashboard.md": "9a768e0fd67f122bc0aef99899314f0a8530189164fb560b536ee03f20e29081", + "release-notes/2026-07-28-flow-watch-loop.md": "54833887e733c65626c3bb7f0e6430eca8028f0fe19aa2cd31cdbdcc6ff4d8ba", "release-notes/2026-07-28-minimum-app-permissions.md": "9ef97e32e5591966ef34ba23f4e0a7aa14d061a4ac5f72f5f4dcecc9bfb62a89", "release-notes/2026-07-28-native-auto-merge-conformance.md": "67d0fab76fd4911b5836d19d06b319537cb1807650d35dbd534627a2c3622757", "release-notes/2026-07-28-operator-frontier-next-actor.md": "7e769625a2beb8a204d2d79158c18bdac350656de5c55998988a8a5aba2c319a", @@ -364,7 +367,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "d3c22e162d0b3ff1c81f117d7c4643dedc79a627", + "commit": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index c28f73e..5076276 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -32,6 +32,8 @@ For the full state machine, read [workflow.md](references/workflow.md). For arti Run the project-local helper's read-only `next-status --repo . --json` inspection. Repository artifacts, managed delivery state, gate receipts, and the recorded PR identity are evidence; conversation, terminal, worktree, and process observations are context only. Never run the returned operation automatically. `NOT_STARTED` points to `auto-plan` (run it with the plan path via `--plan`); `PUBLISHED` means a PR exists but is not a verified merge; only `FEATURE_COMPLETE` requires no action. If state is ambiguous, stale, or invalid, name the blocker instead of choosing by recency or clearing artifacts. When an `AMBIGUOUS` block names only past deliveries the user no longer cares about, name the ignorable delivery slug(s) and offer to exclude them from ambiguity resolution; only after explicit user confirmation, add each slug with `.product-loop/bin/boatstack-helper ignore-delivery --repo . --feature ` (a bounded, provenance-safe write to `workflow.ignored_deliveries` — never hand-edit config or delivery state). Any new, unlisted ambiguous delivery still pauses the workflow. +To see every feature at once, run the read-only `.product-loop/bin/boatstack-helper flow frontier --repo .`. It lists each delivery, its observed position, and who owes the next step. To wait for a published PR to move (checks finish, a review lands, a merge happens), run the read-only `.product-loop/bin/boatstack-helper flow watch --repo .`. The watch observes on an interval and exits when the frontier changes, when nothing can move, or at its timeout. It never acts on what it sees. When it exits, run `next-status` again and continue from the fresh state. + ## Run through ship For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Before the first delivery-stage operation (`build`, `repair`, `test-gate`, `review-gate`, or `ship-gate`), run `run-preflight --repo . --json`. Planning and approval do not require a remote fetch. The preflight fetches `origin` and verifies the current named branch contains the fetched delivery base and is not behind or diverged from its upstream. A failed fetch, missing remote/base, stale base, upstream drift, or constrained branch mismatch blocks before delivery mutation. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. diff --git a/boatstack/cmd/boatstack-helper/flow.go b/boatstack/cmd/boatstack-helper/flow.go index 7b5874c..f0eb673 100644 --- a/boatstack/cmd/boatstack-helper/flow.go +++ b/boatstack/cmd/boatstack-helper/flow.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "time" boatstack "github.com/operatorstack/boatstack/boatstack" ) @@ -14,7 +15,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] { @@ -26,6 +27,8 @@ func flowCommand(arguments []string) int { return flowTasksCommand(arguments[1:]) case "frontier": return flowFrontierCommand(arguments[1:]) + case "watch": + return flowWatchCommand(arguments[1:]) case "report": return flowReportCommand(arguments[1:]) default: @@ -177,6 +180,41 @@ func flowFrontierCommand(arguments []string) int { return 0 } +// flowWatchCommand runs the bounded observe-compare loop: re-observe the +// frontier on an interval, exit 0 the moment it changes (or when nothing can +// move), exit 1 when the timeout passes with no change. It observes and +// exits; it never acts on what it sees. +// control-law: watch-observes-and-exits-never-acts +func flowWatchCommand(arguments []string) int { + flags := flag.NewFlagSet("flow watch", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose delivery frontier should be watched") + interval := flags.Duration("interval", 30*time.Second, "time between frontier observations") + timeout := flags.Duration("timeout", 30*time.Minute, "maximum time to wait for a frontier change") + jsonOutput := flags.Bool("json", false, "print the structured watch result") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.WatchFrontier(boatstack.FlowWatchOptions{ + Repo: *repo, Interval: *interval, Timeout: *timeout, + }) + if err != nil { + return fail(err) + } + if *jsonOutput { + value, marshalErr := boatstack.MarshalJSON(result) + if marshalErr != nil { + return fail(marshalErr) + } + fmt.Print(string(value)) + } else { + fmt.Print(boatstack.FormatFlowWatch(result)) + } + if result.Outcome == boatstack.WatchOutcomeTimeout { + return 1 + } + return 0 +} + // 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 diff --git a/boatstack/flow_watch.go b/boatstack/flow_watch.go new file mode 100644 index 0000000..df86bca --- /dev/null +++ b/boatstack/flow_watch.go @@ -0,0 +1,169 @@ +package boatstack + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// `flow watch` is the bounded waiting primitive for the asynchronous world a +// published PR lives in (CI runs, reviews land, merges happen). Each tick it +// re-runs the same read-only frontier observation and compares a stable +// signature of every row; it EXITS on the first change, on an all-terminal +// frontier, or at the deadline — it never acts on what it sees. Boatstack +// stays a synchronous oracle: the loop here only decides when to ask the +// oracle again, and hands control back the moment the answer differs. No +// daemon, no writes, no transition execution path is reachable from it. +// control-law: watch-observes-and-exits-never-acts +const flowWatchSchemaVersion = 1 + +const ( + WatchOutcomeChanged = "changed" + WatchOutcomeTerminal = "terminal" + WatchOutcomeTimeout = "timeout" +) + +// Seams for tests: the watcher must be provable without real waiting. +var ( + flowWatchNow = time.Now + flowWatchSleep = time.Sleep +) + +type FlowWatchOptions struct { + Repo string + Interval time.Duration + Timeout time.Duration +} + +// FlowWatchResult reports why the watch loop returned and what it saw. Final +// always carries the last observed frontier so the caller re-orients without +// another resolution. +type FlowWatchResult struct { + SchemaVersion int `json:"schema_version"` + Outcome string `json:"outcome"` + Ticks int `json:"ticks"` + ChangedRows []string `json:"changed_rows,omitempty"` + Final FlowFrontier `json:"final"` +} + +const ( + defaultWatchInterval = 30 * time.Second + defaultWatchTimeout = 30 * time.Minute + // minimumWatchInterval keeps a mistyped interval from hammering GitHub. + minimumWatchInterval = 5 * time.Second +) + +// WatchFrontier runs the bounded observe-compare loop. It returns an error +// only for the faults ResolveFrontier itself refuses (unreadable store, +// invalid config); a failing gh observation degrades each row to an Unknown +// phase — a signature like any other — and the loop stays bounded. +func WatchFrontier(options FlowWatchOptions) (FlowWatchResult, error) { + interval := options.Interval + if interval <= 0 { + interval = defaultWatchInterval + } + if interval < minimumWatchInterval { + interval = minimumWatchInterval + } + timeout := options.Timeout + if timeout <= 0 { + timeout = defaultWatchTimeout + } + + result := FlowWatchResult{SchemaVersion: flowWatchSchemaVersion} + initial, err := ResolveFrontier(options.Repo) + if err != nil { + return result, err + } + result.Final = initial + if frontierAllTerminal(initial) { + result.Outcome = WatchOutcomeTerminal + return result, nil + } + baseline := frontierSignatures(initial) + deadline := flowWatchNow().Add(timeout) + for { + if !flowWatchNow().Before(deadline) { + result.Outcome = WatchOutcomeTimeout + return result, nil + } + flowWatchSleep(interval) + result.Ticks++ + current, err := ResolveFrontier(options.Repo) + if err != nil { + return result, err + } + result.Final = current + signatures := frontierSignatures(current) + if changed := signatureDiff(baseline, signatures); len(changed) > 0 { + result.Outcome = WatchOutcomeChanged + result.ChangedRows = changed + return result, nil + } + } +} + +// frontierAllTerminal reports whether nothing on the frontier can move: no +// rows at all, or every row terminal. Blocked and operator rows are NOT +// terminal — external state (a review, a merge, a fix landing elsewhere) can +// change them, which is exactly what a watcher waits for. +func frontierAllTerminal(frontier FlowFrontier) bool { + if !frontier.Initialized || len(frontier.Rows) == 0 { + return true + } + for _, row := range frontier.Rows { + if row.Actor != string(NextActorNone) { + return false + } + } + return true +} + +// frontierSignatures reduces each row to the stable facts a caller would act +// on: position, actor, lifecycle, and the failing-check set. Reasons and +// prescribed command text are deliberately excluded — wording changes are not +// frontier changes. +func frontierSignatures(frontier FlowFrontier) map[string]string { + signatures := map[string]string{} + for _, row := range frontier.Rows { + key := row.Feature + "/" + row.Slice + signatures[key] = strings.Join([]string{ + row.Stage, row.Lifecycle, row.PRPhase, row.Actor, row.NextOperation, + fmt.Sprintf("blocked=%t", row.Blocked), + strings.Join(row.PRFailingChecks, "|"), + }, "·") + } + return signatures +} + +func signatureDiff(before, after map[string]string) []string { + changed := []string{} + for key, value := range after { + if previous, ok := before[key]; !ok || previous != value { + changed = append(changed, key) + } + } + for key := range before { + if _, ok := after[key]; !ok { + changed = append(changed, key+" (gone)") + } + } + sort.Strings(changed) + return changed +} + +// FormatFlowWatch renders the watch outcome and the final frontier. +func FormatFlowWatch(result FlowWatchResult) string { + var b strings.Builder + switch result.Outcome { + case WatchOutcomeChanged: + fmt.Fprintf(&b, "Watch: frontier changed after %d tick(s): %s\n", result.Ticks, strings.Join(result.ChangedRows, ", ")) + case WatchOutcomeTerminal: + b.WriteString("Watch: nothing on the frontier can move; not waiting.\n") + case WatchOutcomeTimeout: + fmt.Fprintf(&b, "Watch: no frontier change within the timeout (%d tick(s)).\n", result.Ticks) + } + b.WriteString(FormatFlowFrontier(result.Final)) + return b.String() +} diff --git a/boatstack/flow_watch_conformance_test.go b/boatstack/flow_watch_conformance_test.go new file mode 100644 index 0000000..8d9b4d5 --- /dev/null +++ b/boatstack/flow_watch_conformance_test.go @@ -0,0 +1,164 @@ +package boatstack + +// control-law: watch-observes-and-exits-never-acts +// +// `flow watch` is a bounded observe-compare loop over the read-only frontier: +// each tick re-observes, and the loop exits on the first signature change, on +// an all-terminal frontier, or at the deadline. It never executes a +// transition and never writes — across any number of ticks the delivery +// ledger stays byte-identical. Time is injected through seams so the law is +// provable without real waiting. +// +// Test classes: positive (an external phase change ends the wait and names +// the changed row), negative (no change → timeout outcome, frontier intact), +// bypass (zero writes across many ticks), failure-state (gh failing every +// tick degrades to Unknown rows and the loop still terminates at the +// deadline; an all-terminal frontier refuses to wait at all). + +import ( + "errors" + "os" + "sync/atomic" + "testing" + "time" +) + +// fakeWatchClock replaces the time seams: sleeping advances a virtual clock, +// so deadlines fire deterministically and instantly. +func fakeWatchClock(t *testing.T) *atomic.Int64 { + t.Helper() + var virtual atomic.Int64 + previousNow, previousSleep := flowWatchNow, flowWatchSleep + flowWatchNow = func() time.Time { return time.Unix(0, virtual.Load()) } + flowWatchSleep = func(d time.Duration) { virtual.Add(int64(d)) } + t.Cleanup(func() { flowWatchNow, flowWatchSleep = previousNow, previousSleep }) + return &virtual +} + +func watchRepoWithOpenPR(t *testing.T) string { + t.Helper() + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "shipped", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "shipped", "feat/phase", "https://example.invalid/pr/9", "") + return repo +} + +// Positive: the PR's checks finish between ticks; the watch exits with +// outcome "changed" and names the row that moved. +func TestWatchExitsWhenTheFrontierChanges(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + var calls atomic.Int64 + withRecoveryGh(t, func(_ string, args ...string) (string, error) { + if calls.Add(1) <= 1 { + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...) + } + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunFail)(repo, args...) + }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeChanged || result.Ticks != 1 { + t.Fatalf("unexpected watch result: %#v", result) + } + if len(result.ChangedRows) != 1 || result.ChangedRows[0] != "shipped/delivery" { + t.Fatalf("changed row not named: %#v", result.ChangedRows) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseChecksFailing) { + t.Fatalf("final frontier does not carry the new observation: %#v", result.Final.Rows) + } +} + +// Negative: nothing changes; the watch times out with the frontier intact and +// the CLI-visible timeout outcome. +func TestWatchTimesOutWhenNothingChanges(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + withRecoveryGh(t, phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 10 * time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTimeout { + t.Fatalf("unexpected outcome: %#v", result) + } + if result.Ticks < 5 || result.Ticks > 7 { + t.Fatalf("unexpected tick count for 1h/10m: %d", result.Ticks) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseChecksPending) { + t.Fatalf("frontier drifted without a change: %#v", result.Final.Rows) + } +} + +// Bypass: across many ticks — including a tick that observes a terminal +// MERGED lifecycle — the watch writes nothing. The change is reported, never +// recorded. +func TestWatchWritesNothingAcrossTicks(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + statePath, err := deliveryStatePath(repo, "shipped") + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int64 + withRecoveryGh(t, func(_ string, args ...string) (string, error) { + if calls.Add(1) <= 3 { + return phaseObservationPayload("OPEN", "", "CLEAN", rollupCheckRunPending)(repo, args...) + } + return phaseObservationPayload("MERGED", "", "", "")(repo, args...) + }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeChanged { + t.Fatalf("unexpected outcome: %#v", result) + } + after, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("watch mutated the delivery ledger") + } +} + +// Failure-state 1: gh fails on every tick — rows degrade to Unknown, no +// crash, and the loop still ends at the deadline. +func TestWatchStaysBoundedWhenObservationFails(t *testing.T) { + fakeWatchClock(t) + repo := watchRepoWithOpenPR(t) + withRecoveryGh(t, func(string, ...string) (string, error) { return "", errors.New("gh unavailable") }) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: 15 * time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTimeout { + t.Fatalf("unexpected outcome: %#v", result) + } + if result.Final.Rows[0].PRPhase != string(PRPhaseUnknown) { + t.Fatalf("degraded observation not fail-closed: %#v", result.Final.Rows) + } +} + +// Failure-state 2: when nothing on the frontier can move, the watch refuses +// to wait at all. +func TestWatchRefusesToWaitOnTerminalFrontier(t *testing.T) { + fakeWatchClock(t) + repo := nextTestRepo(t) + writeNextDelivery(t, repo, "done", "PUBLISHED", 1) + updateRecoveryDelivery(t, repo, "done", "feat/phase", "https://example.invalid/pr/9", "") + withRecoveryGh(t, phaseObservationPayload("MERGED", "", "", "")) + result, err := WatchFrontier(FlowWatchOptions{Repo: repo, Interval: time.Minute, Timeout: time.Hour}) + if err != nil { + t.Fatal(err) + } + if result.Outcome != WatchOutcomeTerminal || result.Ticks != 0 { + t.Fatalf("unexpected result on a terminal frontier: %#v", result) + } +} diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index bb636cd..042d202 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -487,6 +487,10 @@ When `workspace.enabled` is set and an approved feature is still on the default When `workspace.enabled` is set, `boatstack-next` surfaces `workspace-cleanup` for a published feature whose managed worktree still exists locally. The `workspace-cleanup` operation checks the pull request's merge state (GitHub CLI, falling back to local ancestry) and reports it. When `workspace.cleanup_after` is `merge`, cleanup is offered only once the PR is confirmed merged; while it is still open, the workspace is kept and the human may keep waiting or override explicitly. Cleanup never removes a workspace with uncommitted or unmerged work without an explicit forced override, and it reclaims only the local worktree and branch — it never deletes a remote branch or merges anything. In `confirm` mode the human reclaims the workspace with the exact reply `c` (or keeps it with `k`); `auto` mode reclaims a merged workspace without a prompt; `off` disables cleanup. A fresh feature workspace is likewise cut from the up-to-date default branch when a new feature begins, so work never starts on a stale branch. +### `PR_OPEN -> WATCH` + +A published pull request changes asynchronously: checks finish, reviews land, merges happen. `flow watch` is the bounded waiting primitive for that interval. It re-observes the read-only frontier on an interval and exits when a row's position or owner changes, when nothing on the frontier can move, or when its timeout passes (distinct exit code). It performs no writes and executes no operation — observation and actuation stay separate, so waiting can never become acting. When the watch exits, resolve `next-status` again and continue from the fresh state. + ### `PR_OPEN -> RETRO` Record unexpected friction and outcomes. A retro may propose a loop move, but it may not mutate durable instructions automatically. diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index cd97c23..964e22e 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **21376 estimated tokens**, while host adapters point to one operation at a time. +That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **21523 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`d3c22e162d0b3ff1c81f117d7c4643dedc79a627`](https://github.com/operatorstack/intelligence-flow/tree/d3c22e162d0b3ff1c81f117d7c4643dedc79a627/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 [`5850912e0d947f9fbee5048d9ed6a79a5d614ff1`](https://github.com/operatorstack/intelligence-flow/tree/5850912e0d947f9fbee5048d9ed6a79a5d614ff1/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 9f5a585..25100e3 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "d3c22e162d0b3ff1c81f117d7c4643dedc79a627", + "source_commit": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" }, { "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:d3c22e162d0b3ff1c81f117d7c4643dedc79a627" + "last_verified_version": "source:5850912e0d947f9fbee5048d9ed6a79a5d614ff1" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index c2768f0..92f2cd4 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": "d3c22e162d0b3ff1c81f117d7c4643dedc79a627", + "source_commit": "5850912e0d947f9fbee5048d9ed6a79a5d614ff1", "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-28-flow-watch-loop.md b/release-notes/2026-07-28-flow-watch-loop.md new file mode 100644 index 0000000..90a73bb --- /dev/null +++ b/release-notes/2026-07-28-flow-watch-loop.md @@ -0,0 +1,5 @@ +### You can now wait for a pull request to move without polling it yourself + +`flow watch` observes your delivery frontier on an interval and exits the moment something changes: checks finish or fail, a review lands, a merge happens. It also exits immediately when nothing can move, and with a distinct exit code when its timeout passes with no change, so a script or an agent loop can tell "something happened" from "still waiting". Defaults are a 30-second interval and a 30-minute timeout, both adjustable. + +The watch only observes: it performs no writes and never runs an operation on your behalf. When it exits, run `next-status` and continue from the fresh state. Before this, waiting on CI meant either re-running status by hand or asking your agent to poll GitHub in prose.