diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index caf59ac..658bb40 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/bab232ddd34b39e9e480131a3c967c723f831232/examples/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/16e771775417be997b7fc17e1b1b28d700780899/examples/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/README.md b/README.md index 3ef1213..5dab209 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ They come from coding failures observed in benchmark and product-repository work | A passing test was used to support a broader claim | Links each promised outcome to its validation | Coverage and plan-compiler tests | | A failed write led to an invented reset path | Denies high-confidence destructive recovery | Hook behavior verified; outcome benefit still being evaluated | | A PR lost decisions and accepted gaps | Builds a review brief from scope, diff, and evidence | Projection and stale-preview tests | +| A phased plan opened PRs during build | Gates and publishes one delivery slice at a time | Slice-state and bypass tests | | A worktree had the hook but not its ignored helper | Restores the verified local runtime before judging the command | Linked-worktree and tamper tests | [Read what happened, what is tested, and what remains open](docs/why-these-steps.md). The [claim record](docs/public-claims.json) keeps every material statement tied to its sources. diff --git a/UPSTREAM.json b/UPSTREAM.json index d37f9d8..7053111 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 33244, - "estimated_tokens": 8311, + "characters": 36016, + "estimated_tokens": 9004, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,12 +12,12 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "3c4bc4eb4d2e76c4dda310927f33892d7ebe5c2a9ad1b24928b1459ed6495229", - "README.md": "fe52800af55f9867f3a7dfeafd17e2ebc3cca6c19a8f787c7eaf6d27f25028fc", + "CONTRIBUTING.md": "e1f50278dcd1aa38b8d8ea162b760dafc1283b325f3769049c9b350abf93bafb", + "README.md": "ebd2c064401feeb2a7381775f9155285997d196a30ad5f4c59c2f3bbe6e14419", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "c46e935f06fcfde3b37abfd579c1963b765b2337a0fa993f9538c9b652297e39", "assets/boatstack-portability.svg": "38c61b51959ebf5378b1857cdaacfae843a5ffc35350685fc6d1d2b8b9e165a3", - "boatstack/SKILL.md": "5c37ec90eb8c3eae60f435f5d62afd1dc826f8bca6916726588379bcb99f3f58", + "boatstack/SKILL.md": "2c4547c504ebac01b14fe6cbed97b5ea114b685cb9b43b68ffa053ebe0aeb0c4", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", "boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5", @@ -26,35 +26,37 @@ "boatstack/assets/templates/gaps.md": "911cc2f086104d35071b952950c2ec44258641419f10b2355c594f33eb492cbe", "boatstack/assets/templates/move.md": "91bfd9a9b9426ac023eb88fd19f4f638190481c1855f1239acc73830528e50f0", "boatstack/assets/templates/plan-lock.json": "a51e17bb74aa7cd95daaa70fab646a20374ff4bc1d63468d61c5119da61e930f", - "boatstack/assets/templates/plan.md": "3c573cdee094cf8fb134f9ea72d103a1f84c6b561e0cc2925fce16baec5574ec", + "boatstack/assets/templates/plan.md": "1c7d5802b67d674c13bee51a028c5ba933d8b9bfbb32e894af66338f3f4040f5", "boatstack/assets/templates/questions.md": "5875bbfc32d5a1b326c2a48da7449bb90c87f462a4c3a862173247e5f7ea6415", "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/cmd/boatstack-helper/main.go": "32d73f6ac8e87cbe7ebd8ee74e63dfe6f0b8b35da1d3200e23dcf011be9c19ea", - "boatstack/export.go": "c53c5ec83dcea392d2e360c6819202b5f7cf9b3ae64510087bd627c4aaee82e3", - "boatstack/export_test.go": "da9b04e3e03c10f4095ac339d303c1699df3a0b3d68158b09746769c04031f5b", + "boatstack/cmd/boatstack-helper/main.go": "eb618ae01a7950276b347bc379ebd2b7be257ba9a09470ca8129cc32732bec96", + "boatstack/delivery.go": "bfdce7dd3bc1357a614bd458f2f6b4b8570015c117d1f7638c7a1bf3110e1a48", + "boatstack/delivery_test.go": "a8a5a7e6e8dcfee1538367d49c76c531e04211876c1685265884cff26ae04497", + "boatstack/export.go": "ce583075b0edce83ceeed4eed4184f7f92603a21cce87ff95854961206ebd3a2", + "boatstack/export_test.go": "ee5cac13fcd41bea22ee7a96baaa8f53732b3e51ecf9f7a3fad16efa46679196", "boatstack/go.mod": "57c377eccea51372d6664de4169e2ca45806b046f7e8a98a1e35a9eb454b4b8d", - "boatstack/hooks.go": "aed9cee6d3e3fb42e5e98288eade420793a07a4b071e78a613fc888c74521406", + "boatstack/hooks.go": "cf1959f5b6594853180f463dcddb0a6b1aee3e1408a7e44b063abe9ac22f1c56", "boatstack/hooks_test.go": "a5298b7f46709bce617913085fe3b597a4bb4f730a5b5b51f459be85adbefcbb", "boatstack/init.go": "ddee0cfd8ab9f3416fb895afd99d1682ca09550d2d9b2ad3cd11f5e06438c585", "boatstack/init_test.go": "b761ada1f5a04c0a27225a6f1eb99baf5477a424c5a9748a3267f07ba5a84605", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", - "boatstack/plan.go": "519fe7a782c0384d62fda228c58145d36e01a3941691b1839a2d1476528c27b7", + "boatstack/plan.go": "d83037a9f06f927427a59ea936bbee4041ea9276dd84992599c16ef6259b2d4f", "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", "boatstack/planning.go": "3a26417a295e5dfc2b6dcac702287c04b6053e7b74215858a4ea11cf9f9dadfe", "boatstack/planning_test.go": "4662908c1ec063aa8ef6f91db52247864303d9b91ef2363a8f68b41082fe383f", - "boatstack/pr.go": "6fbfd1e673c55e8358090693b20edb9bc6e8efb8913de9ffcaa5143ca24f0947", - "boatstack/pr_test.go": "f200a3a860e3da22798ec17a8eaf335724885b09d48b36bf0c350acdd3cc3ab7", - "boatstack/references/artifacts.md": "0a72961aa7a942056f3185417f545d205a3ce21856e602e367141660298c9410", + "boatstack/pr.go": "2af70c81108288cd9cd319f7100d1abd957e7e84f8db1474afa51a8b74e9d442", + "boatstack/pr_test.go": "74afb1a9be3c4a95a426515be415149ae24b077454b486d8e69b7118c42f2916", + "boatstack/references/artifacts.md": "3a87b8f8d835329191bedb20c94e6056fe2251b3e9d510656909eb9d276069dc", "boatstack/references/failure-moves.md": "1d35126348d0b681976e8819665e16fd745fd65eca271492603cb80aab75bf49", "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "0a32f00c12ea1d92db2e3b29ce5cfdcdd5a013c50ae67f2fc56f0ebde6951988", + "boatstack/references/workflow.md": "9430d093d89da88890993785ed0f1ec9ff328221940e3e4fdaaf4d91c41b994b", "boatstack/runtime.go": "b988d57ec14e15fc6a57949a995879fc0e0d6bfa9a7b62935e7754df0b85d87a", "boatstack/runtime_cache.go": "ab0fbb7f8a2eb8d8428e928fbc3d8866c84104e7cb330a901c121dfec82cddb3", "boatstack/runtime_cache_test.go": "4cbca9dec7800d7df6e3ec0d74c7ecbe1508e5c5a288d863f35fc8d22986c308", - "boatstack/safety.go": "fbf30c34642db6ac18e0e15abbf78cbcd9177cc7aa678b44b4eaabc0202f5bd5", + "boatstack/safety.go": "0921f53df1d86470ef2b0f627f43829d2a7e73c0e5ff0b4195aaaf576aea9325", "boatstack/safety_test.go": "62375fd640d543ab8875c7b31fd935ac7f5385830f625123f44508e629b4ff08", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", @@ -64,22 +66,22 @@ "docs/account-recovery-walkthrough.md": "acd3558a95f48004f18a0590670de496e1cc9f0cd1d187f924615497f57e1d6f", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "ad02a921529735ab6733600ac4e002a9cb4645bbc9af3f3ed61ad5b47e64d4db", + "docs/evidence-engineered-coding.md": "d12239081a9e6486cf7e8354321afa197fe94c194d29469b614e905440788869", "docs/generated-files.md": "7b2e8c10a35aa351fb87753492ed3cadb05011002d2fd6ffeb1951c356f6b286", - "docs/getting-started.md": "9741947c4b072c0838d0ee3a578215d5fac1d72cf5e73075136d0a9c95db2bbb", - "docs/public-claims.json": "d72b759c44c016cf3601ccea67b993dd81fc906224f49c8e0f55d31d847a6f94", + "docs/getting-started.md": "9ecd9e543862e4bcf139be52de113c548708a2a5707c3c09233fd1605eda86d0", + "docs/public-claims.json": "cf2c6fdc04e25d72a59fa87d5549ca02bc4c01bd3bb971bb5eed3ad4c61a36df", "docs/public-surface.md": "422696611bdd52fbac2baf6723a277fa8c451601a9f66774bde1529efdc2abab", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "27e73986a30df5d011b33c3d4701ce30610359e32c496c50d1b783ef5adf5c69", - "docs/validation-and-evidence.md": "a9fe9274f3dc22b152094a307feda5d8c3ab099755100aef77bda13024cc3166", - "docs/why-these-steps.md": "afacdcd78b546c50a0b2a268233f8f86548024298196dcfc7b573773fd5f23e7", + "docs/troubleshooting.md": "a3314d0eb97643534415f3c19f1763fd80ff6bb5e98c53b646e447e0b31c4829", + "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", + "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", "examples/diagram-json/README.md": "061b583180e43bbd26618bbd9d3d79af4b75d7c8f37c66475640745a97328fbc", "examples/diagram-json/approval.md": "bc421a825349923512d5cb0ce489310d3a4d7cbac35e661a693b4a32eec263d1", "examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896", "examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf", - "examples/diagram-json/plan.lock.json": "a1907589972d8f22bd9bef3adfc9bab2ef81b9827553d6c2f02bb15948311c27", + "examples/diagram-json/plan.lock.json": "663ae19ea715dbd04e3d553c5af615241e3f0ab98d8ba1ffd7a83dcd11c96471", "examples/diagram-json/plan.md": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76", "examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -88,12 +90,13 @@ "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", "install.sh": "939e604aa153b454e7fa1cbbe50a88be17782ea9df35d1bbb5615c737ed6f86e", "project.example.json": "d1f7aa3cff0b55ede79500bd2ca710bb99cb2ae579f0a058dc00934accf03d33", + "release-notes/2026-07-17-phase-scoped-delivery.md": "bfc8edd30a67daf5940ad4ceceebf81d01d62914ebeabb7073985c53f13df2ff", "release-notes/2026-07-17-visible-release-messages.md": "c93e8c812528a983502263e35d66c86a265d6ccba8203f393788c069b3fa6606" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "bab232ddd34b39e9e480131a3c967c723f831232", + "commit": "16e771775417be997b7fc17e1b1b28d700780899", "path": "examples/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index f850f60..7ed50a5 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -14,7 +14,7 @@ Map the request to one operation: - `init`: inspect a repository and create or update `.product-loop/project.json`. - `auto-plan`: refine a saved host Plan-mode file into a reviewable draft feature package; refuse when that file is absent. - `plan-gate`: validate the Markdown draft, present it for explicit human acceptance, and record that acceptance in Markdown. -- `build`: activate the approved Markdown plan, then implement its tasks in bounded, reversible slices. +- `build`: activate the approved Markdown plan, then implement only the active delivery slice's tasks. - `test-gate`: test requirements and relevant regressions using independent evidence. - `review-gate`: review the diff against the spec, project invariants, risks, and known gaps. - `ship-gate`: preview, then explicitly open or update, a reviewer-ready PR grounded in the approved diff and evidence. @@ -79,7 +79,8 @@ Normal approval is simply `approve`. Use an explicit supplied identity first; ot 10. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`. 11. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names. 12. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Put its authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan /plan.md`; this command is read-only. If the host blocks its ordinary Markdown writer, pass the document to `boatstack-helper planning-write --repo . --feature --artifact ` on stdin. Never use arbitrary shell redirection to evade a host write boundary. -13. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`. +13. Keep implementation tasks separate from publication authority. Internal phases remain tasks inside one delivery slice. When the accepted outcome explicitly requires multiple PRs, declare ordered `delivery_slices`; assign every task exactly once and give each slice its own optional base/head branch contract. Plan approval approves this structure but never authorizes a push or PR. +14. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`. Do not treat an ADR as general project context. ADRs record accepted durable decisions. Use a question ledger for unknowns and a gap ledger for known divergence. @@ -116,9 +117,11 @@ All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gs ``` - Activation verifies the approval fingerprint, compiles `tasks.json`, `test-matrix.json`, and the evidence skeleton, writes the content-addressed lock last, and rechecks it. It adds no semantics. Missing approval, open blocking questions, or any change to the source plan, spec, or complete `plan.md` returns `BLOCKED`. +- Activation also creates ignored delivery state bound to the plan lock. Read it with `delivery-status`; implement only the active slice's `task_ids`. A multi-slice plan advances only after the current slice publishes through `ship-gate`. - Keep the source plan present and hash-current through completion of `build`. - Choose any suitable model, tool, or implementation tactic inside the approved boundary. Boatstack controls transitions and claims, not local creativity. - Work from approved tasks and acceptance criteria. +- Never push, open, update, ready, or merge a PR during `build`. The host hook denies direct publication while managed delivery is active; publication is reachable only through the confirmed `ship-gate` publisher. - Preserve the last known-good state; repair locally instead of restarting a near-correct implementation. - Re-scope context at task boundaries. Include relevant source, interfaces, invariants, and tests—not arbitrary history. - Stop and ask when implementation exposes a new product decision or a high-impact irreversible choice. @@ -137,6 +140,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. - Treat model-authored tests and same-model self-review as evidence, not ground truth. - Validate that tests load and exercise the intended interface. For high-risk code, add an independent oracle such as contract fixtures, mutation testing, differential checks, staging verification, or human acceptance. - A failing check blocks the gate. A skipped check must include a reason and risk owner. +- Commit the intentional active-slice product and evidence diff, then record the test result with `record-delivery-gate --feature --slice --gate test`. The receipt is bound to the base/head branches, commit, product diff, and evidence hash. Editing an evidence status is not a gate transition. ### Review gate @@ -144,6 +148,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. - Check spec traceability, invariants, data/security/tenancy boundaries, failure behavior, backward compatibility, migrations, observability, tests, docs, and gaps. - Use an independent reviewer for high-risk changes, repeated failures, or when the existing review evidence is circular. - Convert actionable findings into tasks. Do not pass while critical findings are open. +- On pass, record `record-delivery-gate --feature --slice --gate review`. Review is rejected unless the same diff already has a test receipt; any later product change makes both receipts stale. ### Ship gate @@ -156,6 +161,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. - Inspect the projected changed files, diff stat, high-risk matches, and actual diff before composing the brief. Commit messages are navigation aids, not proof of what changed. - Show the exact title and rendered body before any GitHub mutation. If no PR exists, make `Reply open PR` the one next action; if one exists, use `Reply update PR`. - After that exact confirmation, commit only the reviewed `pr.md`, rerun the preview check, require the same preview fingerprint, then invoke the internal publisher with the selected open/update action. It rechecks the current committed diff, approval, lock, and evidence and performs only a normal push. Any intervening change invalidates the preview and requires regeneration; never force-push. +- The publisher additionally requires current test and review receipts for the active slice. Successful publication marks only that slice published and activates the next slice. Plan approval, a prose phase label, or a previous slice's receipts cannot authorize a later slice. - Keep model attribution inside collapsed provenance. Create or update the PR, but keep merge and deploy as separate authorized actions. - Only after successful PR publication, perform the bounded cached release check. If a newer stable Boatstack release should be announced, keep `Review the PR` as the one next action and put the no-mutation update notice in collapsed details. Release lookup failure never changes the ship result. - Never hide failed experiments, skipped checks, or `PASS_WITH_GAPS` behind a green summary. diff --git a/boatstack/assets/templates/plan.md b/boatstack/assets/templates/plan.md index 18d52ec..2808a88 100644 --- a/boatstack/assets/templates/plan.md +++ b/boatstack/assets/templates/plan.md @@ -43,6 +43,15 @@ ], "rollback_boundary": "" } + ], + "delivery_slices": [ + { + "id": "delivery", + "title": "", + "task_ids": ["T-1"], + "base_branch": "", + "head_branch": "" + } ] } ``` @@ -62,3 +71,9 @@ For an external write, replace the empty `side_effects` list with entries such a Boatstack rejects ambiguous targets, automated resets, and destructive rollback. Use `stop-and-fix-forward` when a transaction cannot contain the full operation. + +`delivery_slices` is the only place a plan may declare multiple PR-sized phases. +Every task belongs to exactly one delivery slice. Each slice receives its own +build, test, review, ship confirmation, and PR; plan approval never authorizes +publication by itself. Internal implementation phases should remain ordinary tasks +inside one delivery slice. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 8dc22bd..96086e8 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -226,6 +226,52 @@ func recordApprovalCommand(arguments []string) int { return 0 } +func recordDeliveryGateCommand(arguments []string) int { + flags := flag.NewFlagSet("record-delivery-gate", flag.ContinueOnError) + options := boatstack.DeliveryGateOptions{} + flags.StringVar(&options.Repo, "repo", ".", "repository containing the managed delivery") + flags.StringVar(&options.Feature, "feature", "", "managed Boatstack feature slug") + flags.StringVar(&options.SliceID, "slice", "", "active delivery slice id") + flags.StringVar(&options.Gate, "gate", "", "test or review") + flags.StringVar(&options.Status, "status", "", "PASS or PASS_WITH_GAPS") + flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch; defaults from the active slice or project") + flags.StringVar(&options.EvidencePath, "evidence", "", "current evidence ledger") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if options.Feature == "" || options.SliceID == "" || options.Gate == "" || options.Status == "" { + return fail(fmt.Errorf("record-delivery-gate requires --feature, --slice, --gate, and --status")) + } + receipt, err := boatstack.RecordDeliveryGate(options) + if err != nil { + return fail(err) + } + fmt.Printf("PASS: %s gate recorded for delivery slice %s\nSLICE=%s\nGATE=%s\nSTATUS=%s\nHEAD_COMMIT=%s\nDIFF_SHA256=%s\n", strings.ToUpper(receipt.Gate), receipt.SliceID, receipt.SliceID, receipt.Gate, receipt.Status, receipt.HeadCommit, receipt.DiffSHA256) + return 0 +} + +func deliveryStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("delivery-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the managed delivery") + feature := flags.String("feature", "", "managed Boatstack feature slug") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *feature == "" { + return fail(fmt.Errorf("delivery-status requires --feature")) + } + state, err := boatstack.CurrentDeliveryState(*repo, *feature) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(state) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + return 0 +} + func doctorCommand(arguments []string) int { flags := flag.NewFlagSet("doctor", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Boatstack installation should be checked") @@ -306,12 +352,13 @@ func prContextCommand(arguments []string) int { flags := flag.NewFlagSet("pr-context", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose branch should be projected") feature := flags.String("feature", "", "managed Boatstack feature slug; omit for evidence-limited ad-hoc mode") + slice := flags.String("slice", "", "active managed delivery slice") base := flags.String("base", "", "base branch; defaults to the Boatstack project configuration") format := flags.String("format", "json", "json or template") if err := flags.Parse(arguments); err != nil { return 2 } - context, err := boatstack.PreparePRContext(boatstack.PRContextOptions{Repo: *repo, Feature: *feature, Base: *base}) + context, err := boatstack.PreparePRContext(boatstack.PRContextOptions{Repo: *repo, Feature: *feature, SliceID: *slice, Base: *base}) if err != nil { return fail(err) } @@ -387,7 +434,7 @@ func publishPRCommand(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] { @@ -409,6 +456,10 @@ func run() int { return recordApprovalCommand(os.Args[2:]) case "activate-plan": return activatePlanCommand(os.Args[2:]) + case "delivery-status": + return deliveryStatusCommand(os.Args[2:]) + case "record-delivery-gate": + return recordDeliveryGateCommand(os.Args[2:]) case "pr-context": return prContextCommand(os.Args[2:]) case "check-pr": diff --git a/boatstack/delivery.go b/boatstack/delivery.go new file mode 100644 index 0000000..e3d9658 --- /dev/null +++ b/boatstack/delivery.go @@ -0,0 +1,589 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +const deliveryStateSchemaVersion = 1 + +type DeliverySlice struct { + ID string `json:"id"` + Title string `json:"title"` + TaskIDs []string `json:"task_ids"` + AcceptanceCriteria []string `json:"acceptance_criteria"` + AffectedPaths []string `json:"affected_paths,omitempty"` + Status string `json:"status"` + BaseBranch string `json:"base_branch,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + PRURL string `json:"pr_url,omitempty"` +} + +type DeliveryState struct { + SchemaVersion int `json:"schema_version"` + Feature string `json:"feature"` + PlanLockHash string `json:"plan_lock_sha256"` + ActiveIndex int `json:"active_index"` + Slices []DeliverySlice `json:"slices"` +} + +type DeliveryGateReceipt struct { + SchemaVersion int `json:"schema_version"` + Feature string `json:"feature"` + SliceID string `json:"slice_id"` + Gate string `json:"gate"` + Status string `json:"status"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + HeadCommit string `json:"head_commit"` + DiffSHA256 string `json:"diff_sha256"` + EvidencePath string `json:"evidence_path"` + EvidenceHash string `json:"evidence_sha256"` + RecordedAt string `json:"recorded_at"` +} + +type DeliveryGateOptions struct { + Repo string + Feature string + SliceID string + Gate string + Status string + BaseBranch string + EvidencePath string +} + +func deliveryEvidenceGateStatus(value, gate, sliceID string, explicit bool) string { + if !explicit { + return evidenceGateStatus(value, gate) + } + pattern := regexp.MustCompile(`(?mi)^\s*-\s*` + regexp.QuoteMeta(gate) + `\s+gate\s*\(\s*` + regexp.QuoteMeta(sliceID) + `\s*\)\s*:\s*` + "`?" + `([A-Z_]+)` + "`?" + `\s*$`) + if match := pattern.FindStringSubmatch(value); len(match) == 2 { + return strings.ToUpper(match[1]) + } + return "" +} + +func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { + tasks, _ := objectSlice(plan["tasks"]) + if plan["delivery_slices"] == nil { + taskIDs := make([]string, 0, len(tasks)) + criteria := []string{} + seenCriteria := map[string]bool{} + for _, task := range tasks { + taskIDs = append(taskIDs, stringValue(task["id"])) + mapped, _ := stringSlice(task["acceptance_criteria"]) + for _, criterion := range mapped { + if !seenCriteria[criterion] { + seenCriteria[criterion] = true + criteria = append(criteria, criterion) + } + } + } + return []DeliverySlice{{ID: "delivery", Title: "Feature delivery", TaskIDs: taskIDs, AcceptanceCriteria: criteria, Status: "BUILD"}}, nil + } + items, ok := objectSlice(plan["delivery_slices"]) + if !ok || len(items) == 0 { + return nil, fmt.Errorf("delivery_slices must be a non-empty list") + } + taskIDs := map[string]bool{} + taskCriteria := map[string][]string{} + taskPaths := map[string][]string{} + for _, task := range tasks { + id := stringValue(task["id"]) + taskIDs[id] = true + taskCriteria[id], _ = stringSlice(task["acceptance_criteria"]) + taskPaths[id], _ = stringSlice(task["affected_paths"]) + } + seenSlices := map[string]bool{} + assigned := map[string]string{} + result := make([]DeliverySlice, 0, len(items)) + for index, item := range items { + id := strings.TrimSpace(stringValue(item["id"])) + if !featureSlugPattern.MatchString(id) || seenSlices[id] { + return nil, fmt.Errorf("delivery slice ids must be unique lowercase kebab-case values") + } + seenSlices[id] = true + title := strings.TrimSpace(stringValue(item["title"])) + if title == "" { + return nil, fmt.Errorf("delivery slice %s requires a title", id) + } + mapped, mappedOK := stringSlice(item["task_ids"]) + if !mappedOK || len(mapped) == 0 { + return nil, fmt.Errorf("delivery slice %s requires task_ids", id) + } + criteria := []string{} + affectedPaths := []string{} + seenCriteria := map[string]bool{} + seenPaths := map[string]bool{} + for _, taskID := range mapped { + if !taskIDs[taskID] { + return nil, fmt.Errorf("delivery slice %s maps unknown task %s", id, taskID) + } + if owner := assigned[taskID]; owner != "" { + return nil, fmt.Errorf("task %s is assigned to delivery slices %s and %s", taskID, owner, id) + } + assigned[taskID] = id + if len(taskPaths[taskID]) == 0 { + return nil, fmt.Errorf("task %s in explicit delivery slice %s requires affected_paths", taskID, id) + } + for _, path := range taskPaths[taskID] { + if !seenPaths[path] { + seenPaths[path] = true + affectedPaths = append(affectedPaths, path) + } + } + for _, criterion := range taskCriteria[taskID] { + if !seenCriteria[criterion] { + seenCriteria[criterion] = true + criteria = append(criteria, criterion) + } + } + } + result = append(result, DeliverySlice{ + ID: id, Title: title, TaskIDs: mapped, AcceptanceCriteria: criteria, AffectedPaths: affectedPaths, + Status: "PENDING", BaseBranch: strings.TrimSpace(stringValue(item["base_branch"])), + HeadBranch: strings.TrimSpace(stringValue(item["head_branch"])), + }) + if index == 0 { + result[index].Status = "BUILD" + } + } + unassigned := []string{} + for taskID := range taskIDs { + if assigned[taskID] == "" { + unassigned = append(unassigned, taskID) + } + } + if len(unassigned) > 0 { + sort.Strings(unassigned) + return nil, fmt.Errorf("tasks missing a delivery slice: %s", strings.Join(unassigned, ", ")) + } + sliceIndex := map[string]int{} + for index, slice := range result { + sliceIndex[slice.ID] = index + } + for _, task := range tasks { + taskID := stringValue(task["id"]) + dependencies, _ := stringSlice(task["depends_on"]) + for _, dependency := range dependencies { + if sliceIndex[assigned[dependency]] > sliceIndex[assigned[taskID]] { + return nil, fmt.Errorf("task %s in delivery slice %s depends on future slice task %s", taskID, assigned[taskID], dependency) + } + } + } + return result, nil +} + +func deliveryStateDirectory(repo string) (string, error) { + gitDirectory := gitOutput(repo, "rev-parse", "--path-format=absolute", "--git-dir") + if gitDirectory == "" { + gitDirectory = gitOutput(repo, "rev-parse", "--git-dir") + } + if gitDirectory == "" { + return "", fmt.Errorf("cannot resolve the Git worktree directory") + } + if !filepath.IsAbs(gitDirectory) { + gitDirectory = filepath.Join(repo, gitDirectory) + } + return filepath.Join(filepath.Clean(gitDirectory), "boatstack", "deliveries"), nil +} + +func deliveryStatePath(repo, feature string) (string, error) { + if !featureSlugPattern.MatchString(feature) { + return "", fmt.Errorf("delivery state requires a lowercase kebab-case feature") + } + directory, err := deliveryStateDirectory(repo) + if err != nil { + return "", err + } + return filepath.Join(directory, feature, "state.json"), nil +} + +func deliveryReceiptPath(repo, feature, sliceID, gate string) (string, error) { + statePath, err := deliveryStatePath(repo, feature) + if err != nil { + return "", err + } + if !featureSlugPattern.MatchString(sliceID) || (gate != "test" && gate != "review") { + return "", fmt.Errorf("invalid delivery receipt identity") + } + return filepath.Join(filepath.Dir(statePath), "receipts", sliceID, gate+".json"), nil +} + +func saveDeliveryState(repo string, state DeliveryState) error { + path, err := deliveryStatePath(repo, state.Feature) + if err != nil { + return err + } + value, err := MarshalJSON(state) + if err != nil { + return err + } + return atomicWriteMode(path, value, 0o644) +} + +func LoadDeliveryState(repo, feature string) (DeliveryState, error) { + path, err := deliveryStatePath(repo, feature) + if err != nil { + return DeliveryState{}, err + } + value, err := os.ReadFile(path) + if err != nil { + return DeliveryState{}, fmt.Errorf("managed delivery state is missing: %w", err) + } + var state DeliveryState + if err := json.Unmarshal(value, &state); err != nil { + return DeliveryState{}, fmt.Errorf("managed delivery state is invalid: %w", err) + } + if state.SchemaVersion != deliveryStateSchemaVersion || state.Feature != feature || len(state.Slices) == 0 || state.ActiveIndex < 0 || state.ActiveIndex > len(state.Slices) { + return DeliveryState{}, fmt.Errorf("managed delivery state is invalid") + } + return state, nil +} + +func initializeDeliveryState(repo, feature, planPath, lockPath string) error { + plan, err := LoadPlan(planPath) + if err != nil { + return err + } + slices, err := deliveryDefinitions(plan) + if err != nil { + return err + } + lockHash, err := SHA256File(lockPath) + if err != nil { + return err + } + if existing, loadErr := LoadDeliveryState(repo, feature); loadErr == nil && existing.PlanLockHash == lockHash { + return nil + } + return saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: feature, PlanLockHash: lockHash, + ActiveIndex: 0, Slices: slices, + }) +} + +func activeDeliverySlice(state DeliveryState) (DeliverySlice, error) { + if state.ActiveIndex >= len(state.Slices) { + return DeliverySlice{}, fmt.Errorf("all delivery slices are already published") + } + return state.Slices[state.ActiveIndex], nil +} + +func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error { + lockPath := filepath.Join(repo, ".product-loop", "features", feature, "plan.lock.json") + lockHash, err := SHA256File(lockPath) + if err != nil { + return fmt.Errorf("managed delivery requires its current plan lock: %w", err) + } + if state.PlanLockHash == "" || state.PlanLockHash != lockHash { + return fmt.Errorf("managed delivery state is stale for the current plan lock; reactivate the approved plan") + } + return nil +} + +func CurrentDeliveryState(repoPath, feature string) (DeliveryState, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return DeliveryState{}, err + } + state, err := LoadDeliveryState(repo, feature) + if err != nil { + return DeliveryState{}, err + } + if err := checkDeliveryPlanLock(repo, feature, state); err != nil { + return DeliveryState{}, err + } + return state, nil +} + +func currentDiffIdentity(repo, base, previewPath string) (string, string, string, []string, error) { + head, err := gitCommand(repo, "branch", "--show-current") + if err != nil || head == "" { + return "", "", "", nil, fmt.Errorf("delivery gate requires a named branch") + } + baseCommit, err := resolveBaseCommit(repo, base) + if err != nil { + return "", "", "", nil, err + } + mergeBase, err := gitCommand(repo, "merge-base", baseCommit, "HEAD") + if err != nil || mergeBase == "" { + return "", "", "", nil, fmt.Errorf("cannot determine delivery diff against %s", base) + } + diff, changed, err := productDiff(repo, mergeBase, previewPath) + if err != nil { + return "", "", "", nil, err + } + if len(changed) == 0 { + return "", "", "", nil, fmt.Errorf("delivery slice has no committed changes relative to %s", base) + } + headCommit, err := gitCommand(repo, "rev-parse", "HEAD") + if err != nil { + return "", "", "", nil, err + } + return head, headCommit, SHA256Bytes(diff), changed, nil +} + +func pathMatchesDeliveryScope(path string, patterns []string) bool { + path = filepath.ToSlash(path) + for _, pattern := range patterns { + pattern = filepath.ToSlash(strings.TrimSpace(pattern)) + if pattern == "" { + continue + } + if pattern == "**" || pattern == "*" { + return true + } + if strings.HasSuffix(pattern, "/**") { + root := strings.TrimSuffix(pattern, "/**") + if path == root || strings.HasPrefix(path, root+"/") { + return true + } + } + prefix := strings.TrimSuffix(pattern, "/") + matched, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path)) + if matched || path == prefix || strings.HasPrefix(path, prefix+"/") { + return true + } + } + return false +} + +func validateDeliveryScope(feature string, slice DeliverySlice, changed []string) error { + if len(slice.AffectedPaths) == 0 { + return nil + } + unexpected := []string{} + artifactPrefix := ".product-loop/features/" + feature + "/" + for _, path := range changed { + path = filepath.ToSlash(path) + if strings.HasPrefix(path, artifactPrefix) || pathMatchesDeliveryScope(path, slice.AffectedPaths) { + continue + } + unexpected = append(unexpected, path) + } + if len(unexpected) > 0 { + sort.Strings(unexpected) + return fmt.Errorf("delivery slice %s contains changes outside its affected_paths: %s", slice.ID, strings.Join(unexpected, ", ")) + } + return nil +} + +func readDeliveryReceipt(repo, feature, sliceID, gate string) (DeliveryGateReceipt, error) { + path, err := deliveryReceiptPath(repo, feature, sliceID, gate) + if err != nil { + return DeliveryGateReceipt{}, err + } + value, err := os.ReadFile(path) + if err != nil { + return DeliveryGateReceipt{}, fmt.Errorf("%s gate receipt is missing for delivery slice %s", gate, sliceID) + } + var receipt DeliveryGateReceipt + if err := json.Unmarshal(value, &receipt); err != nil || receipt.SchemaVersion != deliveryStateSchemaVersion || receipt.Feature != feature || receipt.SliceID != sliceID || receipt.Gate != gate { + return DeliveryGateReceipt{}, fmt.Errorf("%s gate receipt is invalid for delivery slice %s", gate, sliceID) + } + return receipt, nil +} + +func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return DeliveryGateReceipt{}, err + } + gate := strings.ToLower(strings.TrimSpace(options.Gate)) + status := strings.ToUpper(strings.TrimSpace(options.Status)) + if gate != "test" && gate != "review" { + return DeliveryGateReceipt{}, fmt.Errorf("delivery gate must be test or review") + } + if status != "PASS" && status != "PASS_WITH_GAPS" { + return DeliveryGateReceipt{}, fmt.Errorf("a delivery gate receipt may record only PASS or PASS_WITH_GAPS") + } + state, err := LoadDeliveryState(repo, options.Feature) + if err != nil { + return DeliveryGateReceipt{}, err + } + if err := checkDeliveryPlanLock(repo, options.Feature, state); err != nil { + return DeliveryGateReceipt{}, err + } + slice, err := activeDeliverySlice(state) + if err != nil { + return DeliveryGateReceipt{}, err + } + if options.SliceID != "" && options.SliceID != slice.ID { + return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID) + } + base := strings.TrimSpace(options.BaseBranch) + if base == "" { + base = slice.BaseBranch + } + if base == "" { + base = defaultPRBase(repo) + } + previewPath, _ := expectedPRPreviewPath("managed", options.Feature, "") + head, headCommit, diffHash, changed, err := currentDiffIdentity(repo, base, previewPath) + if err != nil { + return DeliveryGateReceipt{}, err + } + if err := validateDeliveryScope(options.Feature, slice, changed); err != nil { + return DeliveryGateReceipt{}, err + } + if slice.HeadBranch != "" && slice.HeadBranch != head { + return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head) + } + if gate == "review" { + if slice.Status != "TEST_PASSED" { + return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s must pass its test gate before review", slice.ID) + } + testReceipt, receiptErr := readDeliveryReceipt(repo, options.Feature, slice.ID, "test") + if receiptErr != nil { + return DeliveryGateReceipt{}, receiptErr + } + if testReceipt.HeadCommit != headCommit || testReceipt.DiffSHA256 != diffHash || testReceipt.BaseBranch != base { + return DeliveryGateReceipt{}, fmt.Errorf("delivery diff changed after the test gate; rerun test-gate for slice %s", slice.ID) + } + } + evidencePath := strings.TrimSpace(options.EvidencePath) + if evidencePath == "" { + evidencePath = filepath.Join(repo, ".product-loop", "features", options.Feature, "evidence.md") + } else if !filepath.IsAbs(evidencePath) { + evidencePath = filepath.Join(repo, evidencePath) + } + if resolved, resolveErr := filepath.EvalSymlinks(evidencePath); resolveErr == nil { + evidencePath = resolved + } + evidenceHash, err := SHA256File(evidencePath) + if err != nil { + return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires current evidence: %w", err) + } + evidenceValue, err := os.ReadFile(evidencePath) + if err != nil { + return DeliveryGateReceipt{}, err + } + explicit := len(state.Slices) > 1 || state.Slices[0].ID != "delivery" + gateLabel := strings.ToUpper(gate[:1]) + gate[1:] + if recorded := deliveryEvidenceGateStatus(string(evidenceValue), gateLabel, slice.ID, explicit); recorded != status { + return DeliveryGateReceipt{}, fmt.Errorf("evidence ledger must mark the %s gate for delivery slice %s as %s; found %q", gate, slice.ID, status, recorded) + } + relEvidence, err := repositoryRelativePath(repo, evidencePath) + if err != nil { + return DeliveryGateReceipt{}, err + } + receipt := DeliveryGateReceipt{ + SchemaVersion: deliveryStateSchemaVersion, Feature: options.Feature, SliceID: slice.ID, + Gate: gate, Status: status, BaseBranch: base, HeadBranch: head, HeadCommit: headCommit, + DiffSHA256: diffHash, EvidencePath: relEvidence, EvidenceHash: evidenceHash, + RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), + } + path, _ := deliveryReceiptPath(repo, options.Feature, slice.ID, gate) + value, _ := MarshalJSON(receipt) + if err := atomicWriteMode(path, value, 0o644); err != nil { + return DeliveryGateReceipt{}, err + } + state.Slices[state.ActiveIndex].BaseBranch = base + state.Slices[state.ActiveIndex].HeadBranch = head + if gate == "test" { + state.Slices[state.ActiveIndex].Status = "TEST_PASSED" + if reviewPath, pathErr := deliveryReceiptPath(repo, options.Feature, slice.ID, "review"); pathErr == nil { + _ = os.Remove(reviewPath) + } + } else { + state.Slices[state.ActiveIndex].Status = "REVIEW_PASSED" + } + if err := saveDeliveryState(repo, state); err != nil { + return DeliveryGateReceipt{}, err + } + return receipt, nil +} + +func CheckDeliveryReadyForShip(repo, feature, base, head, diffHash string, changed []string) (DeliveryState, DeliverySlice, []PRSource, error) { + state, err := LoadDeliveryState(repo, feature) + if err != nil { + return DeliveryState{}, DeliverySlice{}, nil, err + } + if err := checkDeliveryPlanLock(repo, feature, state); err != nil { + return DeliveryState{}, DeliverySlice{}, nil, err + } + slice, err := activeDeliverySlice(state) + if err != nil { + return DeliveryState{}, DeliverySlice{}, nil, err + } + if slice.Status != "REVIEW_PASSED" { + return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("delivery slice %s has not passed test and review gates", slice.ID) + } + if err := validateDeliveryScope(feature, slice, changed); err != nil { + return DeliveryState{}, DeliverySlice{}, nil, err + } + sources := []PRSource{} + for _, gate := range []string{"test", "review"} { + receipt, receiptErr := readDeliveryReceipt(repo, feature, slice.ID, gate) + if receiptErr != nil { + return DeliveryState{}, DeliverySlice{}, nil, receiptErr + } + if receipt.BaseBranch != base || receipt.HeadBranch != head || receipt.DiffSHA256 != diffHash { + return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("stale delivery receipt: diff changed after the %s gate; rerun gates for slice %s", gate, slice.ID) + } + path, _ := deliveryReceiptPath(repo, feature, slice.ID, gate) + hash, _ := SHA256File(path) + sources = append(sources, PRSource{Kind: gate + "_gate_receipt", Path: ".git/boatstack/deliveries/" + feature + "/receipts/" + slice.ID + "/" + gate + ".json", SHA256: hash}) + } + return state, slice, sources, nil +} + +func MarkDeliveryPublished(repo, feature, sliceID, url string) error { + state, err := LoadDeliveryState(repo, feature) + if err != nil { + return err + } + if err := checkDeliveryPlanLock(repo, feature, state); err != nil { + return err + } + slice, err := activeDeliverySlice(state) + if err != nil { + return err + } + if slice.ID != sliceID || slice.Status != "REVIEW_PASSED" { + return fmt.Errorf("delivery slice %s is not ready to publish", sliceID) + } + state.Slices[state.ActiveIndex].Status = "PUBLISHED" + state.Slices[state.ActiveIndex].PRURL = url + state.ActiveIndex++ + if state.ActiveIndex < len(state.Slices) { + state.Slices[state.ActiveIndex].Status = "BUILD" + } + return saveDeliveryState(repo, state) +} + +func ActiveManagedDeliveries(repo string) ([]string, error) { + directory, err := deliveryStateDirectory(repo) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(directory) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + active := []string{} + for _, entry := range entries { + if !entry.IsDir() || !featureSlugPattern.MatchString(entry.Name()) { + continue + } + state, loadErr := LoadDeliveryState(repo, entry.Name()) + if loadErr != nil { + return nil, fmt.Errorf("invalid managed delivery state for %s: %w", entry.Name(), loadErr) + } + if state.ActiveIndex < len(state.Slices) { + active = append(active, entry.Name()) + } + } + sort.Strings(active) + return active, nil +} diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go new file mode 100644 index 0000000..7653c9c --- /dev/null +++ b/boatstack/delivery_test.go @@ -0,0 +1,236 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func twoSlicePlan() map[string]any { + plan := validPlan() + plan["acceptance_criteria"] = []any{ + map[string]any{"id": "AC-1", "text": "first observable result"}, + map[string]any{"id": "AC-2", "text": "second observable result"}, + } + first := plan["tasks"].([]any)[0].(map[string]any) + first["affected_paths"] = []any{"feature.go"} + second := map[string]any{ + "id": "T-2", "title": "implement second result", "depends_on": []any{"T-1"}, + "acceptance_criteria": []any{"AC-2"}, "affected_paths": []any{"second.go"}, + "validation": []any{map[string]any{ + "criteria": []any{"AC-2"}, "run": "go test ./...", "origin": "AC-2", + "oracle": "second contract assertion", "independence": "contract-derived", + }}, + } + plan["tasks"] = []any{first, second} + plan["delivery_slices"] = []any{ + map[string]any{"id": "phase-one", "title": "First reviewer outcome", "task_ids": []any{"T-1"}}, + map[string]any{"id": "phase-two", "title": "Second reviewer outcome", "task_ids": []any{"T-2"}}, + } + return plan +} + +func TestDeliverySlicesPartitionTasksAndRejectForwardDependencies(t *testing.T) { + plan := twoSlicePlan() + if err := ValidatePlan(plan); err != nil { + t.Fatalf("valid two-slice plan rejected: %v", err) + } + plan["delivery_slices"].([]any)[1].(map[string]any)["task_ids"] = []any{"T-1", "T-2"} + if err := ValidatePlan(plan); err == nil || !strings.Contains(err.Error(), "assigned") { + t.Fatalf("duplicate task assignment did not block: %v", err) + } + plan = twoSlicePlan() + plan["tasks"].([]any)[0].(map[string]any)["depends_on"] = []any{"T-2"} + plan["tasks"].([]any)[1].(map[string]any)["depends_on"] = []any{} + if err := ValidatePlan(plan); err == nil || !strings.Contains(err.Error(), "future slice") { + t.Fatalf("forward delivery dependency did not block: %v", err) + } +} + +func activateTwoSliceDelivery(t *testing.T) (string, string) { + t.Helper() + repo := prTestRepo(t) + feature := "phased-feature" + directory := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + plan := twoSlicePlan() + plan["feature_id"] = feature + plan["spec_path"] = "feature-spec.md" + if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Two PR proposal\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "feature-spec.md"), []byte("# Accepted phased feature\n"), 0o644); err != nil { + t.Fatal(err) + } + planPath := filepath.Join(directory, "plan.md") + writeMarkdownPlan(t, planPath, plan, true) + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + approvalPath := filepath.Join(directory, "approval.md") + writeApprovalReceipt(t, approvalPath, check.Fingerprint) + if err := ActivatePlan(ActivationOptions{ + PlanPath: planPath, ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"), + OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }); err != nil { + t.Fatal(err) + } + evidence := "# Evidence ledger\n\n- Test gate (phase-one): `PASS`\n- Review gate (phase-one): `PASS`\n- Test gate (phase-two): `BLOCKED`\n- Review gate (phase-two): `BLOCKED`\n" + if err := os.WriteFile(filepath.Join(directory, "evidence.md"), []byte(evidence), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".product-loop/features/"+feature) + runGit(t, repo, "commit", "-m", "activate phased delivery") + return repo, feature +} + +func TestDeliveryGateReceiptsBindTheActiveSliceAndAdvanceOnce(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "test gate") { + t.Fatalf("review passed without a test receipt: %v", err) + } + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n\nconst PhaseOne = true\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "feature.go") + runGit(t, repo, "commit", "-m", "change phase after test") + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "changed after the test gate") { + t.Fatalf("review accepted a diff not covered by the test receipt: %v", err) + } + for _, gate := range []string{"test", "review"} { + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: gate, Status: "PASS"}); err != nil { + t.Fatalf("record %s gate: %v", gate, err) + } + } + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { + t.Fatal(err) + } + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + if state.ActiveIndex != 1 || state.Slices[0].Status != "PUBLISHED" || state.Slices[1].Status != "BUILD" { + t.Fatalf("publication advanced the wrong state: %#v", state) + } + directory := filepath.Join(repo, ".product-loop", "features", feature) + if err := ActivatePlan(ActivationOptions{ + PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"), + OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), + SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }); err != nil { + t.Fatalf("idempotent build activation failed: %v", err) + } + state, err = LoadDeliveryState(repo, feature) + if err != nil || state.ActiveIndex != 1 { + t.Fatalf("rerunning build reset delivery progress: state=%#v err=%v", state, err) + } + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "current slice is phase-two") { + t.Fatalf("prior slice receipt reused after publication: %v", err) + } +} + +func TestDeliveryGateRejectsChangesOwnedByALaterSlice(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + if err := os.WriteFile(filepath.Join(repo, "second.go"), []byte("package fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "second.go") + runGit(t, repo, "commit", "-m", "implement future slice early") + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "outside its affected_paths") { + t.Fatalf("active slice accepted a later slice's file: %v", err) + } +} + +func TestDeliveryGateRejectsStateFromAnotherPlanLock(t *testing.T) { + repo, feature := activateTwoSliceDelivery(t) + state, err := LoadDeliveryState(repo, feature) + if err != nil { + t.Fatal(err) + } + state.PlanLockHash = strings.Repeat("b", 64) + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err == nil || !strings.Contains(err.Error(), "stale for the current plan lock") { + t.Fatalf("delivery state crossed plan locks: %v", err) + } +} + +func TestManagedDeliveryHookDeniesDirectPublicationRoutes(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64), + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}}, + }); err != nil { + t.Fatal(err) + } + for _, command := range []string{ + "git push origin feature", "git -C " + repo + " push origin feature", "gh pr create --title phase-one", + "gh api repos/example/project/pulls --method POST", "hub pull-request -m phase-one", + } { + findings := ClassifyCommand(repo, command) + if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" { + t.Fatalf("direct publication was not denied for %q: %#v", command, findings) + } + } + findings := ClassifyTool(repo, "github_create_pull_request", map[string]any{"title": "phase one"}) + if len(findings) == 0 || findings[0].Category != "workflow-publication-bypass" { + t.Fatalf("GitHub tool publication was not denied: %#v", findings) + } + if findings := ClassifyCommand(repo, "git status --short"); len(findings) != 0 { + t.Fatalf("read-only Git was unexpectedly denied: %#v", findings) + } + statePath, err := deliveryStatePath(repo, "phased-feature") + if err != nil { + t.Fatal(err) + } + findings = ClassifyCommand(repo, "rm "+statePath) + if len(findings) == 0 || findings[0].Category != "workflow-state-tamper" { + t.Fatalf("direct delivery-state mutation was not denied: %#v", findings) + } + if err := os.WriteFile(statePath, []byte("{bad"), 0o644); err != nil { + t.Fatal(err) + } + findings = ClassifyCommand(repo, "git push origin feature") + if len(findings) == 0 || findings[0].Category != "workflow-state-invalid" { + t.Fatalf("corrupt delivery state failed open: %#v", findings) + } +} + +func TestManagedDeliveryStateDoesNotBlockUnrelatedWorktrees(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "README.md") + runGit(t, repo, "commit", "-m", "base") + if err := saveDeliveryState(repo, DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, Feature: "phased-feature", PlanLockHash: strings.Repeat("a", 64), + ActiveIndex: 0, Slices: []DeliverySlice{{ID: "phase-one", Title: "First", Status: "BUILD"}}, + }); err != nil { + t.Fatal(err) + } + linked := filepath.Join(t.TempDir(), "linked") + runGit(t, repo, "worktree", "add", "-b", "other-work", linked) + active, err := ActiveManagedDeliveries(linked) + if err != nil { + t.Fatal(err) + } + if len(active) != 0 { + t.Fatalf("delivery state leaked into unrelated worktree: %v", active) + } + if findings := ClassifyCommand(linked, "git push origin other-work"); len(findings) != 0 { + t.Fatalf("unrelated worktree publication was denied: %#v", findings) + } +} diff --git a/boatstack/export.go b/boatstack/export.go index 634333f..957164d 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -89,7 +89,7 @@ Run the %s operation from @.product-loop/workflow.md. Read @.product-loop/project.json, @.product-loop/artifacts.md, and only the minimal repository context relevant to the current feature. %s -Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Auto-plan and plan-gate may create or update Markdown only. Classify authoritative repository facts as DISCOVERED, agent suggestions as PROPOSED, and only explicit human responses as ANSWERED. Every material proposal remains in blocking_questions; never label an agent default as answered. If a structured question tool is unavailable, ask 1-3 plain-text questions, return WAITING_FOR_INPUT internally, and never silently choose a default. Boatstack leaves implementation tactics open, but completion, approval, and shipping claims require current evidence. +Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Auto-plan and plan-gate may create or update Markdown only. Classify authoritative repository facts as DISCOVERED, agent suggestions as PROPOSED, and only explicit human responses as ANSWERED. Every material proposal remains in blocking_questions; never label an agent default as answered. If a structured question tool is unavailable, ask 1-3 plain-text questions, return WAITING_FOR_INPUT internally, and never silently choose a default. Boatstack leaves implementation tactics open, but completion, approval, and shipping claims require current evidence. During managed delivery, read the active delivery slice, never push or mutate a PR directly, and require slice-scoped test and review receipts before ship-gate. A successful publication activates the next declared slice; parent-plan approval never skips its gates. Follow the User-facing response contract in @.product-loop/workflow.md. Lead with its mapped plain-language outcome, show only decision-relevant content, end with exactly one `+"`### Next step`"+`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed `+"`Technical details`"+`. Treat helper names in this command as internal control machinery; do not expose them in the primary response. `, operation, operation, preflight, extra) @@ -161,11 +161,11 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } operations := map[string]string{ - "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", + "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. The normal user action is simply approve. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval with Reply approve as the one next action. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", - "test-gate": "Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", - "review-gate": "Run the internal repository safety check and review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Executable destructive capability is blocking even when ordinary tests pass. On pass respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", + "review-gate": "Read delivery-status and review the active slice's actual diff against approved intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and make Reply open update PR the one next action. Only that exact reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", @@ -185,6 +185,7 @@ Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the explicit plan gate has produced approval.md and build activation has produced a valid plan lock. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. +Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. When the user asks to update Boatstack itself, use /boatstack-update. Release discovery is read-only and cached; repository mutation begins only from a clean current default branch and is isolated in a versioned chore/update-boatstack branch. Preview the exact infrastructure diff before requiring open update PR. Never mix a Boatstack update into product work or merge it automatically. Do not branch behavior on model name, provider, or price; branch on observed work state and evidence. @@ -212,6 +213,8 @@ Follow the User-facing response contract in .product-loop/workflow.md for every Ordinary product intent must first be explored in the host's Plan mode and saved as a file, preferably under .product-loop/intake/. Auto-plan runs bounded discovery before inspecting the repository and records the single result as source_plan_path. If no file exists or multiple candidates remain, auto-plan is BLOCKED; it must not guess or create a substitute. An explicit path is only an ambiguity override. Auto-plan and plan-gate write Markdown only: plan.md remains canonical and approval.md records explicit acceptance. If the host blocks its normal Markdown writer, use the bounded planning-write helper and never arbitrary shell redirection. Repository facts are DISCOVERED, agent suggestions are PROPOSED, and only human responses are ANSWERED; every material proposal remains blocking. At build, confirm the host can edit product code before activating the plan. A rejected mode transition returns READY_FOR_BUILD and creates no machine artifacts or lock. Once execution is available, activation compiles machine artifacts and the lock before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the approved lock, diff, and evidence after build. +Internal phases are ordinary tasks inside one delivery slice. Multiple PRs require explicit ordered delivery_slices with every task assigned exactly once. After activation, read delivery-status and work only on the active slice. Test-gate and review-gate must record slice-scoped receipts bound to the current branches, commit, diff, and evidence. Direct push, PR mutation, and ad-hoc PR routing are denied while managed delivery is active. Successful confirmed publication advances exactly one slice; plan approval never authorizes later slices. + Normal approval is simply approve. Use an explicit supplied identity first; otherwise use the authenticated GitHub login when the repository is on GitHub and it is available. Ask once for a name or handle only when no trustworthy identity can be resolved. Never infer the approver from a filesystem username, commit history, or the coding agent. If identity is unavailable after approve, preserve the current approval intent, create no receipt, and ask only for identity; do not require approval again when the unchanged plan and identity are available. Use .product-loop/artifacts.md for document boundaries and .product-loop/failure-moves.md for improvement experiments. If a structured question tool is unavailable, ask 1-3 plain-text questions and return WAITING_FOR_INPUT; never select defaults on the user's behalf. Do not implement from an unapproved or stale plan. Implementation tactics are open; completion, approval, and shipping claims require current evidence. Do not branch on model identity; use observable state and gate evidence. diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 99389a0..f06ae85 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -110,6 +110,12 @@ func TestExportAndDriftCheck(t *testing.T) { if !strings.Contains(build, "activate-plan") || !strings.Contains(build, "READY_FOR_BUILD") || !strings.Contains(build, "without activating") || strings.Contains(build, "compile-plan") { t.Fatal("build adapter must activate the Markdown plan exactly once") } + if !strings.Contains(build, "delivery-status") || !strings.Contains(build, "push and PR mutation are never build tactics") { + t.Fatal("build adapter does not confine work to the active delivery slice") + } + if !strings.Contains(string(bundle.Files[".cursor/commands/test-gate.md"]), "record-delivery-gate") || !strings.Contains(string(bundle.Files[".cursor/commands/review-gate.md"]), "record-delivery-gate") { + t.Fatal("test and review adapters do not record slice-scoped gate receipts") + } ship := string(bundle.Files[".cursor/commands/ship-gate.md"]) for _, expected := range []string{"separate repair PR", "Never edit unrelated code", "exact title", "Reply open PR", "Reply update PR", "preview fingerprint"} { if !strings.Contains(ship, expected) { @@ -139,6 +145,11 @@ func TestExportAndDriftCheck(t *testing.T) { } } cursorRule := string(bundle.Files[".cursor/rules/boatstack.mdc"]) + for _, expected := range []string{"delivery_slices", "active slice", "Direct push and PR mutation", "plan approval is never publication authority"} { + if !strings.Contains(cursorRule, expected) { + t.Fatalf("Cursor rule is missing phase-scoped delivery rule %q", expected) + } + } for _, expected := range []string{"naturally asks Boatstack", "evidence-limited ad-hoc PR brief", "not a /pr-brief command", "NOT_VERIFIED"} { if !strings.Contains(cursorRule, expected) { t.Fatalf("Cursor rule is missing ad-hoc PR behavior %q", expected) diff --git a/boatstack/hooks.go b/boatstack/hooks.go index 8efbfac..c658add 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -143,7 +143,7 @@ func desiredHostHookForEvent(host, event string) map[string]any { "matcher": "Bash|Shell|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), - "timeout": 10, "statusMessage": "Checking irreversible-operation policy", + "timeout": 10, "statusMessage": "Checking Boatstack execution policy", }}, } case "codex": @@ -151,7 +151,7 @@ func desiredHostHookForEvent(host, event string) map[string]any { "matcher": "Bash|Shell|mcp__.*", "hooks": []any{map[string]any{ "type": "command", "command": hookCommand(host), "commandWindows": hookCommandWindows(host), - "timeout": 10, "statusMessage": "Checking irreversible-operation policy", + "timeout": 10, "statusMessage": "Checking Boatstack execution policy", }}, } default: diff --git a/boatstack/plan.go b/boatstack/plan.go index 1689c42..7467c93 100644 --- a/boatstack/plan.go +++ b/boatstack/plan.go @@ -521,6 +521,9 @@ func ValidatePlan(plan map[string]any) error { return err } } + if _, err := deliveryDefinitions(plan); err != nil { + return err + } return nil } @@ -620,12 +623,24 @@ func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, e } criteria, _ := objectSlice(plan["acceptance_criteria"]) tasks, _ := objectSlice(plan["tasks"]) + deliverySlices, _ := deliveryDefinitions(plan) rows := make([]any, 0, len(criteria)) evidence := []string{ "# Evidence ledger: " + stringValue(plan["feature_id"]), "", "- Approved plan lock: pending", "- Test gate: `BLOCKED`", "- Review gate: `BLOCKED`", "- Ship gate: `BLOCKED`", "", - "## Acceptance evidence", "", "| Criterion | Tasks | Result | Evidence |", "|---|---|---|---|", } + if plan["delivery_slices"] != nil { + evidence = append(evidence, "## Delivery slices", "") + for _, slice := range deliverySlices { + evidence = append(evidence, + "### "+slice.ID+": "+slice.Title, "", + "- Test gate ("+slice.ID+"): `BLOCKED`", + "- Review gate ("+slice.ID+"): `BLOCKED`", + "- Ship gate ("+slice.ID+"): `BLOCKED`", "", + ) + } + } + evidence = append(evidence, "## Acceptance evidence", "", "| Criterion | Tasks | Result | Evidence |", "|---|---|---|---|") for _, criterion := range criteria { criterionID := stringValue(criterion["id"]) servingIDs := []string{} @@ -677,6 +692,15 @@ func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, e "feature_id": plan["feature_id"], "requirements": rows, } + deliveryValues := make([]any, 0, len(deliverySlices)) + for _, slice := range deliverySlices { + deliveryValues = append(deliveryValues, map[string]any{ + "id": slice.ID, "title": slice.Title, "task_ids": slice.TaskIDs, + "acceptance_criteria": slice.AcceptanceCriteria, "affected_paths": slice.AffectedPaths, + "base_branch": slice.BaseBranch, "head_branch": slice.HeadBranch, + }) + } + taskGraph["delivery_slices"] = deliveryValues return taskGraph, testMatrix, strings.Join(evidence, "\n"), nil } @@ -815,9 +839,6 @@ func ActivatePlan(options ActivationOptions) error { if safety.Status != "PASS" { return fmt.Errorf("operational diff contains an irreversible capability: %s", safety.Findings[0].Category) } - if err := CompilePlanFiles(options.PlanPath, options.OutDir); err != nil { - return err - } tasksPath := filepath.Join(options.OutDir, "tasks.json") approval := ApprovalOptions{ SourcePlanPath: check.SourcePlanPath, @@ -829,10 +850,39 @@ func ActivatePlan(options ActivationOptions) error { SourceCommit: options.SourceCommit, OutputPath: options.OutputPath, } + if fileExists(options.OutputPath) { + if err := CheckApprovalLock(approval); err == nil { + return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath) + } + value, readErr := os.ReadFile(options.OutputPath) + if readErr != nil { + return fmt.Errorf("existing plan lock cannot be verified: %w", readErr) + } + var existing map[string]any + if json.Unmarshal(value, &existing) != nil { + return fmt.Errorf("existing plan lock is unreadable; do not overwrite activation state") + } + currentPlanHash, _ := SHA256File(options.PlanPath) + currentSourceHash, _ := SHA256File(check.SourcePlanPath) + currentSpecHash, _ := SHA256File(check.SpecPath) + if stringValue(existing["plan_sha256"]) == currentPlanHash && + stringValue(existing["source_plan_sha256"]) == currentSourceHash && + stringValue(existing["spec_sha256"]) == currentSpecHash { + return fmt.Errorf("existing activation state is invalid for the unchanged approved plan; repair it instead of resetting delivery progress") + } + } else if statePath, statePathErr := deliveryStatePath(repo, stringValue(check.Plan["feature_id"])); statePathErr == nil && fileExists(statePath) { + return fmt.Errorf("managed delivery state exists without its plan lock; do not reset delivery progress") + } + if err := CompilePlanFiles(options.PlanPath, options.OutDir); err != nil { + return err + } if err := CreateApprovalLock(approval); err != nil { return err } - return CheckApprovalLock(approval) + if err := CheckApprovalLock(approval); err != nil { + return err + } + return initializeDeliveryState(repo, stringValue(check.Plan["feature_id"]), options.PlanPath, options.OutputPath) } func gitCommit(directory string) string { diff --git a/boatstack/pr.go b/boatstack/pr.go index fc6bb8a..7eb6dbe 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -13,13 +13,14 @@ import ( "strings" ) -const prPreviewSchemaVersion = 1 +const prPreviewSchemaVersion = 2 var prStatusPattern = regexp.MustCompile(`(?i)^(PASS|PASS_WITH_GAPS|NOT_VERIFIED|BLOCKED)$`) type PRContextOptions struct { Repo string Feature string + SliceID string Base string } @@ -33,6 +34,7 @@ type PRContext struct { SchemaVersion int `json:"schema_version"` Mode string `json:"mode"` Feature string `json:"feature,omitempty"` + SliceID string `json:"slice_id,omitempty"` BaseBranch string `json:"base_branch"` HeadBranch string `json:"head_branch"` BaseCommit string `json:"base_commit"` @@ -58,6 +60,7 @@ type PRPreview struct { Title string Mode string Feature string + SliceID string BaseBranch string HeadBranch string ContextFingerprint string @@ -293,9 +296,18 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro if err != nil { return nil, nil, err } + deliveryState, err := LoadDeliveryState(repo, feature) + if err != nil { + return nil, nil, err + } + activeSlice, err := activeDeliverySlice(deliveryState) + if err != nil { + return nil, nil, err + } + explicitSlices := len(deliveryState.Slices) > 1 || deliveryState.Slices[0].ID != "delivery" gateStatus := map[string]string{ - "test": evidenceGateStatus(string(evidence), "Test"), - "review": evidenceGateStatus(string(evidence), "Review"), + "test": deliveryEvidenceGateStatus(string(evidence), "Test", activeSlice.ID, explicitSlices), + "review": deliveryEvidenceGateStatus(string(evidence), "Review", activeSlice.ID, explicitSlices), } for _, gate := range []string{"test", "review"} { status := gateStatus[gate] @@ -336,6 +348,15 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil { return PRContext{}, err } + if strings.TrimSpace(options.Feature) == "" { + active, activeErr := ActiveManagedDeliveries(repo) + if activeErr != nil { + return PRContext{}, activeErr + } + if len(active) > 0 { + return PRContext{}, fmt.Errorf("ad-hoc PR preparation is disabled while managed delivery is active: %s", strings.Join(active, ", ")) + } + } head, err := gitCommand(repo, "branch", "--show-current") if err != nil || head == "" { return PRContext{}, fmt.Errorf("PR preparation requires a named branch") @@ -400,6 +421,14 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { } sources := []PRSource{configSource} gateStatus := map[string]string{} + sliceID := "" + safety, err := CheckRepositorySafety(repo) + if err != nil { + return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err) + } + if mode == "managed" && safety.Status != "PASS" { + return PRContext{}, fmt.Errorf("managed PR is blocked by executable irreversible capability: %s", safety.Findings[0].Category) + } if mode == "managed" { managedSources, statuses, sourceErr := managedPRSources(repo, options.Feature) if sourceErr != nil { @@ -407,19 +436,22 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { } sources = append(sources, managedSources...) gateStatus = statuses - } - safety, err := CheckRepositorySafety(repo) - if err != nil { - return PRContext{}, fmt.Errorf("cannot establish operational safety evidence: %w", err) - } - if mode == "managed" && safety.Status != "PASS" { - return PRContext{}, fmt.Errorf("managed PR is blocked by executable irreversible capability: %s", safety.Findings[0].Category) + _, slice, gateSources, deliveryErr := CheckDeliveryReadyForShip(repo, options.Feature, base, head, SHA256Bytes(diff), changed) + if deliveryErr != nil { + return PRContext{}, deliveryErr + } + if options.SliceID != "" && options.SliceID != slice.ID { + return PRContext{}, fmt.Errorf("delivery slice %s is not active; current slice is %s", options.SliceID, slice.ID) + } + sliceID = slice.ID + sources = append(sources, gateSources...) } sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) fingerprintPayload, err := MarshalJSON(map[string]any{ "schema_version": prPreviewSchemaVersion, "mode": mode, "feature": options.Feature, + "slice_id": sliceID, "base_branch": base, "head_branch": head, "base_commit": baseCommit, @@ -434,7 +466,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { return PRContext{}, err } return PRContext{ - SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, + SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, SliceID: sliceID, BaseBranch: base, HeadBranch: head, BaseCommit: baseCommit, MergeBaseCommit: mergeBaseCommit, HeadCommit: headCommit, ProductDiffSHA256: SHA256Bytes(diff), ContextFingerprint: SHA256Bytes(fingerprintPayload), ChangedFiles: changed, Commits: commits, DiffStat: diffStat, @@ -459,7 +491,7 @@ func parsePRFrontmatter(value string) (map[string]string, string, error) { fields := map[string]string{} allowed := map[string]bool{ "boatstack_pr_version": true, "title": true, "mode": true, "feature": true, - "base": true, "head": true, "context_fingerprint": true, + "slice": true, "base": true, "head": true, "context_fingerprint": true, } for _, line := range strings.Split(frontmatter, "\n") { key, raw, found := strings.Cut(line, ":") @@ -484,7 +516,7 @@ func parsePRFrontmatter(value string) (map[string]string, string, error) { } fields[key] = decoded } - for key := range allowed { + for _, key := range []string{"boatstack_pr_version", "title", "mode", "feature", "base", "head", "context_fingerprint"} { if _, exists := fields[key]; !exists { return nil, "", fmt.Errorf("PR frontmatter is missing %s", key) } @@ -588,7 +620,7 @@ func ParsePRPreview(path string) (PRPreview, error) { } preview := PRPreview{ SchemaVersion: version, Title: strings.TrimSpace(fields["title"]), Mode: fields["mode"], - Feature: fields["feature"], BaseBranch: fields["base"], HeadBranch: fields["head"], + Feature: fields["feature"], SliceID: fields["slice"], BaseBranch: fields["base"], HeadBranch: fields["head"], ContextFingerprint: fields["context_fingerprint"], Body: body, Path: path, Fingerprint: SHA256Bytes(value), } @@ -601,9 +633,15 @@ func ParsePRPreview(path string) (PRPreview, error) { if preview.Mode == "managed" && !featureSlugPattern.MatchString(preview.Feature) { return PRPreview{}, fmt.Errorf("managed PR preview requires a lowercase kebab-case feature") } + if preview.Mode == "managed" && !featureSlugPattern.MatchString(preview.SliceID) { + return PRPreview{}, fmt.Errorf("managed PR preview requires a lowercase kebab-case delivery slice") + } if preview.Mode == "ad-hoc" && preview.Feature != "" { return PRPreview{}, fmt.Errorf("ad-hoc PR preview must not claim a managed feature") } + if preview.Mode == "ad-hoc" && preview.SliceID != "" { + return PRPreview{}, fmt.Errorf("ad-hoc PR preview must not claim a managed delivery slice") + } if strings.TrimSpace(preview.BaseBranch) == "" || strings.TrimSpace(preview.HeadBranch) == "" { return PRPreview{}, fmt.Errorf("PR preview requires base and head branches") } @@ -648,7 +686,7 @@ func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) if err != nil { return PRPreview{}, PRContext{}, err } - context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: preview.Feature, Base: preview.BaseBranch}) + context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: preview.Feature, SliceID: preview.SliceID, Base: preview.BaseBranch}) if err != nil { return PRPreview{}, PRContext{}, err } @@ -669,7 +707,7 @@ func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) if filepath.Clean(expectedPath) != filepath.Clean(actualPath) { return PRPreview{}, PRContext{}, fmt.Errorf("PR preview must be stored at %s", context.PreviewPath) } - if preview.Mode != context.Mode || preview.BaseBranch != context.BaseBranch || preview.HeadBranch != context.HeadBranch || preview.ContextFingerprint != context.ContextFingerprint { + if preview.Mode != context.Mode || preview.SliceID != context.SliceID || preview.BaseBranch != context.BaseBranch || preview.HeadBranch != context.HeadBranch || preview.ContextFingerprint != context.ContextFingerprint { return PRPreview{}, PRContext{}, fmt.Errorf("PR preview is stale or does not match the current branch context; regenerate it") } if context.Mode == "managed" { @@ -786,11 +824,21 @@ func PublishPR(options PRPublishOptions) (string, error) { if err != nil { return "", err } + if context.Mode == "managed" { + if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, strings.TrimSpace(url)); err != nil { + return "", fmt.Errorf("PR opened but delivery state could not advance: %w", err) + } + } return strings.TrimSpace(url), nil } if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil { return "", err } + if context.Mode == "managed" { + if err := MarkDeliveryPublished(repo, context.Feature, context.SliceID, existingURL); err != nil { + return "", fmt.Errorf("PR updated but delivery state could not advance: %w", err) + } + } return existingURL, nil } @@ -802,10 +850,11 @@ func PRPreviewTemplate(context PRContext) string { safetySummary := "Repository safety scan: `" + context.SafetyStatus + "`. Destructive recovery remains operator-only outside Boatstack." return strings.Join([]string{ "---", - "boatstack_pr_version: 1", + "boatstack_pr_version: 2", "title: " + quote("Describe the reviewer-visible outcome"), "mode: " + quote(context.Mode), "feature: " + quote(context.Feature), + "slice: " + quote(context.SliceID), "base: " + quote(context.BaseBranch), "head: " + quote(context.HeadBranch), "context_fingerprint: " + quote(context.ContextFingerprint), diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go index f274501..db19e3e 100644 --- a/boatstack/pr_test.go +++ b/boatstack/pr_test.go @@ -68,10 +68,11 @@ func quoted(value string) string { func previewDocument(context PRContext, title, body string) string { return strings.Join([]string{ "---", - "boatstack_pr_version: 1", + "boatstack_pr_version: 2", "title: " + quoted(title), "mode: " + quoted(context.Mode), "feature: " + quoted(context.Feature), + "slice: " + quoted(context.SliceID), "base: " + quoted(context.BaseBranch), "head: " + quoted(context.HeadBranch), "context_fingerprint: " + quoted(context.ContextFingerprint), @@ -211,6 +212,18 @@ No migration; revert the feature commit. } runGit(t, repo, "add", ".product-loop/features/"+feature) runGit(t, repo, "commit", "-m", "record approved feature evidence") + for _, gate := range []string{"test", "review"} { + status := "PASS" + if gate == "review" { + status = "PASS_WITH_GAPS" + } + if _, err := RecordDeliveryGate(DeliveryGateOptions{ + Repo: repo, Feature: feature, SliceID: "delivery", Gate: gate, + Status: status, EvidencePath: filepath.Join(directory, "evidence.md"), + }); err != nil { + t.Fatalf("record %s delivery gate: %v", gate, err) + } + } return directory } diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 46a143d..f349e23 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -13,6 +13,8 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Markdown plan | Human-readable plan plus its one marked structured block; canonical before and during build | A spec is resolved enough to propose tasks and checks | | Approval receipt | Named human, timestamp, and fingerprint in Markdown; not executable state | The exact draft is explicitly approved in Plan mode | | Compiled tasks | Deterministic dependency graph generated from the approved Markdown plan | Build activation succeeds | +| Delivery state | Ignored worktree-local Git active-slice state bound to the approved plan lock; never an approval artifact | Build activation and successful slice publication | +| Gate receipt | Machine-local test or review transition bound to one delivery slice, base/head branches, commit, product diff, and evidence hash | A slice passes test or review | | Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes | | Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent | | Risk/threat note | Assets, actors, trust boundaries, abuse/failure paths | Security, data, tenancy, billing, auth, or destructive paths change | @@ -69,6 +71,12 @@ Generated artifacts include the canonical loop version and config hash. Human ed For managed work it lives under `.product-loop/features//pr.md` and may claim only evidence present in the current approved package. For an existing or ad-hoc branch it lives under `.product-loop/pr-briefs//pr.md`, uses observed branch facts, and labels missing approval or gate evidence `NOT_VERIFIED`. Both are committed with the branch. The preview file itself is excluded from the product-diff fingerprint. +Managed preview metadata also names the active delivery slice. The ignored delivery +state and gate receipts live under the current worktree's Git directory so branch +changes retain control state without blocking unrelated worktrees. They are runtime +control state, not durable product evidence; the PR links the committed evidence +ledger while the publisher rechecks the matching receipts. + ## Planning boundary `auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and `approval.md` is the human-approval receipt. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt is verified. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents. diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 9a3d394..b2ddb6e 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -140,6 +140,14 @@ Create tasks in dependency order. Each task names: - rollback boundary; - unknowns that would stop implementation. +Tasks describe implementation, never publication authority. Internal phases remain +tasks inside one delivery slice. If the accepted product change intentionally needs +multiple PRs, `plan.md` declares ordered `delivery_slices`. Every task belongs to +exactly one slice; dependencies may point within the slice or to an earlier slice, +never forward. Optional base/head branch names are constraints, not permission to +create or push those branches. Approval accepts the delivery structure but does not +authorize any PR mutation. + An external-write task also names `affected_paths` and a compact `side_effects` record: operation kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Ambiguous targets such as “local database” and rollback text such as “reset local DB” block approval. Ordinary tasks do not need side-effect ceremony. Run only relevant review lenses: @@ -183,11 +191,17 @@ At the host's normal Build transition, first confirm the host is in an execution 4. record approver, timestamp, source commit, and all artifact hashes in `plan.lock.json`; 5. write the lock last and recheck it before permitting implementation. +Activation also initializes ignored, worktree-local Git delivery state bound to the lock. +One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An +explicit multi-slice plan starts only its first slice in `BUILD`; later slices remain +`PENDING`. + Missing approval, unresolved `blocking_questions`, or any change to the source plan, approved spec, or complete `plan.md` blocks activation and returns the feature to `PLAN_GATE`. A failed or partial compilation never creates a valid lock. ### `PLAN_LOCKED -> BUILD` -Implement one coherent task slice at a time. After each slice: +Read the active delivery state and implement only that slice's `task_ids`. Within it, +implement one coherent task slice at a time. After each task slice: 1. run the cheapest relevant check; 2. compare the diff to the task contract; @@ -195,6 +209,11 @@ Implement one coherent task slice at a time. After each slice: 4. record deviations or new unknowns; 5. continue, ask, or re-plan explicitly. +Commits are allowed during build. Direct `git push`, `gh pr create/edit/ready/merge`, +and equivalent GitHub mutations are not implementation tactics: the host hook denies +them while managed delivery is active. Do not route a managed branch through the +ad-hoc PR path. + Scan operational changes and configured `high_risk_paths` before activation and after relevant edits. A dangerous capability may remain visible as source for review, but it cannot execute and blocks progression until removed or isolated behind the operator boundary. ### `BUILD -> TEST_GATE` @@ -214,10 +233,19 @@ The riskier the slice, the less acceptable same-model, self-authored tests are a External-write evidence must establish immutable target identity, transactional or fix-forward behavior, and an independent safety oracle. A dry run that only prints the intended command does not prove the live target or failure behavior. +Before passing the gate, commit the intentional active-slice product and evidence diff +and invoke the deterministic delivery-gate recorder for `test`. It captures the slice, +base/head branches, HEAD, product-diff hash, and evidence hash. A `PASS` string edited +into Markdown is evidence content, not a state transition. + ### `TEST_GATE -> REVIEW_GATE` Review only after required mechanical checks pass, unless reviewing a failure is the goal. The reviewer inspects the actual diff and reports findings by severity with file/line evidence, consequence, and correction. +On pass, invoke the same recorder for `review`. It accepts only the active slice and +only when the test receipt matches the current diff. Any product or evidence change +afterward makes the receipts stale and routes back through test and review. + ### `REVIEW_GATE -> SHIP_GATE` Require: @@ -246,6 +274,11 @@ Store the exact preview at `.product-loop/features//pr.md`. Its non-ren Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action: `Reply open PR` when no PR exists, or `Reply update PR` when one exists. Only that explicit reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then perform a normal push and the selected GitHub action. Any drift blocks publication and requires a new preview; never force-push. +For managed work, publication also requires current test and review receipts for the +active delivery slice. Successful publication marks only that slice `PUBLISHED` and +activates the next slice as `BUILD`. No parent-plan approval, prior phase receipt, or +context summary can skip these transitions. + Opening or updating a PR does not authorize merge or deployment. After successful publication only, the publisher may use the ignored 24-hour release cache to report an available stable Boatstack version. The primary response and next action remain **PR opened -> Review the PR**. Put the maintenance notice in collapsed details, state that no files changed, and direct the user to run `/boatstack-update` from the clean default branch after the feature PR merges. Suppress repeated notices for seven days unless a different release appears. Release lookup failure never changes the ship result. diff --git a/boatstack/safety.go b/boatstack/safety.go index 1cf12cd..7aae5d3 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -47,6 +47,9 @@ var irreversiblePatterns = []struct { var operationalPathPattern = regexp.MustCompile(`(?i)(?:^|/)(?:scripts?|migrations?|schema|database|db|deploy|infra|ops|terraform|k8s)(?:/|$)|\.(?:sql|ps1|sh|bash|py)$`) var mutationStatementPattern = regexp.MustCompile(`(?is)\b(?:delete\s+from|update\s+[^\s;]+)\b[^;]*`) +var directPublicationPattern = regexp.MustCompile(`(?i)(?:\bgit\b[^\n;&|]*\bpush\b|\bgh\s+pr\s+(?:create|edit|ready|merge)\b|\bgh\s+api\b[^\n;&|]*(?:/pulls\b|/pull-requests\b)|\bhub\s+pull-request\b|\bcurl\b[^\n;&|]*(?:api\.github\.com|/pulls\b)[^\n;&|]*(?:\s-X\s*(?:POST|PATCH)|--request\s+(?:POST|PATCH)))`) +var approvedPublisherPattern = regexp.MustCompile(`(?i)^\s*(?:[^\s]*/)?boatstack-helper\s+publish-pr\b[^\n;&|]*$`) +var deliveryStatePathPattern = regexp.MustCompile(`(?i)(?:boatstack[/\\]deliveries|\.git[/\\](?:worktrees[/\\][^/\\]+[/\\])?boatstack(?:[/\\]|$))`) func classifySafetyText(value, source string) []SafetyFinding { if isPureReadOnlyCommand(value) { @@ -172,6 +175,18 @@ func ClassifyCommand(repo, command string) []SafetyFinding { if strings.TrimSpace(command) == "" { return []SafetyFinding{{Category: "malformed-tool-input", Reason: "empty shell input is denied by the fail-closed guard", Source: "tool-input"}} } + if deliveryStatePathPattern.MatchString(command) && !isPureReadOnlyCommand(command) { + return []SafetyFinding{{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}} + } + if directPublicationPattern.MatchString(command) && !approvedPublisherPattern.MatchString(command) { + active, activeErr := ActiveManagedDeliveries(repo) + if activeErr != nil { + return []SafetyFinding{{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}} + } + if len(active) > 0 { + return []SafetyFinding{{Category: "workflow-publication-bypass", Reason: "direct push or PR mutation is denied while a managed delivery slice is active", Source: "tool-input"}} + } + } findings := classifySafetyText(command, "command") if regexp.MustCompile(`(?i)\b(?:rm\s+-[^\n;]*(?:r[^\n;]*f|f[^\n;]*r)|remove-item\s+[^\n;]*-recurse[^\n;]*-force)\b`).MatchString(command) && strings.Contains(command, repo) { findings = append(findings, SafetyFinding{Category: "filesystem-destruction", Reason: "recursive deletion of the repository is denied", Source: "command"}) @@ -219,6 +234,19 @@ func ClassifyTool(repo, name string, input any) []SafetyFinding { combined := name + " " + string(value) findings := classifySafetyText(combined, "tool-input") nameLower := strings.ToLower(name) + publicationText := strings.ToLower(combined) + if deliveryStatePathPattern.MatchString(combined) && regexp.MustCompile(`(?:write|edit|delete|remove|move|rename|create|update)`).MatchString(nameLower) { + findings = append(findings, SafetyFinding{Category: "workflow-state-tamper", Reason: "managed delivery state may be changed only by Boatstack transitions", Source: "delivery-state"}) + } + if (strings.Contains(publicationText, "pull_request") || strings.Contains(publicationText, "pull request")) && + regexp.MustCompile(`(?:create|update|edit|merge|publish)`).MatchString(publicationText) { + active, activeErr := ActiveManagedDeliveries(repo) + if activeErr != nil { + findings = append(findings, SafetyFinding{Category: "workflow-state-invalid", Reason: "publication is denied because managed delivery state cannot be verified", Source: "delivery-state"}) + } else if len(active) > 0 { + findings = append(findings, SafetyFinding{Category: "workflow-publication-bypass", Reason: "direct PR mutation is denied while a managed delivery slice is active", Source: "tool-input"}) + } + } if regexp.MustCompile(`(?:delete|destroy|reset|drop|truncate|terminate)`).MatchString(nameLower) && regexp.MustCompile(`(?:database|schema|project|cluster|namespace|volume|bucket|backup|snapshot|instance)`).MatchString(strings.ToLower(combined)) { findings = append(findings, SafetyFinding{Category: "external-resource-destruction", Reason: "destructive external-resource tools are operator-only", Source: "tool-input"}) } @@ -281,6 +309,15 @@ func hookToolInput(host string, value []byte) (string, any, error) { } func denialMessage(finding SafetyFinding) string { + if finding.Category == "workflow-state-invalid" { + return "Boatstack denied publication because managed delivery state cannot be verified. Re-run the active Boatstack operation or repair the installation before publishing." + } + if finding.Category == "workflow-state-tamper" { + return "Boatstack denied direct delivery-state mutation. Use the active build, test, review, or ship transition instead of editing runtime authority." + } + if finding.Category == "workflow-publication-bypass" { + return "Boatstack denied a publication bypass. Finish the active slice's test and review gates, then use ship-gate and the confirmed Boatstack publisher." + } return "Boatstack denied an irreversible operation (" + finding.Category + "). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow." } diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 587c4fd..ba1811d 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -42,6 +42,12 @@ In the repository, those terms are not decorative notation: ZCA creates immediate value by reducing a vague feature request to one verifiable slice. For a shipped SDK, API, or CLI, Boatstack uses two slices: the implementation boundary and one representative consumer path. +Delivery phases use the same separation. Internal phases are tasks inside one slice. +If the accepted result truly needs multiple PRs, the plan defines ordered delivery +slices, but the control state activates only one. Every active slice must produce +fresh diff-bound test and review receipts and receive its own ship confirmation; +approval of the parent plan carries scope, not publication authority. + The entry state is not an unstructured chat message. Ordinary product intent is first explored in the active host's Plan mode and saved as a source plan. `auto-plan` resolves the active path from host conversation context or bounded fallback discovery, then requires that file before projecting repository context: ```text @@ -90,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 **8311 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 **9004 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -140,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 [`bab232ddd34b39e9e480131a3c967c723f831232`](https://github.com/operatorstack/intelligence-flow/tree/bab232ddd34b39e9e480131a3c967c723f831232/examples/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`16e771775417be997b7fc17e1b1b28d700780899`](https://github.com/operatorstack/intelligence-flow/tree/16e771775417be997b7fc17e1b1b28d700780899/examples/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/getting-started.md b/docs/getting-started.md index 3a5ff5c..834ac4f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -126,7 +126,7 @@ Approver, timestamp, fingerprint, and approval-record path. ## 4. Build the approved change -Use Cursor, Codex, or Claude's normal transition out of Plan mode, then run `/build`. Boatstack verifies the approval, creates the machine task/evidence state, and locks it to the reviewed inputs before the first product edit. +Use Cursor, Codex, or Claude's normal transition out of Plan mode, then run `/build`. Boatstack verifies the approval, creates the machine task/evidence state, and locks it to the reviewed inputs before the first product edit. Internal plan phases remain tasks in one delivery. If the approved plan explicitly declares multiple PR-sized `delivery_slices`, Boatstack activates only the first slice; approval of the parent plan is not permission to publish any slice. | Host | Planning surface | Build transition | |---|---|---| @@ -146,11 +146,11 @@ Run the remaining gates: /ship-gate ``` -- **Test gate:** connects every promised outcome to current evidence. -- **Review gate:** checks the actual diff against the approved intent, risks, invariants, and gaps. -- **Ship gate:** creates a reviewer-first title and body from the committed change and recorded evidence. +- **Test gate:** connects the active slice's promised outcomes to current evidence and records a receipt bound to its committed diff. +- **Review gate:** checks that same diff against the approved intent, risks, invariants, and gaps, then records a second receipt. +- **Ship gate:** requires both current receipts and creates a reviewer-first title and body for that slice. -Boatstack shows the exact PR preview before changing GitHub. Reply `open PR` for a new PR. Reply `update PR` for an existing one. Any changed commit or evidence makes the preview stale and forces regeneration. Merge and deploy remain separate human decisions. +Boatstack shows the exact PR preview before changing GitHub. Reply `open PR` for a new PR. Reply `update PR` for an existing one. Any changed product diff or evidence makes the preview and gate receipts stale. A successful publication activates the next declared delivery slice. Direct pushes, direct PR mutations, and the ad-hoc PR route are denied while managed delivery is active. Merge and deploy remain separate human decisions. After successful publication, Boatstack may show a collapsed notice when a newer stable release is available. The check is cached, never changes the feature branch, and never blocks shipping. diff --git a/docs/public-claims.json b/docs/public-claims.json index ea60b54..e1677cb 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "bab232ddd34b39e9e480131a3c967c723f831232", + "source_commit": "16e771775417be997b7fc17e1b1b28d700780899", "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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,18 @@ "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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" + }, + { + "id": "phase-scoped-delivery", + "public_claim": "When an approved plan intentionally contains multiple PR-sized delivery slices, Boatstack requires a fresh test, review, preview, and publication confirmation for each slice.", + "status": "verified", + "originating_observation": "A coding host treated an approved multi-phase plan as standing permission to open several PRs during build without returning through the gates.", + "safeguard": "Explicit task-to-slice partitioning, affected-path scope, diff-bound gate receipts, active-slice publication, and direct push/PR hook denial.", + "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:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "model-neutral-contract", @@ -68,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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "cross-model-failures", @@ -79,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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "lower-cost-outcomes", @@ -90,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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "git-worktree-activation", @@ -101,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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" }, { "id": "visible-updates", @@ -112,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:bab232ddd34b39e9e480131a3c967c723f831232" + "last_verified_version": "source:16e771775417be997b7fc17e1b1b28d700780899" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c82dda4..3b93029 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -82,6 +82,15 @@ Boatstack found an installed generated file that no longer matches its previous A new commit, changed evidence, changed approval artifact, or base-branch update invalidated the preview. Ask Boatstack to regenerate it. Do not copy the old body forward. +## A phased plan cannot push or open its next PR + +Plan approval is not publication authority. Run `delivery-status` through the active +Boatstack operation and confirm that the intended delivery slice is active. Commit +only that slice's declared affected paths, then run `/test-gate`, `/review-gate`, and +`/ship-gate`. Direct pushes, GitHub CLI PR mutations, GitHub tool mutations, and the +ad-hoc PR route are denied until the managed publisher receives the explicit open or +update confirmation. Successful publication activates the next declared slice. + ## GitHub CLI is unavailable Boatstack retains the validated `pr.md`. Authenticate or install GitHub CLI and repeat the open/update confirmation, or copy the exact preview into GitHub manually. Neither path authorizes merge. diff --git a/docs/validation-and-evidence.md b/docs/validation-and-evidence.md index ec7b8dc..2f67798 100644 --- a/docs/validation-and-evidence.md +++ b/docs/validation-and-evidence.md @@ -99,3 +99,5 @@ These observations motivate—not mathematically prove—the separation between ## ZCA translation For ordinary work, Boatstack projects the smallest implementation-relevant slice. For something shipped as an SDK, API, CLI, or reusable product, it also requires a representative verifier/consumer slice. Value emerges where the implementation claim meets an oracle capable of disproving it—not from adding ceremony to the implementation itself. + +The same rule applies to phased delivery. Internal phases stay inside one delivery slice. Multiple PRs require explicit ordered delivery slices, and each slice gets fresh test and review receipts bound to its own committed diff before a separately confirmed ship action. A parent-plan approval or previous slice receipt cannot authorize the next PR. diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md index f74afda..bbfbca3 100644 --- a/docs/why-these-steps.md +++ b/docs/why-these-steps.md @@ -65,6 +65,16 @@ Those labels prevent an implementation test from being presented as proof that t **Status:** product-workflow problem observed; projection behavior verified. Reviewer speed and acceptance quality still need blinded product-delivery evaluation. +## Phase-scoped delivery + +**What happened.** A coding host received an approved plan describing several implementation phases and PRs. During `/build`, it treated that parent-plan approval as standing authority to commit, push, and open each PR. After context compression, “already-approved five-PR plan” became the surviving instruction and the test, review, and ship transitions were skipped. + +**What Boatstack does.** Internal phases remain tasks in one delivery slice. A plan that intentionally requires multiple PRs must partition every task into ordered delivery slices with explicit affected paths. Only one slice is active. Test and review create machine-local receipts bound to that slice's branches, commit, product diff, and evidence. The hook denies direct pushes and PR mutations while managed delivery is active; only the confirmed publisher can advance one slice and activate the next. + +**How we check it.** Plan tests reject missing, duplicate, and forward task assignments. Delivery tests reject review before test, stale diffs, out-of-slice paths, reuse of a prior slice, direct shell pushes, direct GitHub CLI PR creation, and equivalent GitHub tool mutations. + +**Status:** bypass trajectory observed; phase partitioning, receipt ordering, and publication interception verified in automated tests. Host hooks remain defense in depth and still depend on supported host event coverage. + ## Model choice and budget **What happened.** Across the audited benchmark runs, changing the model relocated the dominant bottleneck instead of removing failure. Gemini runs were dominated by near misses in one comparison, while Qwen runs exposed step exhaustion. Other recorded failures involved malformed protocol responses, context loss, unsupported verification claims, and unsafe recovery. A model name, provider, or price was not itself a reliable description of the active engineering problem. diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json index 74021bc..55af344 100644 --- a/examples/diagram-json/plan.lock.json +++ b/examples/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "examples/diagram-json/plan.md", "plan_sha256": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "schema_version": 1, - "source_commit": "bab232ddd34b39e9e480131a3c967c723f831232", + "source_commit": "16e771775417be997b7fc17e1b1b28d700780899", "source_plan_path": "examples/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "examples/diagram-json/spec.md", diff --git a/release-notes/2026-07-17-phase-scoped-delivery.md b/release-notes/2026-07-17-phase-scoped-delivery.md new file mode 100644 index 0000000..c7c04ad --- /dev/null +++ b/release-notes/2026-07-17-phase-scoped-delivery.md @@ -0,0 +1,3 @@ +### Phased plans now preserve every delivery gate + +Boatstack now treats ordinary plan phases as internal work unless the plan explicitly declares PR-sized delivery slices. Each declared slice must independently pass its build, test, review, and confirmed ship flow, preventing plan approval from authorizing mid-build pull requests for later phases.