From 41f5a9096e7818f72d01d3f6fbeee0591184710e Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Sun, 26 Jul 2026 12:08:56 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ 64d586468baf --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 40 +- .../coverage_conformance_test.go | 1 + boatstack/cmd/boatstack-helper/main.go | 27 +- boatstack/config_documentation_test.go | 1 + boatstack/denial.go | 8 +- boatstack/export.go | 6 +- boatstack/next.go | 10 + boatstack/references/config-schema.md | 4 +- boatstack/runtime.go | 1 + boatstack/safety.go | 10 + boatstack/safety_test.go | 41 ++ boatstack/workspace.go | 396 +++++++++++++++--- boatstack/workspace_reap_test.go | 330 +++++++++++++++ boatstack/workspace_test.go | 6 +- docs/configuration.md | 6 +- docs/evidence-engineered-coding.md | 2 +- docs/public-claims.json | 24 +- labs/diagram-json/plan.lock.json | 2 +- release-notes/2026-07-26-workspace-reap.md | 25 ++ 20 files changed, 848 insertions(+), 94 deletions(-) create mode 100644 boatstack/workspace_reap_test.go create mode 100644 release-notes/2026-07-26-workspace-reap.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7de1bc8..b48e658 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/dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4/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/64d586468baf5a7b45a2debbfc747b0d9ec9a233/labs/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/UPSTREAM.json b/UPSTREAM.json index 52ad814..5256e34 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "f4661ce261ab80742282fcfe23bc42a1f155dc0ea4a2484ff6333493e2d059d1", + "CONTRIBUTING.md": "2c757dbb0038f032fe541d105efc60c0d34378aec0bf2dd87ac42c738495ce34", "README.md": "3ce3e95e511089b44e946a44b8d5f4f81d019ece5336db65b2cab1f9dc4d4dad", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -40,14 +40,14 @@ "boatstack/capture_test.go": "63fa1177738081f1e862364d7a4257f5e259f8e9c36276ba1775b8085b277105", "boatstack/changelog.go": "6b06be7cd9738de29ba6e87aa2569f3b027a2e618b04524f5abd7abaa17945bf", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "aff3bbada81820f9b77c0f4339c6e78e6489e1b82176b57129b5102664ada5a8", + "boatstack/cmd/boatstack-helper/coverage_conformance_test.go": "d7802c45129b3a14921120a0806d598a26ef052442539d789579eb13da16b7d6", "boatstack/cmd/boatstack-helper/flow.go": "5ab24541d3f85c2f442730d3122d18eb6676600bc11fde4805e803a25a8300c7", - "boatstack/cmd/boatstack-helper/main.go": "7575dfbf03fd1ca0f7f3f5d519750a5b1fa81ff02a89547c053d94769e580b1b", + "boatstack/cmd/boatstack-helper/main.go": "b3983742991ed193b6e13d9a84192bf4bfdc7536a8bd6b022255ca5ba4124982", "boatstack/cmd/boatstack-helper/main_test.go": "b36c52d6d5c9dd2428730de10ff18194b7e32a98722e41341c301c6f7a04cad5", "boatstack/command.go": "4726ac515dedab4947be7eb48f88c6cb8b53d674124504b69f03e6396b080ee8", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", "boatstack/compiled_artifact_resolution_test.go": "0748d67643263e698211eb04d46464e1dd3db15d94537f5fd5092b5aa689745b", - "boatstack/config_documentation_test.go": "0632366edc5e88145bb080083ea03c6515da07b0162ce404d63e51bb5bc0774e", + "boatstack/config_documentation_test.go": "aaecea04ee178ecf7872fff23a64014a3968dce1e7624e61a9453567e99969a9", "boatstack/decision.go": "257ca328da6ae19ab252f10ee5d06bd7daf49dd8141d083ab1b32f106ea7a94c", "boatstack/decision_test.go": "1a92ff832610f9559bd47ccac7fc1755a8b4f8261c35bc72a092830dff05f7c0", "boatstack/delivery.go": "86150374b14982b1e589714d6ef6230348ef57b6824d4e1552b72289c044af4b", @@ -57,11 +57,11 @@ "boatstack/delivery_reactivation_test.go": "573a2dba0034bc4290478414e3bdd8670b06a326128eb0295d77e748ecc8689e", "boatstack/delivery_test.go": "45c48ff7581c911bcaf821c3e4241d4ae2a9bb4aa682485cc58b6ad8fe1c85bf", "boatstack/deliverycontrol_parity_test.go": "f8662cfc35043395a0e1eef8a87051c2120752f38b09c56b78f82896008f1b65", - "boatstack/denial.go": "10ac51d100ae5d6cd167d63d7c1c7721c96e09c29e6290177a9bda13b61427f9", + "boatstack/denial.go": "656dc5e71a11daba25e7a23599f2e1aafa7a7439589af943cdccc72154a45816", "boatstack/denial_test.go": "c480c0b2a489838b22b4d5ec20cc30ef1859cc0820a75974bc56a46c31f90041", "boatstack/docs/control-law-scoping.md": "0ae984821248eabda8c0eeaf201b367991e6742984e7c718df20ecc24caee475", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "b3e28b571024b1b7a97b28f226f7c89734c95dcf1854c3d1a6dc672f34d4ded7", + "boatstack/export.go": "1a01d19ac6e8418febf93f9ebf1a466a46da4b09eea7bafe6ccfb6cfb0f73a6d", "boatstack/export_test.go": "dce5aa3ab5499c82d05859cf86b46dfcee308482491366d83e10ca3fb8605bb6", "boatstack/flow_coding.go": "9fa53a0204f98a25f97775c3acf37392a591c14ce850b44aa587b5806e770bb9", "boatstack/flow_coding_test.go": "dddcd7a85892d4fa10af42739d4c1ff265721b0313e27b6e7a1bbb019d5c3b51", @@ -115,7 +115,7 @@ "boatstack/mutation_test.go": "68d5049c7f96c1ac558e4c781151f67e8deee2f8d6b9bf293b90d44e769ef7c6", "boatstack/mutation_undo.go": "697d11b600a276ddbcabe6a9f8040d4f7283e017a0e8fd689ef53a274638946c", "boatstack/mutation_undo_test.go": "39540e717e3f2136bf975594043a3db9072b28ebe61c6cb0b982cea5e8b1e14e", - "boatstack/next.go": "39d813c96a6a5119efcabda0f59ce7c6faf91e5e76c77c7be999bf85f43de284", + "boatstack/next.go": "f11e1b5c93a362438663a663e126ea75fd1042453c907fc335fee543bf76c43d", "boatstack/next_banner_test.go": "c431a6987ed1e479442fc9f5db4371632880b92aa790fa9dd0f5285293352c41", "boatstack/next_test.go": "d442d22023831ba39fcfbbf73f1a2da4170a83fc2aab5ce1b44f10cf9d88e173", "boatstack/operation.go": "35142d24e166bc400229d233757b6ab185f94b01a3c11be49da33b7f123189a2", @@ -139,7 +139,7 @@ "boatstack/reexec_unix.go": "ff86157a9aa20c82a56fcd859b70669b7eacf4e0a9f61a4546ef33808437939e", "boatstack/reexec_windows.go": "f5335c8c28cb4e89048b058b1c4d12f78644f99acb4f6167ff60e622dfb9e742", "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", - "boatstack/references/config-schema.md": "0170b90f1d0a592f58e255ffeff642fa037676042443f74a0f1b6e39be5dbbb8", + "boatstack/references/config-schema.md": "fa5e09d008101957cf5577e9031de256ab682711c3fa21fa9494cd600ddbd2f0", "boatstack/references/failure-moves.md": "b65ef72035afa6ad0dce589a0b38f84bc40cde3864c9ecf973f08fc687f001c3", "boatstack/references/host-hook-contracts.md": "2a89d44d0e418a53f2e3b6300fed957cdf878f45ea97ce24b55b66065f0eaa1d", "boatstack/references/irreversible-operation-boundary.md": "e0076f0fea3bf729b2e9bdf353eaeaaf7cdafabfaf26b8d9b27287e5414c2441", @@ -150,12 +150,12 @@ "boatstack/repair_state_test.go": "f3779ac47c3db3927175a545728d3b2e020dbc85f41394d8235753b52afc3739", "boatstack/run.go": "74967ad5b3ed3847baffec1231aae69a81a70f9cce3a9412fa05bcfdc4eca6d1", "boatstack/run_test.go": "5b291510fa90cefdc26eb89e18a3443385456a6ebc73408325ac1945b7c084d6", - "boatstack/runtime.go": "d1e95895002ea2b27199b6e05b33c4c6e20f63455a44f63ca3cfeedecfc23420", + "boatstack/runtime.go": "11f741d45994c336e927121ede2481c94d5c33ed9c3beecf89f760450b304da3", "boatstack/runtime_cache.go": "89834409b426dce292fd810a091de1433fefbfba9249d8c1e31ccc40f0d5dbd6", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", "boatstack/runtime_provenance_test.go": "1d52f1e6b0691cf4667729cc9b9f3c55c128f0aa3321f3a2843a9aa6fd0e73dc", - "boatstack/safety.go": "5ea64b051d5409b1a4a1731135f95afae31465ea328722fbb8b193260a663de3", - "boatstack/safety_test.go": "01f28bc3bfcb6bdd47b307e309e36bbc1921b6426ad0b777d81fe4131200c37e", + "boatstack/safety.go": "f8a6b5426893b021c8dd5079432e541150136c65584feb3404afa6f7ff4aa52f", + "boatstack/safety_test.go": "2741610de4b8a47d66b1a5f18a028ab63417826789636587745b4e71dd2d59c3", "boatstack/safety_update_publisher_test.go": "ed3f8187036623694dfe7c395cdae00fdae14609bab6124d1fdfc6fe73fa2196", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", @@ -171,18 +171,19 @@ "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", "boatstack/visual_publisher.go": "330511d000e712fa37c52af17d59c8ec9cb41f49c773a517f856651e66a60d25", "boatstack/visual_publisher_test.go": "979f600edae00c77569ae753b80530e8cbf2e3b342995efcd992d351ff382eaf", - "boatstack/workspace.go": "91b343400b3506a6f516c28fabc3f1575f22024a5b19f934a020be660a20482e", + "boatstack/workspace.go": "c21f312a8b631b5cf65de397e24e1b9c7197ebcb73e5c26804e36f136f84fdbb", + "boatstack/workspace_reap_test.go": "a08f41ee1b2d158efe878082b5fcd65928e723afce4dd19e3a5a8a26b8f10874", "boatstack/workspace_sync.go": "0cc2f03fd1aa57c66b3d603d5ef30aa820f14ab60771a795cf10c46f3c9a71b8", "boatstack/workspace_sync_test.go": "a5fd532d23a6675c96fc5eb29a149c812717f21237a4050ae5f41afb34601273", - "boatstack/workspace_test.go": "ea022ab13cf593c84cf053eca8dad345aae656d3e089b0ae68a4ba7e7cdc87c6", + "boatstack/workspace_test.go": "e0e38c14b7e218053eb6e8b900412ed4c6da8a19ecdde26cb22b1a2af7a3695d", "docs/account-recovery-walkthrough.md": "676034974594a7d1a559b24dbed31d7ccc429eb81404b203ca07bbdaa19ec3d3", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/configuration.md": "df054f49d532c8b1b7d94184810d1b3b5bf18cdc30eb985b4b6d0639162e341a", - "docs/evidence-engineered-coding.md": "3c7c1ba355eb11306ed0990b4fbf7b139bd733dd356fd596c0a81bed15460ae3", + "docs/configuration.md": "060775c73431f28bd16066bdf9e0f89034d2855c7ca0f5544f660d24b91211d0", + "docs/evidence-engineered-coding.md": "b9851a7c40f39f4f799f8576292ae3c15cdfbf935ca1baa28ab24ea8e556665c", "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", "docs/getting-started.md": "1dd4f4e2e636cc5adfc2f79939629701e171087c3d5e558cf919548b9224adfd", - "docs/public-claims.json": "388c9d265b989d94075ca0d3fbe237414d8cfc9bc3edf5746552d42049321f3b", + "docs/public-claims.json": "9cee0f39f2389c4a1f6bda2fc4186f143ed8065e99a58623c185eed6326362a3", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "8d78678108f0a6c924e1ff9b32c0f81aae9d1f779e0082843b6f99ad993ae2b6", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", @@ -196,7 +197,7 @@ "labs/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "labs/diagram-json/compiled/tasks.json": "88f60851abf79d851e9fccc754ff3040034ae595306bc87d64784c19eb403e71", "labs/diagram-json/compiled/test-matrix.json": "424657ff505768e50fa113801fd8363364a18269d5297480907a993d44063a39", - "labs/diagram-json/plan.lock.json": "9a1c7fded38aaa2c498f9cfb18de9cc7ee2428f299052f266831bd1bee63b9f7", + "labs/diagram-json/plan.lock.json": "db3338f91bb12226693d021033cd9228dc4747a917d5c00341fc4d48e3a77ae8", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -303,12 +304,13 @@ "release-notes/2026-07-26-calm-denials.md": "9e4a5fc23b02500cf2f124cb462a8d863ed953a899c9485a81e4971dd89c9576", "release-notes/2026-07-26-guard-etxtbsy-retry.md": "4238591804be62f8b9a76dd5cda18923af60ef67932d40d70cab0d815124bcaf", "release-notes/2026-07-26-guard-hydrate-double-check.md": "83a5591aba6bf30c9f4008ba8d26bf1994ef3fd61145f46fcdd1678912b9990b", - "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a" + "release-notes/2026-07-26-hidden-jflow-design-note.md": "f60ed9dbbfb46a172ac9d33dd758a3166f820007b1673029f29f0fbefa0e5c0a", + "release-notes/2026-07-26-workspace-reap.md": "e691d6a1c232cf218157880655413005fcb2c4f3113ededffdb80899a5054bb8" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", + "commit": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go index 8ce4014..b80e728 100644 --- a/boatstack/cmd/boatstack-helper/coverage_conformance_test.go +++ b/boatstack/cmd/boatstack-helper/coverage_conformance_test.go @@ -70,6 +70,7 @@ var nonDeliveryVerbs = map[string]bool{ "bootstrap-safety-hook": true, "workspace-cut": true, "workspace-cleanup": true, + "workspace-reap": true, "workspace-sync": true, // Flow layer itself is read-only navigation over the machine, not a transition. "flow": true, diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 6aa9ea6..740918e 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -1189,6 +1189,29 @@ func workspaceCleanupCommand(arguments []string) int { return 0 } +func workspaceReapCommand(arguments []string) int { + flags := flag.NewFlagSet("workspace-reap", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose terminal workspaces should be reclaimed") + confirm := flags.Bool("confirm", false, "operator confirmation to reclaim the merged or abandoned workspaces") + force := flags.Bool("force", false, "override the merge gate and discard uncommitted or unmerged work") + if err := flags.Parse(arguments); err != nil { + return 2 + } + result, err := boatstack.ReapWorkspaces(boatstack.WorkspaceReapOptions{Repo: *repo, Confirm: *confirm, Force: *force}) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return 0 +} + func workspaceStatusCommand(arguments []string) int { flags := flag.NewFlagSet("workspace-status", flag.ContinueOnError) repo := flags.String("repo", ".", "repository to inspect") @@ -1236,7 +1259,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1326,6 +1349,8 @@ func run() int { return workspaceCutCommand(os.Args[2:]) case "workspace-cleanup": return workspaceCleanupCommand(os.Args[2:]) + case "workspace-reap": + return workspaceReapCommand(os.Args[2:]) case "workspace-status": return workspaceStatusCommand(os.Args[2:]) case "workspace-sync": diff --git a/boatstack/config_documentation_test.go b/boatstack/config_documentation_test.go index d8838bd..3ea7db1 100644 --- a/boatstack/config_documentation_test.go +++ b/boatstack/config_documentation_test.go @@ -131,6 +131,7 @@ func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) "workspace.cleanup_after", "workspace.enabled", "workspace.mode", + "workspace.reap", } document := publicConfigurationDocument(t) got := documentedConfigSurface(t, document, userConfigFieldMarkerPrefix) diff --git a/boatstack/denial.go b/boatstack/denial.go index 843ba36..d3bac40 100644 --- a/boatstack/denial.go +++ b/boatstack/denial.go @@ -337,7 +337,7 @@ func denialFor(host string, finding SafetyFinding) Denial { case "workflow-state-tamper": d.Qualifier = "managed runtime authority" - d.Detail = "Change `.git/boatstack/` only through the command that owns it — a build, test, review, or ship transition for delivery state, or `publish-update-pr` for a version update." + d.Detail = "Change `.git/boatstack/` only through the command that owns it — a build, test, review, or ship transition for delivery state, `publish-update-pr` for a version update, or `workspace-reap` to reclaim a finished worktree and its runtime state." d.Reassurance = "Nothing was written; your runtime state is unchanged." d.Hint = "boatstack-helper diagnose-hook" return d @@ -417,6 +417,12 @@ func denialFor(host string, finding SafetyFinding) Denial { d.Detail = "Invoke only the exact project-local workspace-sync helper for the current repository." d.Reassurance = reassureUntouched return d + + case "filesystem-destruction": + d.Qualifier = "recursive deletion" + d.Detail = "Recursive deletion of a broad or protected path is denied. To reclaim a finished managed worktree and its branch, use `workspace-reap` (or single-feature `workspace-cleanup`); Boatstack removes them through its own sanctioned actuator. For any other path, preserve current state and use fix-forward recovery — destructive deletion is operator-only outside the agent workflow." + d.Reassurance = reassureUntouched + return d } // operation-* fallthrough (operation-state-invalid and any other operation-*) diff --git a/boatstack/export.go b/boatstack/export.go index deab7b4..a7547a7 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -171,6 +171,9 @@ func validateWorkspaceConfig(workspace Workspace) error { if after := workspace.CleanupAfter; after != "" && after != "merge" && after != "ship" { return fmt.Errorf("workspace.cleanup_after must be \"merge\" or \"ship\"") } + if reap := workspace.Reap; reap != "" && reap != "confirm" && reap != "auto" && reap != "off" { + return fmt.Errorf("workspace.reap must be \"confirm\", \"auto\", or \"off\"") + } return nil } @@ -311,6 +314,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate. Respond Improvement proposed and make reviewing or authorizing the experiment the one next action.", "workspace-cut": "Cut a fresh managed workspace for an approved feature before building, so work never starts on a stale branch. Surfaced by boatstack-next at the approved-to-build transition when workspace.enabled and the working tree is still on the default branch; the user does not invoke it directly. Run the project-local helper workspace-cut --repo . --feature . It fetches origin, creates a new branch from the up-to-date default branch, and in worktree mode adds a linked worktree; it never rewrites history, reuses an existing branch, or names the workspace after the base branch. Report the created branch and, in worktree mode, its path, then continue to build on the new workspace.", "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the project-local helper workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", + "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the project-local helper workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } if contains(adapters, "cursor") { @@ -349,7 +353,7 @@ description: Use when the user asks what is next in Boatstack, asks Boatstack to # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation, which classifies the failure and produces a source plan to hand to auto-plan; it never edits code or advances a gate. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, root-cause, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, workspace-cleanup, and workspace-reap. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route bug diagnosis such as a stack trace or "why did this crash" to the read-only root-cause operation, which classifies the failure and produces a source plan to hand to auto-plan; it never edits code or advances a gate. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. %s diff --git a/boatstack/next.go b/boatstack/next.go index fbf333a..0fbdf86 100644 --- a/boatstack/next.go +++ b/boatstack/next.go @@ -338,6 +338,16 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { base.NextOperation = "workspace-cleanup" base.Reason = fmt.Sprintf("Feature %q is merged; its workspace on %q can be cleaned up.", completed[0].Feature, base.HeadBranch) } + // At the merge checkpoint, prefer the backlog sweep: if reaping is + // enabled and there are terminal Boatstack workspaces to reclaim, + // surface workspace-reap so one prompt clears the accumulated backlog + // rather than only the just-merged feature. + if reapEnabled(repo) { + if count := CountReclaimableWorkspaces(repo); count > 0 { + base.NextOperation = "workspace-reap" + base.Reason = fmt.Sprintf("Feature %q is merged; %d merged or abandoned Boatstack workspace(s) are reclaimable.", completed[0].Feature, count) + } + } } } else { branch, _ := gitCommand(repo, "branch", "--show-current") diff --git a/boatstack/references/config-schema.md b/boatstack/references/config-schema.md index 8de2e54..ca3a25c 100644 --- a/boatstack/references/config-schema.md +++ b/boatstack/references/config-schema.md @@ -25,6 +25,7 @@ boatstack-config-field:workspace.enabled boatstack-config-field:workspace.mode boatstack-config-field:workspace.cleanup boatstack-config-field:workspace.cleanup_after +boatstack-config-field:workspace.reap boatstack-config-field:adapters boatstack-config-field:integrations boatstack-config-field:integrations.*.requested @@ -80,8 +81,9 @@ This is the exhaustive serialization contract, not a list of recommended user ed - `enabled` (boolean, optional): Enables managed per-feature workspaces. Defaults to `false`. - `mode` (string, optional): `worktree` or `branch`. Defaults to `worktree` when workspace management is enabled. -- `cleanup` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. +- `cleanup` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs single-feature cleanup of the named workspace. - `cleanup_after` (string, optional): `merge` or `ship`. Defaults to `merge`. +- `reap` (string, optional): `confirm`, `auto`, or `off`. Defaults to `confirm`. Governs the post-merge sweep that reclaims all terminal (merged or abandoned) Boatstack workspaces at once. `confirm` prompts the operator once when reclaimable workspaces exist; `auto` reclaims them without prompting; `off` disables the sweep and its prompt. ### adapters Values diff --git a/boatstack/runtime.go b/boatstack/runtime.go index 30db7e7..6abf9b5 100644 --- a/boatstack/runtime.go +++ b/boatstack/runtime.go @@ -94,6 +94,7 @@ type Workspace struct { Mode string `json:"mode,omitempty"` // "worktree" | "branch" (default "worktree") Cleanup string `json:"cleanup,omitempty"` // "confirm" | "auto" | "off" (default "confirm") CleanupAfter string `json:"cleanup_after,omitempty"` // "merge" | "ship" (default "merge") + Reap string `json:"reap,omitempty"` // "confirm" | "auto" | "off" (default "confirm") } func ReadCanonical(path string) ([]byte, error) { diff --git a/boatstack/safety.go b/boatstack/safety.go index b013474..6c2d3ae 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -128,6 +128,16 @@ func controlledPhaseTransition(command, stage string) bool { if fields[1] == "undo" { return true } + // workspace-reap and workspace-cleanup are the sanctioned actuators that + // reclaim finished managed worktrees and branches. They mutate only + // Boatstack-owned workspace bookkeeping — never product source or delivery + // state — and self-guard (refusing the base branch, the current worktree, and + // unmerged or dirty work without an explicit force). They are stage-independent + // like undo: without this the pre-activation interlock would deny post-merge + // cleanup and force the operator to reclaim worktrees with raw, denied Git. + if fields[1] == "workspace-reap" || fields[1] == "workspace-cleanup" { + return true + } switch stage { case "DRAFT_PLAN": return fields[1] == "planning-write" || fields[1] == "record-approval" diff --git a/boatstack/safety_test.go b/boatstack/safety_test.go index b3c1c51..ae22155 100644 --- a/boatstack/safety_test.go +++ b/boatstack/safety_test.go @@ -73,6 +73,47 @@ func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { } } +// control-law: reap-mutates-only-through-the-sanctioned-actuator +// The sanctioned workspace-reap helper is allowed through the pre-activation +// interlock, while the raw destructive equivalents an operator would otherwise +// run by hand stay denied and are redirected to workspace-reap. +func TestReapHelperIsExemptWhileRawWorktreeRemovalStaysDenied(t *testing.T) { + repo := safetyTestRepo(t) + // A saved-but-unactivated plan latches the pre-activation interlock, so this + // proves the allowlist entry rather than merely the absence of a deny pattern. + writeValidSavedFeaturePlan(t, repo, "pending-feature") + helper := filepath.Join(repo, ".product-loop", "bin", helperName()) + if err := os.MkdirAll(filepath.Dir(helper), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + allowed := ".product-loop/bin/" + helperName() + " workspace-reap --repo . --confirm" + if findings := ClassifyCommand(repo, allowed); len(findings) != 0 { + t.Fatalf("sanctioned reap helper was denied: %#v", findings) + } + + // Recursive deletion of the Boatstack worktree directory stays denied. + rawFilesystem := ClassifyCommand(repo, "rm -rf .product-loop/worktrees/*") + if len(rawFilesystem) == 0 || rawFilesystem[0].Category != "filesystem-destruction" { + t.Fatalf("raw worktree deletion was not denied: %#v", rawFilesystem) + } + if message := denialMessage("cursor", rawFilesystem[0]); !strings.Contains(message, "workspace-reap") { + t.Fatalf("filesystem-destruction denial should redirect to workspace-reap: %s", message) + } + + // Touching a worktree's runtime ledger is workflow-state-tamper. + rawState := ClassifyCommand(repo, "rm -rf .git/worktrees/old-feature/boatstack") + if len(rawState) == 0 || rawState[0].Category != "workflow-state-tamper" { + t.Fatalf("raw runtime-state deletion was not denied: %#v", rawState) + } + if message := denialMessage("cursor", rawState[0]); !strings.Contains(message, "workspace-reap") { + t.Fatalf("workflow-state-tamper denial should mention workspace-reap: %s", message) + } +} + func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) { repo := safetyTestRepo(t) writeValidSavedFeaturePlan(t, repo, "pending-feature") diff --git a/boatstack/workspace.go b/boatstack/workspace.go index e2c7d27..9fb7d23 100644 --- a/boatstack/workspace.go +++ b/boatstack/workspace.go @@ -27,6 +27,7 @@ type ResolvedWorkspace struct { Mode string Cleanup string CleanupAfter string + Reap string } func resolveWorkspace(workspace Workspace) ResolvedWorkspace { @@ -35,6 +36,7 @@ func resolveWorkspace(workspace Workspace) ResolvedWorkspace { Mode: workspace.Mode, Cleanup: workspace.Cleanup, CleanupAfter: workspace.CleanupAfter, + Reap: workspace.Reap, } if resolved.Mode == "" { resolved.Mode = "worktree" @@ -45,6 +47,9 @@ func resolveWorkspace(workspace Workspace) ResolvedWorkspace { if resolved.CleanupAfter == "" { resolved.CleanupAfter = "merge" } + if resolved.Reap == "" { + resolved.Reap = "confirm" + } return resolved } @@ -58,6 +63,17 @@ func workspaceEnabled(repo string) bool { return policy.Enabled } +// reapEnabled reports whether the post-merge reap sweep is active — workspace +// management is on and workspace.reap is not "off". It swallows config errors as +// "off" so the read-only next surface never fails on a malformed project file. +func reapEnabled(repo string) bool { + policy, err := loadWorkspacePolicy(repo) + if err != nil { + return false + } + return policy.Enabled && policy.Reap != "off" +} + // needsFreshCut reports whether an approved feature still has to be moved off the // base branch onto its own fresh workspace. It is a local-only check: true only // when the feature has no existing branch or worktree and the working tree is @@ -260,6 +276,83 @@ func blockedCleanup(branch, reason string) WorkspaceCleanup { return WorkspaceCleanup{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Branch: branch, Reason: reason} } +// workspaceRemovalPlan is the outcome of the safety gates shared by cleanup and +// reap: whether the named workspace may be removed, and the merge state resolved +// while deciding (which selects force-deletion of a squash/rebase-merged branch). +type workspaceRemovalPlan struct { + Removable bool + Status string // BLOCKED when !Removable + Reason string + Merged bool +} + +// planWorkspaceRemoval applies the base-branch, current-branch, merge, dirty, and +// unmerged-commit gates that govern removing a single feature workspace. It never +// consults cleanup/reap mode or human confirmation and never mutates the +// repository; callers own policy and confirmation. cleanupAfter="ship" permits +// removing an unmerged branch (used for merged-optional and abandoned workspaces). +func planWorkspaceRemoval(repo, base, branch, worktreePath, cleanupAfter string, merged, force bool) workspaceRemovalPlan { + if branch == base { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Refusing to clean up the base branch %q.", base)} + } + if current, _ := workspaceGit(repo, "branch", "--show-current"); strings.TrimSpace(current) == branch && worktreePath == "" { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q is the current branch; switch away before cleaning it up.", branch)} + } + if cleanupAfter == "merge" && !merged && !force { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("PR for %q is not merged yet; keeping the workspace. Re-run with force to clean up early.", branch)} + } + // Refuse to discard work the user has not landed unless explicitly forced. + if !force { + if worktreePath != "" { + if dirty, _ := workspaceGit(worktreePath, "status", "--porcelain"); strings.TrimSpace(dirty) != "" { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Workspace %q has uncommitted changes; commit or discard them, or force cleanup.", branch)} + } + } + if !merged { + for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { + if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { + merged = true + break + } + } + if !merged && cleanupAfter != "ship" { + return workspaceRemovalPlan{Status: "BLOCKED", Reason: fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base)} + } + } + } + return workspaceRemovalPlan{Removable: true, Status: "VERIFIED", Merged: merged} +} + +// performWorkspaceRemoval removes the linked worktree (if any) and deletes the +// local branch in-process. It assumes planWorkspaceRemoval already cleared the +// safety gates; merged/force select force-deletion so a squash- or rebase-merged +// branch (whose local ref is not a local ancestor of the base) is still removable. +// The git it runs is the helper's own subprocess, never an agent shell command, so +// it is the sanctioned actuator the guard allows. +func performWorkspaceRemoval(repo, branch, worktreePath string, merged, force bool) (worktreeRemoved, branchDeleted bool, reason string, err error) { + if worktreePath != "" { + removeArgs := []string{"worktree", "remove", worktreePath} + if force { + removeArgs = append(removeArgs, "--force") + } + if _, e := workspaceGit(repo, removeArgs...); e != nil { + return false, false, "Boatstack could not remove the worktree: " + e.Error(), e + } + worktreeRemoved = true + } + if branchExists(repo, branch) { + deleteFlag := "-d" + if force || merged { + deleteFlag = "-D" + } + if _, e := workspaceGit(repo, "branch", deleteFlag, branch); e != nil { + return worktreeRemoved, false, "Boatstack could not delete the branch: " + e.Error(), e + } + branchDeleted = true + } + return worktreeRemoved, branchDeleted, "", nil +} + // CleanupFeatureWorkspace removes a finished workspace only when it is safe: the // PR must be merged (unless cleanup_after is "ship" or Force overrides), there // must be no uncommitted or unmerged work (unless Force), and confirm-mode must @@ -293,49 +386,19 @@ func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, } base := defaultPRBase(repo) - if branch == base { - return blockedCleanup(branch, fmt.Sprintf("Refusing to clean up the base branch %q.", base)), nil - } - if current, _ := workspaceGit(repo, "branch", "--show-current"); strings.TrimSpace(current) == branch && worktreePath == "" { - return blockedCleanup(branch, fmt.Sprintf("Branch %q is the current branch; switch away before cleaning it up.", branch)), nil - } - merged, source := workspaceMergeStatus(repo, branch, base) result := WorkspaceCleanup{ SchemaVersion: workspaceSchemaVersion, Branch: branch, Mode: policy.Mode, Merged: merged, MergeSource: source, } - if policy.CleanupAfter == "merge" && !merged && !options.Force { - result.VerificationStatus = "BLOCKED" - result.Reason = fmt.Sprintf("PR for %q is not merged yet; keeping the workspace. Re-run with force to clean up early.", branch) + plan := planWorkspaceRemoval(repo, base, branch, worktreePath, policy.CleanupAfter, merged, options.Force) + if !plan.Removable { + result.VerificationStatus = plan.Status + result.Reason = plan.Reason return result, nil } - - // Refuse to discard work the user has not landed unless explicitly forced. - if !options.Force { - if worktreePath != "" { - if dirty, _ := workspaceGit(worktreePath, "status", "--porcelain"); strings.TrimSpace(dirty) != "" { - result.VerificationStatus = "BLOCKED" - result.Reason = fmt.Sprintf("Workspace %q has uncommitted changes; commit or discard them, or force cleanup.", branch) - return result, nil - } - } - if !merged { - for _, target := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { - if _, err := workspaceGit(repo, "merge-base", "--is-ancestor", "refs/heads/"+branch, target); err == nil { - merged = true - break - } - } - if !merged && policy.CleanupAfter != "ship" { - result.VerificationStatus = "BLOCKED" - result.Reason = fmt.Sprintf("Branch %q has commits not merged into %s; force cleanup to discard them.", branch, base) - return result, nil - } - } - } - result.Merged = merged + result.Merged = plan.Merged if policy.Cleanup == "confirm" && !options.Confirm && !options.Force { result.VerificationStatus = "NEEDS_CONFIRMATION" @@ -343,31 +406,258 @@ func CleanupFeatureWorkspace(options WorkspaceCleanupOptions) (WorkspaceCleanup, return result, nil } - if worktreePath != "" { - removeArgs := []string{"worktree", "remove", worktreePath} - if options.Force { - removeArgs = append(removeArgs, "--force") + worktreeRemoved, branchDeleted, failReason, removeErr := performWorkspaceRemoval(repo, branch, worktreePath, plan.Merged, options.Force) + if removeErr != nil { + return blockedCleanup(branch, failReason), nil + } + result.WorktreeRemoved = worktreeRemoved + result.BranchDeleted = branchDeleted + result.VerificationStatus = "VERIFIED" + result.Reason = fmt.Sprintf("Cleaned up the workspace for %q.", branch) + return result, nil +} + +// worktreeEntry is one linked worktree and the branch it has checked out. +type worktreeEntry struct { + Path string + Branch string +} + +// boatstackWorktrees lists the linked worktrees Boatstack owns — those created +// under .product-loop/worktrees/. The main worktree, human-created worktrees, and +// detached worktrees are excluded so reap can never remove work Boatstack did not +// create. Paths are absolute (git reports them so), which lets reap run from any +// worktree. +func boatstackWorktrees(repo string) []worktreeEntry { + out, err := workspaceGit(repo, "worktree", "list", "--porcelain") + if err != nil { + return nil + } + var entries []worktreeEntry + var current worktreeEntry + flush := func() { + if current.Path != "" && current.Branch != "" && strings.Contains(filepath.ToSlash(current.Path), "/.product-loop/worktrees/") { + entries = append(entries, current) } - if _, err := workspaceGit(repo, removeArgs...); err != nil { - return blockedCleanup(branch, "Boatstack could not remove the worktree: "+err.Error()), nil + current = worktreeEntry{} + } + for _, line := range strings.Split(out, "\n") { + switch { + case strings.HasPrefix(line, "worktree "): + flush() + current.Path = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + case strings.HasPrefix(line, "branch "): + current.Branch = strings.TrimPrefix(strings.TrimSpace(strings.TrimPrefix(line, "branch ")), "refs/heads/") } - result.WorktreeRemoved = true } - if branchExists(repo, branch) { - // Once the merge/safety gates above have cleared, force-delete so a - // squash- or rebase-merged PR (whose local ref is not a local ancestor - // of the base) is still removable. - deleteFlag := "-d" - if options.Force || result.Merged { - deleteFlag = "-D" + flush() + return entries +} + +// WorkspaceReapOptions requests a sweep of all terminal Boatstack workspaces. +type WorkspaceReapOptions struct { + Repo string + Confirm bool // the operator approved the aggregate reap prompt + Force bool // override the merge/dirty/unmerged gates and discard unlanded work +} + +// WorkspaceReapItem is the per-workspace outcome within a reap sweep. +type WorkspaceReapItem struct { + Branch string `json:"branch"` + WorktreePath string `json:"worktree_path,omitempty"` + Merged bool `json:"merged"` + MergeSource string `json:"merge_source,omitempty"` + Abandoned bool `json:"abandoned,omitempty"` + Action string `json:"action"` // reaped | reclaimable | skipped | blocked + WorktreeRemoved bool `json:"worktree_removed,omitempty"` + BranchDeleted bool `json:"branch_deleted,omitempty"` + Reason string `json:"reason"` +} + +// WorkspaceReap is the deterministic result of a reap sweep. +type WorkspaceReap struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` // VERIFIED | NEEDS_CONFIRMATION | BLOCKED + Mode string `json:"mode,omitempty"` + Candidates []WorkspaceReapItem `json:"candidates,omitempty"` + ReclaimableCount int `json:"reclaimable_count"` + ReapedCount int `json:"reaped_count"` + Reason string `json:"reason"` +} + +func blockedReap(reason string) WorkspaceReap { + return WorkspaceReap{SchemaVersion: workspaceSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} +} + +// samePath reports whether two filesystem paths denote the same location, +// resolving symlinks first (macOS temp dirs live under a /private symlink, so a +// raw string compare of git's toplevel against a recorded worktree path can +// spuriously differ) and falling back to a lexical comparison. +func samePath(a, b string) bool { + if resolved, err := filepath.EvalSymlinks(a); err == nil { + a = resolved + } + if resolved, err := filepath.EvalSymlinks(b); err == nil { + b = resolved + } + return filepath.Clean(a) == filepath.Clean(b) +} + +// reclaimableScan enumerates the Boatstack worktrees and classifies each as +// skipped or reclaimable without mutating the repository. It returns the skipped +// candidates (for reporting) and the reclaimable subset (merged or abandoned, +// excluding the base branch, the current worktree, and non-Boatstack worktrees). +func reclaimableScan(repo, base string, ignored []string) (skipped, reapable []WorkspaceReapItem) { + abandonedBranches := map[string]bool{} + for _, slug := range ignored { + if branch := branchForFeature(slug); branch != "" { + abandonedBranches[branch] = true + } + } + currentTop := "" + if top, topErr := workspaceGit(repo, "rev-parse", "--show-toplevel"); topErr == nil { + currentTop = strings.TrimSpace(top) + } + for _, wt := range boatstackWorktrees(repo) { + branch := wt.Branch + if branch == "" || branch == base { + continue + } + item := WorkspaceReapItem{Branch: branch, WorktreePath: wt.Path} + if currentTop != "" && samePath(wt.Path, currentTop) { + item.Action = "skipped" + item.Reason = "This is the current worktree; reap it from another location." + skipped = append(skipped, item) + continue + } + merged, source := workspaceMergeStatus(repo, branch, base) + abandoned := abandonedBranches[branch] + item.Merged = merged + item.MergeSource = source + item.Abandoned = abandoned + if !merged && !abandoned { + item.Action = "skipped" + item.Reason = "Not merged and not abandoned; keeping the workspace." + skipped = append(skipped, item) + continue + } + item.Action = "reclaimable" + reapable = append(reapable, item) + } + return skipped, reapable +} + +// CountReclaimableWorkspaces reports how many terminal Boatstack workspaces reap +// would reclaim right now. It is read-only so boatstack-next can surface the reap +// prompt with an accurate count. It returns 0 when workspace management is off. +func CountReclaimableWorkspaces(repoPath string) int { + repo, err := ResolveRepository(repoPath) + if err != nil { + return 0 + } + config, _, cfgErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if cfgErr != nil || !resolveWorkspace(config.Workspace).Enabled { + return 0 + } + _, reapable := reclaimableScan(repo, defaultPRBase(repo), config.Workflow.IgnoredDeliveries) + return len(reapable) +} + +// ReapWorkspaces sweeps every terminal Boatstack workspace — one whose branch is +// confirmed merged (via gh, else local ancestry) or explicitly abandoned (its +// feature slug is in workflow.ignored_deliveries) — and reclaims the local +// worktree and branch. It never touches a non-Boatstack worktree, the base branch, +// the current worktree, or an unmerged/open workspace, and it never discards +// uncommitted or unmerged work without Force. In confirm mode it returns the +// reclaimable set as NEEDS_CONFIRMATION without removing anything; in auto mode (or +// after Confirm/Force) it removes them through the in-process actuator. +func ReapWorkspaces(options WorkspaceReapOptions) (WorkspaceReap, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return blockedReap(err.Error()), nil + } + if !fileExists(filepath.Join(repo, ".product-loop", "project.json")) { + return blockedReap("This repository has no Boatstack project installation."), nil + } + config, _, cfgErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if cfgErr != nil { + return blockedReap("Boatstack could not read the workspace policy: " + cfgErr.Error()), nil + } + policy := resolveWorkspace(config.Workspace) + result := WorkspaceReap{SchemaVersion: workspaceSchemaVersion, Mode: policy.Reap} + if !policy.Enabled { + result.VerificationStatus = "BLOCKED" + result.Reason = "Workspace management is disabled (workspace.enabled=false)." + return result, nil + } + if policy.Reap == "off" && !options.Force { + result.VerificationStatus = "BLOCKED" + result.Reason = "Workspace reaping is disabled (workspace.reap=off)." + return result, nil + } + + base := defaultPRBase(repo) + skipped, reapable := reclaimableScan(repo, base, config.Workflow.IgnoredDeliveries) + result.Candidates = append(result.Candidates, skipped...) + result.ReclaimableCount = len(reapable) + + if len(reapable) == 0 { + result.VerificationStatus = "VERIFIED" + result.Reason = "No merged or abandoned Boatstack workspaces to reclaim." + return result, nil + } + + if policy.Reap == "confirm" && !options.Confirm && !options.Force { + result.Candidates = append(result.Candidates, reapable...) + result.VerificationStatus = "NEEDS_CONFIRMATION" + result.Reason = fmt.Sprintf("%d Boatstack worktree(s)/branch(es) are merged or abandoned and reclaimable. Confirm reap to remove them.", len(reapable)) + return result, nil + } + + for _, item := range reapable { + // An explicitly abandoned but unmerged branch is operator-authorized + // disposal, so it is removed like cleanup_after="ship" (unmerged allowed); + // the dirty gate below still protects uncommitted work unless forced. + effectiveAfter := policy.CleanupAfter + if item.Abandoned && !item.Merged { + effectiveAfter = "ship" } - if _, err := workspaceGit(repo, "branch", deleteFlag, branch); err != nil { - return blockedCleanup(branch, "Boatstack could not delete the branch: "+err.Error()), nil + plan := planWorkspaceRemoval(repo, base, item.Branch, item.WorktreePath, effectiveAfter, item.Merged, options.Force) + if !plan.Removable { + item.Action = "blocked" + item.Reason = plan.Reason + result.Candidates = append(result.Candidates, item) + continue } - result.BranchDeleted = true + // A terminal branch — merged or operator-abandoned — is safe to + // force-delete: an abandoned branch is intentionally not merged into base, + // so `git branch -d` would refuse it. The gates above already cleared it. + forceDelete := plan.Merged || item.Abandoned + worktreeRemoved, branchDeleted, failReason, removeErr := performWorkspaceRemoval(repo, item.Branch, item.WorktreePath, forceDelete, options.Force) + if removeErr != nil { + item.Action = "blocked" + item.Reason = failReason + result.Candidates = append(result.Candidates, item) + continue + } + item.Merged = plan.Merged + item.WorktreeRemoved = worktreeRemoved + item.BranchDeleted = branchDeleted + item.Action = "reaped" + item.Reason = fmt.Sprintf("Reclaimed the workspace for %q.", item.Branch) + result.Candidates = append(result.Candidates, item) + result.ReapedCount++ } + + // Clear any stale worktree admin entries left by out-of-band directory removal. + _, _ = workspaceGit(repo, "worktree", "prune") + result.VerificationStatus = "VERIFIED" - result.Reason = fmt.Sprintf("Cleaned up the workspace for %q.", branch) + if result.ReapedCount == len(reapable) { + result.Reason = fmt.Sprintf("Reclaimed %d Boatstack workspace(s).", result.ReapedCount) + } else { + result.Reason = fmt.Sprintf("Reclaimed %d of %d reclaimable Boatstack workspace(s); see candidates for the rest.", result.ReapedCount, len(reapable)) + } return result, nil } diff --git a/boatstack/workspace_reap_test.go b/boatstack/workspace_reap_test.go new file mode 100644 index 0000000..5c70739 --- /dev/null +++ b/boatstack/workspace_reap_test.go @@ -0,0 +1,330 @@ +package boatstack + +import ( + "os" + "path/filepath" + "testing" +) + +// These are boundary-conformance tests for the post-merge reap sweep. Each test +// names the control law it proves (see AGENTS.md "Map tests to control laws"): +// +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Reap removes a worktree/branch only when it is Boatstack-owned AND confirmed +// merged or explicitly abandoned; never unmerged/open, never a non-Boatstack +// worktree, never the base or current worktree. +// control-law: reap-never-discards-unlanded-work +// Reap never removes a worktree with uncommitted or unmerged work without Force. +// control-law: reap-prompts-before-destroying-in-confirm-mode +// confirm returns NEEDS_CONFIRMATION without removing; auto removes; off blocks. +// control-law: reap-preserves-per-worktree-delivery-isolation +// Reap enumerates via git worktree list and the merge oracle; reaping one +// worktree never disturbs an unrelated worktree or its per-worktree state. + +// ghStateByBranch mocks `gh pr view --json state -q .state`, returning a +// per-branch state and defaulting unknown branches to OPEN so the merge oracle is +// deterministic (it never falls through to local ancestry, which would treat a +// commitless freshly-cut branch as merged). +func ghStateByBranch(states map[string]string) func(string, ...string) (string, error) { + return func(_ string, arguments ...string) (string, error) { + branch := "" + for i := 0; i+1 < len(arguments); i++ { + if arguments[i] == "view" { + branch = arguments[i+1] + break + } + } + if state, ok := states[branch]; ok { + return state, nil + } + return "OPEN", nil + } +} + +func cutWorktree(t *testing.T, repo, feature string) (branch, path string) { + t.Helper() + cut, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: feature}) + if err != nil || cut.VerificationStatus != "VERIFIED" { + t.Fatalf("cut %q failed: %+v (%v)", feature, cut, err) + } + return cut.Branch, cut.WorktreePath +} + +// commitInWorktree gives a worktree a real, unmerged commit so it is genuinely not +// an ancestor of the base branch. +func commitInWorktree(t *testing.T, path, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(path, name), []byte("work\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, path, "add", name) + workspaceGitDo(t, path, "commit", "-m", "work in "+name) +} + +// writeWorkspaceProjectConfig rewrites project.json with a workspace policy and an +// optional set of abandoned (ignored) feature slugs. It mirrors the config that +// workspaceRepo writes. +func writeWorkspaceProjectConfig(t *testing.T, repo string, ws Workspace, ignored ...string) { + t.Helper() + config := ProjectConfig{ + SchemaVersion: 1, + Project: Project{Name: "test", DefaultBranch: "main", Commands: map[string]string{"test": "go test ./..."}}, + Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true, IgnoredDeliveries: ignored}, + Workspace: ws, + Adapters: []string{"cursor"}, + } + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func autoReapWorkspace() Workspace { + ws := defaultWorkspace() + ws.Reap = "auto" + return ws +} + +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Positive + relation conformance: a sweep reclaims every merged worktree at once +// and keeps the still-open one. +func TestReapSweepsAllMergedWorktrees(t *testing.T) { + repo := workspaceRepo(t, autoReapWorkspace()) + alphaBranch, alphaPath := cutWorktree(t, repo, "alpha") + betaBranch, betaPath := cutWorktree(t, repo, "beta") + gammaBranch, gammaPath := cutWorktree(t, repo, "gamma") + commitInWorktree(t, gammaPath, "gamma.txt") // genuinely unmerged + withWorkspaceGh(t, ghStateByBranch(map[string]string{ + alphaBranch: "MERGED", betaBranch: "MERGED", gammaBranch: "OPEN", + })) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "VERIFIED" || result.ReapedCount != 2 { + t.Fatalf("expected 2 merged worktrees reaped: %+v", result) + } + for _, path := range []string{alphaPath, betaPath} { + if dirExists(path) { + t.Fatalf("merged worktree was not removed: %s", path) + } + } + if branchExists(repo, alphaBranch) || branchExists(repo, betaBranch) { + t.Fatal("merged branches were not deleted") + } + if !dirExists(gammaPath) || !branchExists(repo, gammaBranch) { + t.Fatal("open worktree must be kept") + } +} + +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Positive conformance: an explicitly abandoned (ignored) delivery is reclaimed +// even though its branch is unmerged, because the operator authorized disposal. +func TestReapReclaimsAbandonedIgnoredDelivery(t *testing.T) { + ws := autoReapWorkspace() + repo := workspaceRepo(t, ws) + branch, path := cutWorktree(t, repo, "delta") + commitInWorktree(t, path, "delta.txt") // unmerged commit + writeWorkspaceProjectConfig(t, repo, ws, "delta") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "OPEN"})) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.ReapedCount != 1 || dirExists(path) || branchExists(repo, branch) { + t.Fatalf("abandoned delivery was not reclaimed: %+v", result) + } +} + +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Negative + bypass conformance: a human-created worktree outside +// .product-loop/worktrees is never reaped, even when it is merged; a real +// Boatstack worktree alongside it still is, proving the sweep ran. +func TestReapSkipsNonBoatstackWorktree(t *testing.T) { + repo := workspaceRepo(t, autoReapWorkspace()) + manualBranch := "feat/manual" + manualPath := filepath.Join(repo, "manual-wt") + workspaceGitDo(t, repo, "worktree", "add", "-b", manualBranch, manualPath) + realBranch, realPath := cutWorktree(t, repo, "real") + withWorkspaceGh(t, ghStateByBranch(map[string]string{ + manualBranch: "MERGED", realBranch: "MERGED", + })) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if !dirExists(manualPath) || !branchExists(repo, manualBranch) { + t.Fatal("non-Boatstack worktree must never be reaped") + } + if dirExists(realPath) || branchExists(repo, realBranch) { + t.Fatal("Boatstack worktree should have been reaped") + } + if result.ReapedCount != 1 { + t.Fatalf("expected exactly one reaped worktree: %+v", result) + } +} + +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Negative conformance: an open, unmerged, non-abandoned worktree is never in the +// reclaimable set — Force overrides discard gates, not terminality. +func TestReapNeverReapsOpenUnmergedEvenWithForce(t *testing.T) { + repo := workspaceRepo(t, autoReapWorkspace()) + branch, path := cutWorktree(t, repo, "openpr") + commitInWorktree(t, path, "openpr.txt") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "OPEN"})) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true, Force: true}) + if err != nil { + t.Fatal(err) + } + if result.ReapedCount != 0 || !dirExists(path) || !branchExists(repo, branch) { + t.Fatalf("open unmerged branch must never be reaped, even with force: %+v", result) + } +} + +// control-law: reap-never-discards-unlanded-work +// Negative + override conformance: a merged worktree with uncommitted changes is +// blocked without Force and reclaimed with it. +func TestReapRefusesDirtyMergedWorktreeWithoutForce(t *testing.T) { + repo := workspaceRepo(t, autoReapWorkspace()) + branch, path := cutWorktree(t, repo, "dirtymerged") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"})) + if err := os.WriteFile(filepath.Join(path, "scratch.txt"), []byte("uncommitted\n"), 0o644); err != nil { + t.Fatal(err) + } + + blocked, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if blocked.ReapedCount != 0 || !dirExists(path) { + t.Fatalf("dirty worktree must not be reaped without force: %+v", blocked) + } + sawBlocked := false + for _, candidate := range blocked.Candidates { + if candidate.Branch == branch && candidate.Action == "blocked" { + sawBlocked = true + } + } + if !sawBlocked { + t.Fatalf("dirty worktree should surface as a blocked candidate: %+v", blocked) + } + + forced, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Force: true}) + if err != nil { + t.Fatal(err) + } + if forced.ReapedCount != 1 || dirExists(path) { + t.Fatalf("force must reclaim the dirty merged worktree: %+v", forced) + } +} + +// control-law: reap-prompts-before-destroying-in-confirm-mode +// Positive conformance: confirm mode reports the reclaimable set without removing +// anything, then reclaims after the confirmation. +func TestReapNeedsConfirmationInConfirmMode(t *testing.T) { + repo := workspaceRepo(t, defaultWorkspace()) // reap defaults to "confirm" + branch, path := cutWorktree(t, repo, "confirmme") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"})) + + pending, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if pending.VerificationStatus != "NEEDS_CONFIRMATION" || pending.ReclaimableCount != 1 || !dirExists(path) { + t.Fatalf("confirm mode must prompt without removing: %+v", pending) + } + + done, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo, Confirm: true}) + if err != nil { + t.Fatal(err) + } + if done.VerificationStatus != "VERIFIED" || done.ReapedCount != 1 || dirExists(path) { + t.Fatalf("confirmed reap must remove the workspace: %+v", done) + } +} + +// control-law: reap-prompts-before-destroying-in-confirm-mode +// Negative conformance: reap=off blocks the sweep and removes nothing. +func TestReapDisabledWhenReapOff(t *testing.T) { + ws := defaultWorkspace() + ws.Reap = "off" + repo := workspaceRepo(t, ws) + branch, path := cutWorktree(t, repo, "keepme") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"})) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "BLOCKED" || !dirExists(path) || !branchExists(repo, branch) { + t.Fatalf("reap=off must block and remove nothing: %+v", result) + } +} + +// control-law: reap-removes-only-terminal-boatstack-workspaces +// Failure-state conformance: reaping from inside a worktree never removes the +// worktree it is standing in; it is reported as skipped instead. +func TestReapSkipsCurrentWorktree(t *testing.T) { + ws := autoReapWorkspace() + repo := workspaceRepo(t, ws) + // Commit the installation so the linked worktree carries project.json. + workspaceGitDo(t, repo, "add", ".product-loop") + workspaceGitDo(t, repo, "commit", "-m", "install boatstack") + branch, path := cutWorktree(t, repo, "current") + withWorkspaceGh(t, ghStateByBranch(map[string]string{branch: "MERGED"})) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: path}) + if err != nil { + t.Fatal(err) + } + if !dirExists(path) { + t.Fatal("reap must never remove the current worktree") + } + if result.ReapedCount != 0 { + t.Fatalf("current worktree must not be reaped: %+v", result) + } + skipped := false + for _, candidate := range result.Candidates { + if candidate.Branch == branch && candidate.Action == "skipped" { + skipped = true + } + } + if !skipped { + t.Fatalf("current worktree should be a skipped candidate: %+v", result) + } +} + +// control-law: reap-preserves-per-worktree-delivery-isolation +// Relation conformance: reaping one merged worktree leaves an unrelated open +// worktree — and thus its per-worktree runtime/delivery state under its own git +// dir — fully intact and still registered. +func TestReapPreservesUnrelatedWorktree(t *testing.T) { + repo := workspaceRepo(t, autoReapWorkspace()) + mergedBranch, mergedPath := cutWorktree(t, repo, "landed") + openBranch, openPath := cutWorktree(t, repo, "inflight") + commitInWorktree(t, openPath, "inflight.txt") + withWorkspaceGh(t, ghStateByBranch(map[string]string{ + mergedBranch: "MERGED", openBranch: "OPEN", + })) + + result, err := ReapWorkspaces(WorkspaceReapOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if result.ReapedCount != 1 || dirExists(mergedPath) { + t.Fatalf("the merged worktree should have been reaped: %+v", result) + } + if !dirExists(openPath) || !branchExists(repo, openBranch) { + t.Fatal("reap must not disturb an unrelated worktree") + } + if worktreePathForBranch(repo, openBranch) == "" { + t.Fatal("the unrelated worktree must remain a registered linked worktree") + } +} diff --git a/boatstack/workspace_test.go b/boatstack/workspace_test.go index 9f78dad..3610528 100644 --- a/boatstack/workspace_test.go +++ b/boatstack/workspace_test.go @@ -405,7 +405,11 @@ func writeCompletedDelivery(t *testing.T, repo, feature, headBranch string) { } func TestResolveNextRoutesToWorkspaceCleanupAfterPublication(t *testing.T) { - repo := workspaceRepo(t, defaultWorkspace()) + // With reap disabled, the merge checkpoint falls back to the single-feature + // workspace-cleanup surface for the just-merged worktree. + ws := defaultWorkspace() + ws.Reap = "off" + repo := workspaceRepo(t, ws) if _, err := CutFeatureWorkspace(WorkspaceCutOptions{Repo: repo, Feature: "shipped"}); err != nil { t.Fatal(err) } diff --git a/docs/configuration.md b/docs/configuration.md index 309898f..dd4f4d1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,7 @@ boatstack-user-config-field:workspace.enabled boatstack-user-config-field:workspace.mode boatstack-user-config-field:workspace.cleanup boatstack-user-config-field:workspace.cleanup_after +boatstack-user-config-field:workspace.reap boatstack-user-config-field:adapters --> @@ -133,13 +134,14 @@ List feature slugs here to drop past deliveries from the ambiguity check so hist "enabled": true, "mode": "worktree", "cleanup": "confirm", - "cleanup_after": "merge" + "cleanup_after": "merge", + "reap": "confirm" }, "adapters": ["cursor", "claude", "codex", "github"] } ``` -Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces. +Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. `reap` is `confirm`, `auto`, or `off`: when a delivery's PR is confirmed merged, Boatstack sweeps every terminal (merged or abandoned) Boatstack workspace at once — `confirm` asks the operator once before reclaiming them, `auto` reclaims without asking, and `off` disables the sweep. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces. ## Installer-owned fields diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index fa8276b..0629fb2 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -146,6 +146,6 @@ Delivery and system improvement also remain separate. A failed task may suggest ## What is evidence-backed -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4`](https://github.com/operatorstack/intelligence-flow/tree/dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4/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 [`64d586468baf5a7b45a2debbfc747b0d9ec9a233`](https://github.com/operatorstack/intelligence-flow/tree/64d586468baf5a7b45a2debbfc747b0d9ec9a233/labs/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/public-claims.json b/docs/public-claims.json index eef6b4b..784785c 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", + "source_commit": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" }, { "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:dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4" + "last_verified_version": "source:64d586468baf5a7b45a2debbfc747b0d9ec9a233" } ] } diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index d9b4c9f..5607855 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": "dbd5f6e24439cc9798b4b3c8645b5e34f3f3c0b4", + "source_commit": "64d586468baf5a7b45a2debbfc747b0d9ec9a233", "source_plan_path": "labs/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "labs/diagram-json/spec.md", diff --git a/release-notes/2026-07-26-workspace-reap.md b/release-notes/2026-07-26-workspace-reap.md new file mode 100644 index 0000000..49df38f --- /dev/null +++ b/release-notes/2026-07-26-workspace-reap.md @@ -0,0 +1,25 @@ +### One prompt clears merged worktrees at the merge checkpoint + +Boatstack cuts a fresh worktree and branch for each managed delivery. After a pull request +merged, that worktree and branch used to stay behind, so a fast loop left a growing pile of +finished workspaces to prune by hand — and the safety guard denied the raw deletion commands +used to prune them. + +When a delivery's pull request is confirmed merged, Boatstack now offers to sweep the whole +backlog at once. It reclaims every terminal Boatstack workspace — each one whose branch is +confirmed merged (through the GitHub CLI, with a local-ancestry fallback) or explicitly +abandoned by listing its feature in `workflow.ignored_deliveries`. It never reclaims a +workspace with an open or unknown-state pull request, a worktree Boatstack did not create, +the base branch, the worktree you are standing in, or one holding uncommitted or unmerged +work unless you force it. It reclaims only the local worktree and branch; it never deletes a +remote branch or merges anything. + +A new setting, `workspace.reap`, tunes the prompt: `confirm` (the default) asks once when +reclaimable workspaces exist, `auto` reclaims them without asking, and `off` disables the +sweep. The removal runs through Boatstack's own `workspace-reap` operation, so it is no +longer denied as filesystem or Git destruction; a denied raw deletion of a Boatstack +worktree now points you at `workspace-reap` instead. + +Reaping keys only on merge or abandonment and inspects worktrees through Git, never through +another worktree's private delivery state, so per-worktree isolation is preserved. Nothing +changes for a repository that leaves `workspace.reap` at its default until a delivery merges.