From 78d49c1fffeac74ea6b3a93891a4ce73e93a5ac7 Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 19 Jul 2026 00:40:12 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 3c8217d87c71 --- CONTRIBUTING.md | 2 +- README.md | 25 ++- UPSTREAM.json | 45 +++--- boatstack/assets/templates/plan.md | 3 +- boatstack/changelog.go | 147 ++++++++++++++++++ boatstack/changelog_test.go | 138 ++++++++++++++++ boatstack/delivery.go | 19 +++ boatstack/delivery_test.go | 52 ++++++- boatstack/export.go | 6 +- boatstack/export_test.go | 17 ++ boatstack/init_test.go | 3 + boatstack/pr.go | 7 + boatstack/pr_test.go | 7 + boatstack/references/workflow.md | 19 ++- boatstack/runtime.go | 1 + docs/evidence-engineered-coding.md | 4 +- docs/getting-started.md | 28 ++++ docs/public-claims.json | 24 +-- labs/diagram-json/plan.lock.json | 2 +- project.example.json | 3 +- ...026-07-19-optional-repository-changelog.md | 9 ++ 21 files changed, 511 insertions(+), 50 deletions(-) create mode 100644 boatstack/changelog.go create mode 100644 boatstack/changelog_test.go create mode 100644 release-notes/2026-07-19-optional-repository-changelog.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61b44f6..3a74caf 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/af3a40320dd2525fccc99e67c7ca26a43ec7ded8/labs/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/3c8217d87c7117173b02ce29cae5316ccb6beca4/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/README.md b/README.md index e4a0f6c..f066d83 100644 --- a/README.md +++ b/README.md @@ -77,18 +77,31 @@ Receipts remain as history; published corrections become linked deliveries. ## Features -- **A guided path from idea to PR.** Start with `/auto-plan`; Boatstack presents one next action at a time through planning, approval, build, validation, review, and PR preparation. +- **A guided path from idea to PR.** `/auto-plan` starts a one-action-at-a-time delivery flow. - **Instant orientation after a break.** `boatstack next` reconstructs the verified stage without treating chat or a running process as workflow evidence. - **Human decisions stay human.** Material product questions remain open until a person answers them, and implementation waits for explicit approval. - **Evidence tied to the promise.** Tests and checks map to the outcomes the change claims to deliver instead of treating one green command as proof of everything. -- **Context that survives the feature.** Plans, decisions, accepted gaps, evidence, review findings, and code state can inform the next feature rather than disappearing with the chat. +- **Context that survives the feature.** Plans, decisions, gaps, evidence, and code state remain useful beyond the chat. - **Conversational repair after Build.** Describe what changed; Boatstack preserves valid work and reruns only affected boundaries. - **Safer agent execution.** High-confidence destructive recovery is stopped before execution; phased work is gated and published one approved delivery slice at a time. -- **Reviewer-ready pull requests.** Boatstack carries the reason, actual changes, validation, risks, gaps, rollout, and rollback into a focused PR brief. -- **Portable across your AI stack.** Cursor, Codex, Claude Code, different model tiers, and specialist skills use the same repository-owned delivery contract. -- **Repository-friendly maintenance.** Linked worktrees restore their verified runtime automatically, while Boatstack updates stay isolated in reviewable infrastructure PRs. +- **Reviewer-ready pull requests.** Actual changes, evidence, risks, rollout, and rollback become a focused PR brief. +- **Optional repository changelog.** Require readable `CHANGELOG.md` entries grounded in actual changes. +- **Portable across your AI stack.** Hosts, models, and skills share one repository-owned delivery contract. +- **Repository-friendly maintenance.** Worktrees restore runtime; updates stay in separate infrastructure PRs. -You remain free to build however the work requires. Boatstack governs claims of approval, completion, review, and shipping—not the implementation technique. +### Optional changelog + +It is disabled by default. Enable it in `.boatstack-project.json`: + +```json +{ + "workflow": { + "maintain_changelog": true + } +} +``` + +Enabled repositories require a categorized `CHANGELOG.md` → `Unreleased` entry for every managed slice and Boatstack-prepared ad-hoc PR. The file stays user-owned; install and update never overwrite it. [See the format and first-entry example](docs/getting-started.md#keep-a-repository-changelog). ## How Boatstack fits into your AI stack diff --git a/UPSTREAM.json b/UPSTREAM.json index 2eeabcb..fdd8260 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 42600, - "estimated_tokens": 10650, + "characters": 43583, + "estimated_tokens": 10896, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,8 +12,8 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "acc6358f197620db553f49454b13ca97230d3cf39f394a775524feeafde1c79c", - "README.md": "73147594dce1b867d0258c87301032ee5e20ad83ee74843fd076a31838880f0e", + "CONTRIBUTING.md": "115637ba74936e2d3452359dc3bbe3b3520e7cebcabe696896ab7beb61cf2177", + "README.md": "a840004db57a129ecd361bb0421adfe02065d86751736dd91953c8a8b178e5ef", "assets/boatstack-journey.svg": "c1f7fe2741f5e9ca66bb3fe9b103e6364ba5acbca8b7a8054768ffd85cf325ea", "assets/boatstack-mark.svg": "ec96165583b15cfd446c27049d49217973f3e9b1defa5771cc08eec0c9542ce4", "assets/boatstack-portability.svg": "ce648f5581d16586d25824d3a8132ef1b3d88b73329179173120129d4f74fd24", @@ -26,21 +26,23 @@ "boatstack/assets/templates/gaps.md": "911cc2f086104d35071b952950c2ec44258641419f10b2355c594f33eb492cbe", "boatstack/assets/templates/move.md": "91bfd9a9b9426ac023eb88fd19f4f638190481c1855f1239acc73830528e50f0", "boatstack/assets/templates/plan-lock.json": "a51e17bb74aa7cd95daaa70fab646a20374ff4bc1d63468d61c5119da61e930f", - "boatstack/assets/templates/plan.md": "1c7d5802b67d674c13bee51a028c5ba933d8b9bfbb32e894af66338f3f4040f5", + "boatstack/assets/templates/plan.md": "ce0d43ea36a40486b0e3fc5edd11649d9591abb1ccf6b3c3affdba0048d6cd05", "boatstack/assets/templates/questions.md": "1133b557a832d4988545f3694b365ebffa808640ed696b6c04b5a390266eed80", "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", + "boatstack/changelog.go": "5f0b1e7a66586c58aad03bfea8126178316f82ff6583bd67d35d6fcf71a6f9bc", + "boatstack/changelog_test.go": "2cee0d580dc2fa0752ee58564d030961b20139008de132ee8abff9e6b6713e28", "boatstack/cmd/boatstack-helper/main.go": "b4b8b43d80dbf60f15e18885ff8ee01637df6e47c0249938714d885163350539", - "boatstack/delivery.go": "907a0ff8dc3e6120387eef3c7d97cdadb0b9dd8f788cb39e149aa7a6c4a6260a", - "boatstack/delivery_test.go": "744d166757deafe5b9fc4f66b79b324de43dcc36a26a317572eaec73a8b21044", - "boatstack/export.go": "f097e3b143011c13c17fcba7cd147c19848b2f9b472862fe8cad4bbff6310e57", - "boatstack/export_test.go": "67c3b8ce09aa63bd8c5168f3d3fe56156c5b47cef4d2f83bbcaa7eb7302ad712", + "boatstack/delivery.go": "565bc8465252a798124a29874e1aeffff16e9027d1a86715e11b3e4bc0743bd2", + "boatstack/delivery_test.go": "2545c26389d330a92c11e7b09fb2342101db5d09a825a2a9e81b4ea9eea9407b", + "boatstack/export.go": "48032b870f226b1a2758f332aa5b684fad65c2dd5370ae6447d694f406a6a467", + "boatstack/export_test.go": "9474985bf2aab4f2f14d37dcc3cedc6d7a74db39739fbbfd7615297e35b079b5", "boatstack/go.mod": "57c377eccea51372d6664de4169e2ca45806b046f7e8a98a1e35a9eb454b4b8d", "boatstack/hooks.go": "1d5d8c4bf7e6e867c8bf07e391d86158347269f856647a5d256f345bbb8d3c96", "boatstack/hooks_test.go": "c3f359416ea53f258d8747d0247381e8946efd4d4a5bcf072c4147f885475ad3", "boatstack/init.go": "fb863a68a6cced5bd2dcf9cd8ca53f0dab7ef0309830269efd985ad8b8a92059", - "boatstack/init_test.go": "46ef98d955224d7744a5d67798bbc001f2d4c2c6aa2ac8a802c56e91a3ad8fb8", + "boatstack/init_test.go": "eb91d280077115c0dbe3d8472e1e4fa9794d5ae6e5a58afbb9c3f4f39aee80d8", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/next.go": "9a9b3b9dde0a528991d2b6ac358638b249a791a4eb3e7a2656bd43b652a2c628", @@ -49,18 +51,18 @@ "boatstack/plan_test.go": "006cdc6681f77e579c5a0f709e30ede759c337132d4f2f5193b7b79b29bd7149", "boatstack/planning.go": "3a26417a295e5dfc2b6dcac702287c04b6053e7b74215858a4ea11cf9f9dadfe", "boatstack/planning_test.go": "6b156a64182ed76d4c3d392b4c5a26abe5d8b81cea27ee12ac7c4627c827e186", - "boatstack/pr.go": "2e0ea90a991fe4504e54972506cb306f48124c8b34be8577d96f6c97079bdf0c", - "boatstack/pr_test.go": "74afb1a9be3c4a95a426515be415149ae24b077454b486d8e69b7118c42f2916", + "boatstack/pr.go": "ba537fc35cc8d2aaf584c5ba6de1194fdd1c4255dfb227a09478457752e2b9ad", + "boatstack/pr_test.go": "ae23130d9d96cf214cf272227aa572dd09e2fe3adac22921949f541ba99ecb23", "boatstack/references/artifacts.md": "8f2e79b8af4bd3ad2e32aa3e8c07f10812555d0a3247e4991db75cc1cda395c3", "boatstack/references/failure-moves.md": "1d35126348d0b681976e8819665e16fd745fd65eca271492603cb80aab75bf49", "boatstack/references/irreversible-operation-boundary.md": "2a695f2d7de95cfc8750f107bef9c86581712aa1f02e7233b69b850d8c2af42e", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "7e889d106aaab0a4acbb2c0af6a79b914a7599f84cb03eb26d1f28c33c4e3bea", + "boatstack/references/workflow.md": "b460b647bdd5d0ce4f1bd1015434a9e6c3b8ef525920ed018fb26a22bbc3aa9a", "boatstack/release.go": "fa2ac926df89c90c5844e938a2e02d4b8dbbaefbf85bb7a1a89fc51690bea520", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "a9afb239c5e2cb80f96dab594f0dc94852f2f16625b1de05c5110ee85f61cfaa", "boatstack/run_test.go": "fdc416f15e787b5c8401fc0f0e3aeb58b4891c828a8a869c8dce4e8f1541d809", - "boatstack/runtime.go": "f393745950e8ba2da7e25d5539ad536a6239a10c17109224bad20cf48445c380", + "boatstack/runtime.go": "504b7b68e550ed178c2c12bb815aa21e67300cad65cf2311bb5e8b2860747f81", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "4cbca9dec7800d7df6e3ec0d74c7ecbe1508e5c5a288d863f35fc8d22986c308", "boatstack/safety.go": "8bcce4c11094b4018093ac5fb5a5256cc5c63c825ddf7ec1a46b9d0098557d33", @@ -73,10 +75,10 @@ "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "bbf3f5d0cddccae591aee3b92083ba78df20451a7f920d3b94129cf3ab33c563", + "docs/evidence-engineered-coding.md": "800027421c4aa40d31b337ecb4aa96edc672911c7e475501f40fd01543e1387f", "docs/generated-files.md": "136422baf0c7fc2bd5100cfe0ebdb3d9d0705dfd7e7d54bf745dd1037e63492c", - "docs/getting-started.md": "61efc6bd618bd6674687d8efac9e1bc425fe5c10e72f87e267461dd2a830736e", - "docs/public-claims.json": "13a35ca66fd3ff382da2e181b6f14b8d28fdbc2621a6c322fd5a67bc79d9cdd8", + "docs/getting-started.md": "c298c0d78054266099ae98232a4c29976fa4e176d4ace49b6a20a8c13c35bc51", + "docs/public-claims.json": "85a6de5fb913d6e039323d0b7d0dc166f3abae1f231354af034288214ef680e5", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -90,13 +92,13 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "e48d49d74d6b3f337f3d9ef14243a6f9bea44bf55641f3dea459d04e61d02626", + "labs/diagram-json/plan.lock.json": "d9320eed4adfd09430d9bb6bc4fd6878cbb32a25e1662e130105f18c40839958", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", "labs/diagram-json/source-plan.md": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "labs/diagram-json/spec.md": "506b12b57bef99183d8b9a87b9f1aa5e68c39b910de88d7637b2207b7b1d6bb4", - "project.example.json": "d1f7aa3cff0b55ede79500bd2ca710bb99cb2ae579f0a058dc00934accf03d33", + "project.example.json": "f90fa25280c75e37a7073d384f61330b0889248fd2ca263babcc57a0bb8afd09", "release-notes/2026-07-17-delivery-harness-framing.md": "6fccbe7efdb288f5eca7e824ee5e44ad6b30a34f312eb997dca6aa44c55b26cb", "release-notes/2026-07-17-phase-scoped-delivery.md": "bfc8edd30a67daf5940ad4ceceebf81d01d62914ebeabb7073985c53f13df2ff", "release-notes/2026-07-17-visible-release-messages.md": "c93e8c812528a983502263e35d66c86a265d6ccba8203f393788c069b3fa6606", @@ -113,12 +115,13 @@ "release-notes/2026-07-18-run-through-ship.md": "e69d314fe65265eb1f38c933340772d15bbb53b74c33d9d489b8a55849f545c7", "release-notes/2026-07-18-safety-sql-boundaries.md": "32011ca3d02a371e8f3f2899ffb34df3af0843d18d25e7db95fbca32c2dcf18c", "release-notes/2026-07-18-stacked-bar-mark.md": "c4d5bd5fb89c280d7fba015384fd795fcb8c31ffe501078aa55a90cbcf66ba7b", - "release-notes/2026-07-18-startup-recovery-routing.md": "c305a1c2b8347e1bc6d7fcbd4a8e629438cdeb4bcf6f181a7d6fc154a88f4318" + "release-notes/2026-07-18-startup-recovery-routing.md": "c305a1c2b8347e1bc6d7fcbd4a8e629438cdeb4bcf6f181a7d6fc154a88f4318", + "release-notes/2026-07-19-optional-repository-changelog.md": "7ae6bed436c269335ac5055e04438489451bef00836fecb3e5fcb06a18501853" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "af3a40320dd2525fccc99e67c7ca26a43ec7ded8", + "commit": "3c8217d87c7117173b02ce29cae5316ccb6beca4", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/assets/templates/plan.md b/boatstack/assets/templates/plan.md index 2808a88..ab8f9c1 100644 --- a/boatstack/assets/templates/plan.md +++ b/boatstack/assets/templates/plan.md @@ -76,4 +76,5 @@ Boatstack rejects ambiguous targets, automated resets, and destructive rollback. 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. +inside one delivery slice. When `workflow.maintain_changelog` is enabled, include +`CHANGELOG.md` in the affected paths of tasks assigned to every delivery slice. diff --git a/boatstack/changelog.go b/boatstack/changelog.go new file mode 100644 index 0000000..84b6416 --- /dev/null +++ b/boatstack/changelog.go @@ -0,0 +1,147 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const changelogPath = "CHANGELOG.md" + +var changelogCategories = map[string]bool{ + "Added": true, + "Changed": true, + "Fixed": true, + "Removed": true, + "Security": true, + "Documentation": true, + "Maintenance": true, +} + +// changelogEntries returns the categorized bullets in the Unreleased section. +// Historical release sections are intentionally ignored: the policy requires a +// new reader-facing entry for the change being prepared, not rewritten history. +func changelogEntries(value []byte) (map[string]int, error) { + entries := map[string]int{} + inUnreleased := false + category := "" + foundUnreleased := false + for _, rawLine := range strings.Split(strings.ReplaceAll(string(value), "\r\n", "\n"), "\n") { + line := strings.TrimSpace(rawLine) + if strings.HasPrefix(line, "## ") { + if line == "## Unreleased" { + if foundUnreleased { + return nil, fmt.Errorf("%s must contain exactly one ## Unreleased section", changelogPath) + } + foundUnreleased = true + inUnreleased = true + category = "" + continue + } + if inUnreleased { + inUnreleased = false + } + continue + } + if !inUnreleased || line == "" { + continue + } + if strings.HasPrefix(line, "### ") { + category = strings.TrimSpace(strings.TrimPrefix(line, "### ")) + if !changelogCategories[category] { + return nil, fmt.Errorf("%s uses unsupported Unreleased category %q", changelogPath, category) + } + continue + } + if strings.HasPrefix(line, "-") { + entry := strings.TrimSpace(strings.TrimPrefix(line, "-")) + if category == "" { + return nil, fmt.Errorf("%s Unreleased entries must appear under an allowed category", changelogPath) + } + if entry == "" { + return nil, fmt.Errorf("%s contains an empty Unreleased entry", changelogPath) + } + entries[category+"\x00"+entry]++ + } + } + if !foundUnreleased { + return nil, fmt.Errorf("%s must contain a ## Unreleased section", changelogPath) + } + return entries, nil +} + +func readFileAtCommit(repo, commit, path string) ([]byte, bool, error) { + command := exec.Command("git", "-C", repo, "show", commit+":"+path) + value, err := command.Output() + if err == nil { + return value, true, nil + } + if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() != 0 { + return nil, false, nil + } + return nil, false, fmt.Errorf("cannot read %s at base commit: %w", path, err) +} + +func validateChangelogChange(repo, baseCommit string, config ProjectConfig) error { + if !config.Workflow.MaintainChangelog { + return nil + } + current, err := os.ReadFile(filepath.Join(repo, changelogPath)) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("changelog policy requires %s with a new entry under ## Unreleased", changelogPath) + } + return err + } + currentEntries, err := changelogEntries(current) + if err != nil { + return err + } + baseEntries := map[string]int{} + base, exists, err := readFileAtCommit(repo, baseCommit, changelogPath) + if err != nil { + return err + } + if exists { + parsed, parseErr := changelogEntries(base) + if parseErr == nil { + baseEntries = parsed + } + } + for entry, count := range currentEntries { + if count > baseEntries[entry] { + return nil + } + } + return fmt.Errorf("changelog policy requires a new categorized entry under ## Unreleased in %s", changelogPath) +} + +// changelogComparisonBase makes each managed slice prove its own entry. Later +// slices compare with the previous slice's reviewed head, even when both slices +// use the same Git base and earlier Unreleased entries are still present. +func changelogComparisonBase(repo, feature, mergeBase string) (string, error) { + if strings.TrimSpace(feature) == "" { + return mergeBase, nil + } + state, err := LoadDeliveryState(repo, feature) + if err != nil { + return "", err + } + if state.ActiveIndex == 0 { + return mergeBase, nil + } + if state.ActiveIndex >= len(state.Slices) { + return "", fmt.Errorf("delivery %s has no active slice for changelog comparison", feature) + } + previous := state.Slices[state.ActiveIndex-1] + receipt, err := readDeliveryReceipt(repo, feature, previous.ID, "review") + if err != nil { + return "", fmt.Errorf("cannot establish changelog baseline for delivery slice %s: %w", state.Slices[state.ActiveIndex].ID, err) + } + if strings.TrimSpace(receipt.HeadCommit) == "" { + return "", fmt.Errorf("previous delivery slice %s has no reviewed head commit", previous.ID) + } + return receipt.HeadCommit, nil +} diff --git a/boatstack/changelog_test.go b/boatstack/changelog_test.go new file mode 100644 index 0000000..747758f --- /dev/null +++ b/boatstack/changelog_test.go @@ -0,0 +1,138 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func changelogConfig() ProjectConfig { + config := testConfig() + config.Workflow.MaintainChangelog = true + return config +} + +func writeChangelog(t *testing.T, repo, value string) { + t.Helper() + if err := os.WriteFile(filepath.Join(repo, changelogPath), []byte(value), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestChangelogPolicyAcceptsExistingAndFirstEntries(t *testing.T) { + for _, test := range []struct { + name string + base string + head string + }{ + { + name: "existing changelog", + base: "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n\n## 1.0.0\n\n- First release.\n", + head: "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n- New reader-visible capability.\n\n## 1.0.0\n\n- First release.\n", + }, + { + name: "first changelog", + head: "# Changelog\n\n## Unreleased\n\n### Maintenance\n\n- Document the supported delivery workflow.\n", + }, + { + name: "existing legacy changelog adopts policy", + base: "# Changes\n\n## 1.0.0\n\n- First release.\n", + head: "# Changes\n\n## Unreleased\n\n### Changed\n\n- Adopt readable unreleased entries.\n\n## 1.0.0\n\n- First release.\n", + }, + } { + t.Run(test.name, func(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 test.base != "" { + writeChangelog(t, repo, test.base) + } else if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + base := runGit(t, repo, "rev-parse", "HEAD") + writeChangelog(t, repo, test.head) + if err := validateChangelogChange(repo, base, changelogConfig()); err != nil { + t.Fatalf("valid changelog rejected: %v", err) + } + }) + } +} + +func TestChangelogPolicyRejectsMissingMalformedAndHistoricalOnlyChanges(t *testing.T) { + base := "# Changelog\n\n## Unreleased\n\n### Added\n\n- Existing capability.\n\n## 1.0.0\n\n- First release.\n" + tests := []struct { + name string + head string + want string + }{ + {name: "no new entry", head: base, want: "new categorized entry"}, + {name: "historical only", head: strings.Replace(base, "- First release.", "- First release.\n- Rewritten history.", 1), want: "new categorized entry"}, + {name: "entry outside category", head: "# Changelog\n\n## Unreleased\n\n- Missing category.\n", want: "allowed category"}, + {name: "empty entry", head: "# Changelog\n\n## Unreleased\n\n### Fixed\n\n- \n", want: "empty"}, + {name: "unsupported category", head: "# Changelog\n\n## Unreleased\n\n### Internal\n\n- Hidden work.\n", want: "unsupported"}, + } + for _, test := range tests { + t.Run(test.name, func(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") + writeChangelog(t, repo, base) + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + baseCommit := runGit(t, repo, "rev-parse", "HEAD") + writeChangelog(t, repo, test.head) + if err := validateChangelogChange(repo, baseCommit, changelogConfig()); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("expected %q failure, got %v", test.want, err) + } + }) + } + + 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", ".") + runGit(t, repo, "commit", "-m", "base") + if err := validateChangelogChange(repo, runGit(t, repo, "rev-parse", "HEAD"), changelogConfig()); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") { + t.Fatalf("missing changelog did not fail: %v", err) + } +} + +func TestDisabledChangelogPolicyLeavesRepositoriesUnchanged(t *testing.T) { + if err := validateChangelogChange(t.TempDir(), "unused", testConfig()); err != nil { + t.Fatalf("disabled changelog policy affected repository: %v", err) + } +} + +func TestAdHocPRContextEnforcesConfiguredChangelog(t *testing.T) { + repo := prTestRepo(t) + configPath := filepath.Join(repo, ".product-loop", "project.json") + config := changelogConfig() + config.Project.DefaultBranch = "main" + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, value, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".product-loop/project.json") + runGit(t, repo, "commit", "-m", "enable changelog policy") + if _, err := PreparePRContext(PRContextOptions{Repo: repo}); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") { + t.Fatalf("ad-hoc PR ignored missing changelog: %v", err) + } + writeChangelog(t, repo, "# Changelog\n\n## Unreleased\n\n### Added\n\n- Make reviewer-visible behavior predictable.\n") + runGit(t, repo, "add", changelogPath) + runGit(t, repo, "commit", "-m", "add changelog entry") + if _, err := PreparePRContext(PRContextOptions{Repo: repo}); err != nil { + t.Fatalf("ad-hoc PR rejected valid changelog: %v", err) + } +} diff --git a/boatstack/delivery.go b/boatstack/delivery.go index 986710f..59a5c03 100644 --- a/boatstack/delivery.go +++ b/boatstack/delivery.go @@ -612,6 +612,25 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error 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) } + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return DeliveryGateReceipt{}, fmt.Errorf("review requires a valid Boatstack project configuration: %w", configErr) + } + baseCommit, baseErr := resolveBaseCommit(repo, base) + if baseErr != nil { + return DeliveryGateReceipt{}, baseErr + } + mergeBase, mergeErr := gitCommand(repo, "merge-base", baseCommit, "HEAD") + if mergeErr != nil || mergeBase == "" { + return DeliveryGateReceipt{}, fmt.Errorf("cannot determine changelog diff against %s", base) + } + changelogBase, changelogBaseErr := changelogComparisonBase(repo, options.Feature, mergeBase) + if changelogBaseErr != nil { + return DeliveryGateReceipt{}, changelogBaseErr + } + if changelogErr := validateChangelogChange(repo, changelogBase, config); changelogErr != nil { + return DeliveryGateReceipt{}, changelogErr + } } evidencePath := strings.TrimSpace(options.EvidencePath) if evidencePath == "" { diff --git a/boatstack/delivery_test.go b/boatstack/delivery_test.go index b20ebd2..56b7340 100644 --- a/boatstack/delivery_test.go +++ b/boatstack/delivery_test.go @@ -49,14 +49,27 @@ func TestDeliverySlicesPartitionTasksAndRejectForwardDependencies(t *testing.T) } func activateTwoSliceDelivery(t *testing.T) (string, string) { + return activateTwoSliceDeliveryWithChangelog(t, false) +} + +func activateTwoSliceDeliveryWithChangelog(t *testing.T, maintainChangelog bool) (string, string) { t.Helper() - repo := prTestRepo(t) + repo := prTestRepoConfigured(t, func(config *ProjectConfig) { + config.Workflow.MaintainChangelog = maintainChangelog + }) feature := "phased-feature" directory := filepath.Join(repo, ".product-loop", "features", feature) if err := os.MkdirAll(directory, 0o755); err != nil { t.Fatal(err) } plan := twoSlicePlan() + if maintainChangelog { + for _, task := range plan["tasks"].([]any) { + item := task.(map[string]any) + paths := item["affected_paths"].([]any) + item["affected_paths"] = append(paths, changelogPath) + } + } 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 { @@ -88,6 +101,43 @@ func activateTwoSliceDelivery(t *testing.T) (string, string) { return repo, feature } +func TestManagedReviewRequiresChangelogEntryAndBindsItToTestEvidence(t *testing.T) { + repo, feature := activateTwoSliceDeliveryWithChangelog(t, true) + options := DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Status: "PASS"} + options.Gate = "test" + if _, err := RecordDeliveryGate(options); err != nil { + t.Fatal(err) + } + options.Gate = "review" + if _, err := RecordDeliveryGate(options); err == nil || !strings.Contains(err.Error(), "requires CHANGELOG.md") { + t.Fatalf("managed review ignored missing changelog: %v", err) + } + writeChangelog(t, repo, "# Changelog\n\n## Unreleased\n\n### Added\n\n- Make the first delivery outcome available.\n") + runGit(t, repo, "add", changelogPath) + runGit(t, repo, "commit", "-m", "add first slice changelog entry") + if _, err := RecordDeliveryGate(options); err == nil || !strings.Contains(err.Error(), "changed after the test gate") { + t.Fatalf("changelog edit did not invalidate test evidence: %v", err) + } + options.Gate = "test" + if _, err := RecordDeliveryGate(options); err != nil { + t.Fatal(err) + } + options.Gate = "review" + if _, err := RecordDeliveryGate(options); err != nil { + t.Fatalf("managed review rejected valid changelog entry: %v", err) + } + if err := MarkDeliveryPublished(repo, feature, "phase-one", "https://example.invalid/pr/1"); err != nil { + t.Fatal(err) + } + base, err := changelogComparisonBase(repo, feature, runGit(t, repo, "merge-base", "main", "HEAD")) + if err != nil { + t.Fatal(err) + } + if err := validateChangelogChange(repo, base, changelogConfig()); err == nil || !strings.Contains(err.Error(), "new categorized entry") { + t.Fatalf("second slice reused the first slice changelog entry: %v", err) + } +} + 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") { diff --git a/boatstack/export.go b/boatstack/export.go index 81cd99d..45217d6 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -236,12 +236,12 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte operations := map[string]string{ "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from FEATURE_COMPLETE, which responds Feature complete and requires no action. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", - "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.", + "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. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. 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. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. 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 and render the one next action as: Reply `a` to approve. 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. 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.", + "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. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under CHANGELOG.md ## Unreleased before recording test evidence; if the file is absent, create the documented skeleton and first categorized entry. 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.", "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "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.", + "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. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact rather than commits, PR metadata, artifacts, or test commands. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff and a missing or malformed required changelog entry. 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 and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full 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 render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full 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.", diff --git a/boatstack/export_test.go b/boatstack/export_test.go index db11c82..08beaa8 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -357,6 +357,23 @@ func TestExportAndDriftCheck(t *testing.T) { } } +func TestExportPreservesOptionalChangelogPolicy(t *testing.T) { + config := testConfig() + config.Workflow.MaintainChangelog = true + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack") + if err != nil { + t.Fatal(err) + } + project := string(bundle.Files[".product-loop/project.json"]) + if !strings.Contains(project, `"maintain_changelog": true`) { + t.Fatalf("generated project configuration lost changelog policy: %s", project) + } +} + func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { config := testConfig() raw, err := MarshalJSON(config) diff --git a/boatstack/init_test.go b/boatstack/init_test.go index 2c42ff8..3902931 100644 --- a/boatstack/init_test.go +++ b/boatstack/init_test.go @@ -70,6 +70,9 @@ func TestRuntimeFreeInit(t *testing.T) { if strings.Contains(string(configValue), `"status"`) { t.Fatal("machine-local integration status leaked into repository configuration") } + if !strings.Contains(string(configValue), `"maintain_changelog": false`) { + t.Fatal("fresh initialization did not default changelog maintenance off") + } installValue, _ := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) if !strings.Contains(string(installValue), `"binary_sha256"`) || !strings.Contains(string(installValue), `"integrations"`) { t.Fatal("local install lock did not record binary and integration state") diff --git a/boatstack/pr.go b/boatstack/pr.go index 29a7b9b..2ee5f18 100644 --- a/boatstack/pr.go +++ b/boatstack/pr.go @@ -403,6 +403,13 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if len(changed) == 0 { return PRContext{}, fmt.Errorf("branch has no committed product changes relative to %s", base) } + changelogBase, err := changelogComparisonBase(repo, options.Feature, mergeBaseCommit) + if err != nil { + return PRContext{}, err + } + if err := validateChangelogChange(repo, changelogBase, config); err != nil { + return PRContext{}, err + } diffStat, err := productDiffStat(repo, mergeBaseCommit) if err != nil { return PRContext{}, err diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go index db19e3e..d2b81fe 100644 --- a/boatstack/pr_test.go +++ b/boatstack/pr_test.go @@ -21,6 +21,10 @@ func runGit(t *testing.T, repo string, arguments ...string) string { } func prTestRepo(t *testing.T) string { + return prTestRepoConfigured(t, nil) +} + +func prTestRepoConfigured(t *testing.T, configure func(*ProjectConfig)) string { t.Helper() repo := t.TempDir() runGit(t, repo, "init", "-b", "main") @@ -30,6 +34,9 @@ func prTestRepo(t *testing.T) string { config.Project.DefaultBranch = "main" config.Project.Context = []string{"README.md"} config.Project.HighRiskPaths = []string{"feature.go"} + if configure != nil { + configure(&config) + } value, err := MarshalJSON(config) if err != nil { t.Fatal(err) diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index d0473ec..22a5168 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -179,6 +179,10 @@ never forward. Optional base/head branch names are constraints, not permission t create or push those branches. Approval accepts the delivery structure but does not authorize any PR mutation. +When `workflow.maintain_changelog` is enabled, every delivery slice includes +`CHANGELOG.md` in its affected paths. This is product-owned reader documentation, +not a generated Boatstack artifact. + 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: @@ -247,6 +251,13 @@ 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. +When `workflow.maintain_changelog` is enabled, update `CHANGELOG.md` before +recording test evidence. Add a concise bullet under `## Unreleased` and one of +`Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, or +`Maintenance`. Describe the actual reader-visible outcome, not the commit, PR, +Boatstack artifacts, or test commands. If the file does not exist, create the +documented skeleton and its first entry. + ### `BUILD -> TEST_GATE` Crossing this boundary ends the requirement to keep loading or checking the source Plan-mode file. Its recorded path and hash preserve provenance. Subsequent gates judge the approved intent against the actual diff and evidence. @@ -277,6 +288,10 @@ On pass, invoke the same recorder for `review`. It accepts only the active slice 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. +With changelog maintenance enabled, the review recorder also compares the merge-base +and current `CHANGELOG.md`. It requires a new categorized `Unreleased` bullet and the +reviewer checks that its wording is supported by the actual diff. + ### `REVIEW_GATE -> SHIP_GATE` Require: @@ -330,7 +345,9 @@ There is no public `/pr-brief` operation. When the user asks in natural language 2. store the exact preview at `.product-loop/pr-briefs//pr.md`; 3. use the same reviewer-first format, but mark unavailable approval and gate evidence `NOT_VERIFIED`; 4. never claim that Boatstack approved the work or that an unrun gate passed; -5. preview first, then require `o` to open or `u` to update the PR and recheck the diff before publication. +5. when `workflow.maintain_changelog` is enabled, require a new categorized + `CHANGELOG.md` entry under `## Unreleased`; +6. preview first, then require `o` to open or `u` to update the PR and recheck the diff before publication. Adaptive sections for security/privacy, migrations, UI evidence, or operations appear only when relevant. Model attribution belongs inside collapsed provenance. If GitHub CLI authentication is unavailable, keep the validated preview and provide one manual publication action instead of losing the work. diff --git a/boatstack/runtime.go b/boatstack/runtime.go index eaa23d6..6b87a02 100644 --- a/boatstack/runtime.go +++ b/boatstack/runtime.go @@ -49,6 +49,7 @@ type Workflow struct { HumanPlanApproval bool `json:"human_plan_approval"` IndependentReviewForHighRisk bool `json:"independent_review_for_high_risk"` AllowPassWithGaps bool `json:"allow_pass_with_gaps"` + MaintainChangelog bool `json:"maintain_changelog"` } type IntegrationState struct { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index b16ad3a..d45948e 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -96,7 +96,7 @@ subject to acceptance criteria pass approval is current ``` -That is why context trimming is not automatically an optimization. If removing state increases rework or false acceptance, total cost rises. The canonical runtime references are approximately **10650 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 **10896 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`af3a40320dd2525fccc99e67c7ca26a43ec7ded8`](https://github.com/operatorstack/intelligence-flow/tree/af3a40320dd2525fccc99e67c7ca26a43ec7ded8/labs/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`3c8217d87c7117173b02ce29cae5316ccb6beca4`](https://github.com/operatorstack/intelligence-flow/tree/3c8217d87c7117173b02ce29cae5316ccb6beca4/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/getting-started.md b/docs/getting-started.md index c86dea3..e4c48d7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -172,6 +172,34 @@ Use Cursor, Codex, or Claude's normal transition out of Plan mode, then run `/bu If the host is still read-only, Boatstack reports that it is ready for build without creating compiled state. Switch modes and rerun `/build`. +### Keep a repository changelog + +This policy is optional and disabled by default. To require readable changelog entries, set this repository-owned configuration in `.boatstack-project.json`, then regenerate the Boatstack export through the normal reviewed configuration update: + +```json +{ + "workflow": { + "maintain_changelog": true + } +} +``` + +If `CHANGELOG.md` already exists, keep its released history and add a new bullet under `## Unreleased`. Boatstack compares the branch with its merge base, so editing only an older release does not satisfy the policy. + +If the file does not exist, the first managed delivery slice or Boatstack-prepared ad-hoc PR creates it as user-owned Markdown: + +```markdown +# Changelog + +## Unreleased + +### Added + +- Explain the reader-visible capability or outcome. +``` + +Supported categories are `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Documentation`, and `Maintenance`. Write about the actual outcome rather than commits, PR numbers, generated artifacts, or test commands. Every slice of a multi-PR delivery needs its own entry. Boatstack installation and update PRs are exempt. + ## 5. Prove, review, and prepare the PR Run the remaining gates: diff --git a/docs/public-claims.json b/docs/public-claims.json index 40101f5..34557f2 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "af3a40320dd2525fccc99e67c7ca26a43ec7ded8", + "source_commit": "3c8217d87c7117173b02ce29cae5316ccb6beca4", "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:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "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:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "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:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "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:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "reviewer-ready-pr", @@ -57,7 +57,7 @@ "readable_evidence": "why-these-steps.md#reviewer-ready-pr", "implementation": ["../boatstack/pr.go", "getting-started.md"], "verification": ["../boatstack/pr_test.go"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "phase-scoped-delivery", @@ -68,7 +68,7 @@ "readable_evidence": "why-these-steps.md#phase-scoped-delivery", "implementation": ["../boatstack/delivery.go", "../boatstack/safety.go", "../boatstack/hooks.go", "../boatstack/references/workflow.md"], "verification": ["../boatstack/delivery_test.go", "../boatstack/pr_test.go"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "model-neutral-contract", @@ -79,7 +79,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md", "../boatstack/references/workflow.md"], "verification": ["../boatstack/export_test.go", "../boatstack/planning_test.go"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "cross-model-failures", @@ -90,7 +90,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "lower-cost-outcomes", @@ -101,7 +101,7 @@ "readable_evidence": "why-these-steps.md#model-choice-and-budget", "implementation": ["research-and-design.md"], "verification": ["benchmark-corpus-audit.md", "benchmark-submission-audit.md"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "git-worktree-activation", @@ -112,7 +112,7 @@ "readable_evidence": "why-these-steps.md#git-worktree-activation", "implementation": ["../boatstack/runtime_cache.go", "../boatstack/hooks.go"], "verification": ["../boatstack/runtime_cache_test.go", "../boatstack/hooks_test.go"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" }, { "id": "visible-updates", @@ -123,7 +123,7 @@ "readable_evidence": "why-these-steps.md#visible-updates", "implementation": ["../boatstack/update.go", "../boatstack/init.go"], "verification": ["../boatstack/update_test.go", "../boatstack/init_test.go", "../boatstack/export_test.go"], - "last_verified_version": "source:af3a40320dd2525fccc99e67c7ca26a43ec7ded8" + "last_verified_version": "source:3c8217d87c7117173b02ce29cae5316ccb6beca4" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index ee2cc6d..dddecfc 100644 --- a/labs/diagram-json/plan.lock.json +++ b/labs/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "labs/diagram-json/plan.md", "plan_sha256": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "schema_version": 1, - "source_commit": "af3a40320dd2525fccc99e67c7ca26a43ec7ded8", + "source_commit": "3c8217d87c7117173b02ce29cae5316ccb6beca4", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/project.example.json b/project.example.json index b59db1d..b65c709 100644 --- a/project.example.json +++ b/project.example.json @@ -24,7 +24,8 @@ "workflow": { "human_plan_approval": true, "independent_review_for_high_risk": true, - "allow_pass_with_gaps": true + "allow_pass_with_gaps": true, + "maintain_changelog": false }, "integrations": { "gstack": { diff --git a/release-notes/2026-07-19-optional-repository-changelog.md b/release-notes/2026-07-19-optional-repository-changelog.md new file mode 100644 index 0000000..140235e --- /dev/null +++ b/release-notes/2026-07-19-optional-repository-changelog.md @@ -0,0 +1,9 @@ +### Keep a readable repository changelog + +Repositories can now opt into changelog maintenance with +`workflow.maintain_changelog`. When enabled, Boatstack requires each managed +delivery slice and Boatstack-prepared ad-hoc PR to add a categorized entry under +`CHANGELOG.md` → `Unreleased`, giving readers a useful history without requiring +them to inspect PRs or delivery artifacts. Existing repositories remain unchanged +unless they enable the policy, and Boatstack installation and update PRs are +exempt.