From 086cba51bfe37967dda55b985d118897ab9c768e Mon Sep 17 00:00:00 2001 From: "operator-stack-publisher[bot]" Date: Thu, 23 Jul 2026 00:23:03 +0000 Subject: [PATCH] Sync Boatstack from Intelligence Flow Labs @ bfaa855fddf3 --- CONTRIBUTING.md | 2 +- UPSTREAM.json | 61 +-- boatstack/cmd/boatstack-helper/main.go | 39 +- boatstack/export.go | 25 +- boatstack/export_test.go | 27 +- boatstack/hooks.go | 145 +++++- boatstack/init.go | 147 +++++- boatstack/init_test.go | 22 + boatstack/installation_repair.go | 487 ++++++++++++++++++ boatstack/installation_repair_test.go | 336 ++++++++++++ boatstack/references/artifacts.md | 3 + boatstack/references/failure-moves.md | 1 + boatstack/references/host-hook-contracts.md | 7 + boatstack/references/workflow.md | 6 +- boatstack/safety.go | 2 +- boatstack/update.go | 202 +++++++- boatstack/update_publication.go | 35 +- boatstack/update_publication_test.go | 24 + boatstack/update_test.go | 11 + docs/evidence-engineered-coding.md | 4 +- docs/generated-files.md | 6 +- docs/getting-started.md | 4 +- docs/public-claims.json | 24 +- docs/troubleshooting.md | 8 +- docs/why-these-steps.md | 2 + install.ps1 | 50 +- install.sh | 43 +- labs/diagram-json/plan.lock.json | 2 +- ...2026-07-23-bootstrap-safe-update-repair.md | 3 + 29 files changed, 1608 insertions(+), 120 deletions(-) create mode 100644 boatstack/installation_repair.go create mode 100644 boatstack/installation_repair_test.go create mode 100644 release-notes/2026-07-23-bootstrap-safe-update-repair.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3afc06c..d46f870 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/3fc5f6eb68c22007a46434b4af9dacc31dcc04ed/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/bfaa855fddf392520adb0e2324d38aff0421a7fb/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 257a8f2..ffecd9b 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 56257, - "estimated_tokens": 14065, + "characters": 57829, + "estimated_tokens": 14458, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -12,7 +12,7 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "1971ef51d571471a54b88c648cf7d8027057155efb75125647042997a31ae078", + "CONTRIBUTING.md": "2352ef6339506b5d3b6abaa1c81e35f33410add957cb10a05df8c3aa5f71f1a1", "README.md": "125b47671a68556df382f19756fb61fa18925606cbbaf54d6bc9df8872b36870", "assets/boatstack-journey.svg": "e465befc50c8ce30f3e07e8fd97012931beeb053392c8fbf38ad645023b3cc63", "assets/boatstack-mark.svg": "be1f984da1bfa69fa5d1f986d8343d21f7e20921b71db888c928b4d2e54b09b5", @@ -34,7 +34,7 @@ "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", "boatstack/changelog.go": "c5e1f31440b44d61e6037ad27af0333540af3545d655e35819a0241cbbebd8ec", "boatstack/changelog_test.go": "ce792f23a7fe1e09fb3096cd1314130a6ab69321d4877b12a8e994027541baf7", - "boatstack/cmd/boatstack-helper/main.go": "093e9b0e28931febf1dfb150dc83a46e4b8a8411693ea56409e25cbe6a50ee9e", + "boatstack/cmd/boatstack-helper/main.go": "f4ba748c29f96896d0cb496cf59a259e1b2993c8ee9391d4e49d5f768695f559", "boatstack/cmd/boatstack-helper/main_test.go": "ff73003b6a5157202fa09ddf1129fb13c3d79702b2e05a8721ce5a11bf5ab779", "boatstack/command.go": "94d2117c6e390d5a644afc5cd90f7e712e3f8b1032f8c9e3b253cc134524c28a", "boatstack/command_test.go": "9f707abba3640add81c3e97ba7e72fedbf98f3394b1c060a9ca4b4a28e919968", @@ -44,15 +44,17 @@ "boatstack/delivery.go": "96b55c27d320c0a218b1db4ea9c61a8687dfcff8c6f9d3f1126f5b7ae7ed35bd", "boatstack/delivery_test.go": "5ce806257307af5d2e4c74c07c77c3db119398f60953890d9b72f95cb168cae2", "boatstack/evidence.go": "497a31e6ff632cb1d7c3adfc9f269af3f6aa84e948dd5d417c162767542a27df", - "boatstack/export.go": "4deaef656e181533230b63bfbddac0c5169048a68f150ee1f32282b4a381ce1c", - "boatstack/export_test.go": "d2f57fd5af57ac0992ad198c1982c848b8fa39699c0af9a206b72960efb3e460", + "boatstack/export.go": "5f6e09c683974bacd1415cd0c9b75f7fe0cef2d22f865e5aa6798c0890b46dd4", + "boatstack/export_test.go": "762ce1425a721af1d3b1b3de91570a7ea1d99846301e8c42c9e201ae13fbf297", "boatstack/go.mod": "6086ef1b2a83f5696190dca692c653925f27b61f652f659fd3fca43ed54a1641", "boatstack/go.sum": "26c315c867b11b886f3c9402fce7f341f6a9115a5d61f54afbb5e1b1fb5f6017", - "boatstack/hooks.go": "ac67d7d0beea65a3eb719679d80213f911bfad7da7f16397d9831062a03839ce", + "boatstack/hooks.go": "b88cedcd045e5217fedfac625ced2e4f691adb42e62155fcbc392a8f6d88366e", "boatstack/hooks_test.go": "c5786bc6463cf6932a6612b26cbe65d035008ecbca5c0063bea253d857c2f622", - "boatstack/init.go": "816d2a59ea8a48e078f72158c134304393804f7530c38d4e117419cb5ba24391", - "boatstack/init_test.go": "fa48be69d07691fa7842224ec831e5f290504d8b6565263c9735d2dabd9b43b8", + "boatstack/init.go": "a32ffdbc148a19aa556e36897d80e7d41ac04a976fef6b27d527b81246bcf32d", + "boatstack/init_test.go": "5fdf687205e7a5984a98a87336b7127e4ae9b651d57e21ec2dc8ca7e653ee602", "boatstack/init_transaction.go": "112456c4e1c4db54c4137bcf4f7a9a9e63399a6f5971e9b3dc952d0c4b2aa4b6", + "boatstack/installation_repair.go": "0c896e7a5033211350eca2b2225bca638b3b0ac4adff550bc514090f7b789f23", + "boatstack/installation_repair_test.go": "ae5a5ea1110836bd78cf20ade863a4d32cfd63d282559f92786f57b31869bd14", "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", "boatstack/migrate.go": "eaf589e2b266238068e42c6d78e01dc040266d28e342cb24f09e33e8541749b3", "boatstack/migrate_test.go": "9f4bda2fb158c5e54bcc0242dace1da3c1965f9846a213c573956a35b7d1724e", @@ -70,13 +72,13 @@ "boatstack/pr_test.go": "838e6c244aed84317d00e6f49de6b65bb08f6fe1e1a11fb37aad8428ae2f6ea1", "boatstack/recovery.go": "dd816b18b54a0085b8d8276a93ee98d2b1e90099059a0d85cf6e24edf6f37d5b", "boatstack/recovery_test.go": "29490e7477ba602491330036a491289dd9117b99ff862f66dae421ba17e04c9f", - "boatstack/references/artifacts.md": "3f88ef39233b8e4b3554651c0d3ff6c8fade0512f69a912404976d90bf9ff304", + "boatstack/references/artifacts.md": "5fa888ac519085d65cee1d04df5902761651bcf2d7af81711fa0f8ecd1fc0f59", "boatstack/references/config-schema.md": "c07c2d532ef95ea6ae538a1fffefb92ded1f8dc6a06eb3b8d463e371d1ed8416", - "boatstack/references/failure-moves.md": "9daee2131050e9e0fd3d5f18385b7363e552da28fdf0dc35146f651e62071233", - "boatstack/references/host-hook-contracts.md": "7414a633dee8e8bd5d4516562f2030561f994366e6a02d0e18e0af152e80faa6", + "boatstack/references/failure-moves.md": "36e4f1487a790d981055ee5554fb1ee829b183dc8ca8c2b93f24e678b7d8a680", + "boatstack/references/host-hook-contracts.md": "d68ae1556e7b1e29e9ac7cb4db767809d510aabf0be52e60e44665ea7abb980e", "boatstack/references/irreversible-operation-boundary.md": "631743991ace65977586e4537f8dd50f8ae88f8e16f27cf7baad93b2791a73df", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "75c9b433a0c42275e47046ad5a3d5a1be4fdec1004dcdf91369fb6f839b8c582", + "boatstack/references/workflow.md": "86cc04ff440098ddf9f1a793da9e5dca7208fd24dcc3be4a7742c1d323e4866c", "boatstack/release.go": "82dcb4ca59e8c79a68d5333d650f90e64abd448d04e0c6f504fdf07f42b5ed76", "boatstack/release_test.go": "5cf2d76fe9b836a91ca68eba53d5585e2c4be5b9421aaf939ea0723063a24690", "boatstack/run.go": "fbdbf583c862c41f23d1a200f53d042842db72f19c29fe94e4288a69b0ac4a6b", @@ -84,17 +86,17 @@ "boatstack/runtime.go": "687b3543c22acbbed91c94e798c2a813bd828e087ac180ab29a21e3a429f9971", "boatstack/runtime_cache.go": "60c4eb0c7dde91d40d6ef3f05adc1a1282d17ff1ca12470d0a008454f7ca7489", "boatstack/runtime_cache_test.go": "b981467ddc9f0f562da6bff5de7a80a9fe5a433a0317541d1e48df268546ac85", - "boatstack/safety.go": "56dd3760f16aa083d95d740f160770c21429f2ef2b7b2f6a940956a8ad50f774", + "boatstack/safety.go": "5997a2fe1de6d563c219ca73bcc3f5bd00b88b9439831d335ee37eaeb6ad5dcc", "boatstack/safety_test.go": "dc21b6ee72221d3913fbac11e13af56c5a38c9e7b31dba6113fb5dde02036741", "boatstack/skill_frontmatter.go": "73364df463ce828c2d005aab55f72bb92f7a34d99cf3f53d4e0cd5a4da9dbd0e", "boatstack/skill_frontmatter_test.go": "a3ec52e7df357a72265c95dd66db15d9c0effc7e5f90f14ce69c27792ce394eb", "boatstack/testdata/reviewer-pr-body.md": "4c64e3788e5d61a377aeb0f797f7fc8d2316ab6e49572d15636eea7ba9e34ac4", "boatstack/testdata/safety/safe_apply.py.txt": "c9ec7fb932cf21b6aa8df597c4d4c54d6ec65e796240e49118d699f583383975", "boatstack/testdata/safety/unsafe_apply.py.txt": "42db1751865cc15c4dd69a03146b5deca8f21f916d258e433b27bbef5f884ab1", - "boatstack/update.go": "792ba4481503590e1412c0d2ddbfdd912899218a8e3ebcf323e385698b498ab0", - "boatstack/update_publication.go": "b1c967a4f6e43d7be2d8d71734bcb0563bc062a0ed152f56506c62a046ed54cd", - "boatstack/update_publication_test.go": "df07663ceec8a8055eaac6a8b1583bc9ef38c033fac191ec74c821d19238305a", - "boatstack/update_test.go": "b7d989c3c74a9e4d80ad14f9f74d94b617798dbcc36359327a4233524677db22", + "boatstack/update.go": "edcf524ec103c62e07266aa70c3b7abba665ce1ed04dfa8eb3b26e330100172e", + "boatstack/update_publication.go": "c8b7bd38019b1cbf8c523652e9e20e8c971c7e48b2f3972a0ac638648326fe63", + "boatstack/update_publication_test.go": "c5f32578db53be65e35452d5e8b4520884354e4e80a370dcec19860ea644d091", + "boatstack/update_test.go": "4cdd612356978edf718191f794be11590796bd5af1d524381b1a09243636cd51", "boatstack/visual_evidence.go": "90a68d554e10ff4fb7afa45000912cdedd4cdf93b3d279055b50844401924f01", "boatstack/visual_evidence_test.go": "0fe8f5154ef4398dfeba5e7f7b387b2d279ed75635ea35d93ff392269f2cc6d0", "boatstack/workspace.go": "91b343400b3506a6f516c28fabc3f1575f22024a5b19f934a020be660a20482e", @@ -103,24 +105,24 @@ "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", "docs/configuration.md": "f530c5dcbacf32dcb6fdab590901d8f658a4f29bd93f6264cec5d4f449c2cbd1", - "docs/evidence-engineered-coding.md": "8eba4e45b1969d0418306a8b6156c56e93263ab2d2b9f6cd790f67998ffc3ef7", - "docs/generated-files.md": "bb9b9efe69d97471820c225a9dcf5fda75e4896c20c9be6afb0db22a6bc9049e", - "docs/getting-started.md": "f741cfc69ac7123709b6120a1cbcc57a6e5559a6b7783bdb9f495a3cb845bd33", - "docs/public-claims.json": "49dbe4592a124ccccd4fe0ce907bb1b403521ec57905e2760372dcf274360ea8", + "docs/evidence-engineered-coding.md": "f722b9e3db8c3f8f9e8e34f8f9ee916ed22966dacc67965fc2acec17bd7fbc99", + "docs/generated-files.md": "437791765b0a4015032ae21d1a6618563cad92b7402819e4f963bf5ae16284a3", + "docs/getting-started.md": "d5f0b170209e61518810a23b851bf9eb50b6755906703ca313e6e9faab20e1cd", + "docs/public-claims.json": "7e844ed4a89ebdcf1834def12db0bb8cda83fe2fab511bfa54353304b4127193", "docs/public-surface.md": "713f7a050b5f339cf948299103ef3800417dccfecf2cc1a4166397ea6f978907", "docs/research-and-design.md": "d65c66e323037bda5d45aacef5d48afa6bf93da55901378891d235aca3a5684f", "docs/safety.md": "7b9b5c515d36e683767ec8d3d9d6d119ac93650b2f629d351deadd4c600ed6a6", - "docs/troubleshooting.md": "065fe8580215b4964d676fd6f6fa69f12720ddf0f55a951c167360637f4dfede", + "docs/troubleshooting.md": "7e0106ab2b7642f0c7cf641a61c18287bbe8a6b61a89222ce745dabe804240c9", "docs/validation-and-evidence.md": "e7d91ad49c6adb44784ebe7d94feceb6abd445857f9a0716f0758bf6b55296c5", - "docs/why-these-steps.md": "80957af13979070e8b2f2a8db78ce06d20d152bbc8ec41c3a8003f28393f6369", - "install.ps1": "960b2b20b406bb2878a560e9ace53fe7226bc510be6ee8466ce4e608beb5625a", - "install.sh": "939e604aa153b454e7fa1cbbe50a88be17782ea9df35d1bbb5615c737ed6f86e", + "docs/why-these-steps.md": "cbe0d769db11ef15bb1dff888009378d6783776ad020e6f5139847a1dd62fa09", + "install.ps1": "6f5857ec0feb502683c5781b9bfbbe31ed39556cd13384ddc66da622a8423cb7", + "install.sh": "c76a3ac6c7a45e6eb8e0178e1c3458f72c4899426c6a801d058ee38938e4b477", "labs/diagram-json/README.md": "f56a120877c8a3b10daa49c6d951481c02e98b8b8bb3f28672e9e092d97a37bb", "labs/diagram-json/approval.md": "ec9f353dc2a923c8df2c7fe6e90f5b054bed1129a6a21e86351596ef2a5d4215", "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": "57689bcf7c38c94f7bd59f3acd901b9446c1d4cc6545f0ef1a11a39dc618f92e", + "labs/diagram-json/plan.lock.json": "b2cb5cdf6ed31f55d748ad5d62cba8d894d48a98857201818e553d9c0928358f", "labs/diagram-json/plan.md": "3cc4f533b8d69386deff16b3a594a3ba09d4c0c3db636cccd8c4380084ce6a51", "labs/diagram-json/questions.md": "74733b015002c8a6777c558e7e997fa48c94850b9bd39054fe9366c97ecf728d", "labs/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -177,12 +179,13 @@ "release-notes/2026-07-22-pre-activation-mutation-interlock.md": "bebc6104159acc7c91e45c3d80d6e714b79486ab204c619fc715971303adedd1", "release-notes/2026-07-22-product-configuration-guide.md": "45e96862336bd53a2628ce3fe718c829ea20315555f53187eb7f04ba749f3a97", "release-notes/2026-07-22-projection-layout-validation.md": "fd0d2935f3f0c4caa41bda678e3bd9a9b496eb2652ab38d80c1c1434cbba3159", - "release-notes/2026-07-22-value-translation-boundary.md": "9cf168ff7caaf3906b78533935bdfb2c86e753984ed1e5ec8204cb373d083390" + "release-notes/2026-07-22-value-translation-boundary.md": "9cf168ff7caaf3906b78533935bdfb2c86e753984ed1e5ec8204cb373d083390", + "release-notes/2026-07-23-bootstrap-safe-update-repair.md": "d8e66c46ae05e3d228dfea45879f4d1166d5e3a253ac24bababd4c3e396c214b" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", + "commit": "bfaa855fddf392520adb0e2324d38aff0421a7fb", "path": "labs/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 497d58f..192e789 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -66,16 +66,49 @@ func updateCommand(arguments []string) int { repo := flags.String("repo", ".", "repository to update") binary := flags.String("binary", "", "verified replacement helper binary") yes := flags.Bool("yes", false, "accept the generated-file preview") + repair := flags.Bool("repair", false, "repair only fingerprinted Boatstack-owned control state") + allowDowngrade := flags.Bool("allow-downgrade", false, "permit an explicitly repaired downgrade") if err := flags.Parse(arguments); err != nil { return 2 } - err := boatstack.RunUpdate(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, Yes: *yes}) + err := boatstack.RunUpdate(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, Yes: *yes, Repair: *repair, AllowDowngrade: *allowDowngrade}) if err != nil { return fail(err) } return 0 } +func repairStatusCommand(arguments []string) int { + flags := flag.NewFlagSet("repair-status", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository installation to inspect") + allowDowngrade := flags.Bool("allow-downgrade", false, "include explicit downgrade authority in the projection") + jsonOutput := flags.Bool("json", false, "emit the versioned JSON projection") + if err := flags.Parse(arguments); err != nil { + return 2 + } + config, _, err := boatstack.LoadConfig(filepath.Join(*repo, ".boatstack-project.json")) + if err != nil { + return fail(err) + } + result, err := boatstack.ClassifyInstallationRepair(*repo, config.Adapters, *allowDowngrade) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + if *jsonOutput { + fmt.Print(string(value)) + } else { + fmt.Printf("REPAIR_STATUS=%s\nDIRECTION=%s\nPACKAGE_FINGERPRINT=%s\nNEXT_OPERATION=%s\n", result.VerificationStatus, result.Direction, result.PackageFingerprint, result.NextOperation) + } + if result.VerificationStatus == "BLOCKED" { + return 1 + } + return 0 +} + func checkUpdateCommand(arguments []string) int { flags := flag.NewFlagSet("check-update", flag.ContinueOnError) repo := flags.String("repo", ".", "repository whose Boatstack release should be checked") @@ -896,7 +929,7 @@ func workspaceStatusCommand(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] { @@ -906,6 +939,8 @@ func run() int { return updateCommand(os.Args[2:]) case "check-update": return checkUpdateCommand(os.Args[2:]) + case "repair-status": + return repairStatusCommand(os.Args[2:]) case "operation-status": return operationStatusCommand(os.Args[2:]) case "prepare-update-pr": diff --git a/boatstack/export.go b/boatstack/export.go index 9836c2d..41dbe23 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -274,7 +274,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "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. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. 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 authorized 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. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. 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 . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant or unresolved, show the exact fingerprinted local PNGs and public-repository warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Use a signed-in host browser to upload or update that comment when available and record the observed PR and comment URLs with record-pr-visual-publication; otherwise expose the local paths for manual attachment. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and fix forward from visual_pending after attachment failure. Add security/privacy, migration, or operations sections only when 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. After installation use the newly installed helper prepare-update-pr --repo . --version --json. It atomically writes a non-empty machine-local preview and fingerprints the exact diff. Show its version transition, release notes and link, integration state, title, body, 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 publish-update-pr --repo . --preview --preview-fingerprint . The deterministic publisher stages only the previewed paths, reuses an existing update commit, pushes normally, reconciles the exact branch and PR after an interrupted response, and opens at most one reviewer-ready PR. Never stage, commit, push, or open the update PR through free-form terminal calls. If GitHub auth is unavailable, preserve the branch and give one manual publication action. If operation-status reports EXECUTING, wait; if it reports RECONCILE_REQUIRED, reconcile instead of repeating publication. 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.", + "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. Force check-update with the current helper when available, but treat it and doctor as diagnostics rather than repair authority. If the installed helper is unavailable, resolve the latest stable tag from the official GitHub release endpoint and continue with the checksum-verified target installer; never require the broken helper to fix itself. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current default branch whose HEAD equals origin/ and no product or user-owned edits; otherwise respond Update postponed and give one recovery action. Ensure no update PR or branch already exists and create chore/update-boatstack-v. Fetch the installer from that exact release tag: it must checksum-verify the target helper before consulting installed state. Run it with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Exact installed hook and generated-state migrations are automatic. If the verified target helper reports REPAIR_AVAILABLE, show repair-status, state that the repair remains in this update PR, and ask whether the user wants the exact update rerun with BOATSTACK_REPAIR=1; do not infer that authority from the update request. Never offer repair for user-owned, mixed, malformed, symlinked, product, network, or authentication failures. Downgrades additionally require separately requested BOATSTACK_ALLOW_DOWNGRADE=1. The verified update preserves configuration, adapters, integrations, and unrelated host settings, writes any repair backup to Git-common state, runs doctor, and touches only Boatstack infrastructure. After installation use prepare-update-pr --repo . --version --json. Show version and repair provenance, release notes and link, integration state, title, body, 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 publish-update-pr --repo . --preview --preview-fingerprint . The deterministic publisher stages only previewed paths, reuses an existing update commit, pushes normally, reconciles the exact branch and PR after an interrupted response, and opens at most one reviewer-ready PR. Never stage, commit, push, or open the update PR through free-form terminal calls. If GitHub auth is unavailable, preserve the branch and give one manual publication action. If operation-status reports EXECUTING, wait; if it reports RECONCILE_REQUIRED, reconcile instead of repeating publication. 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.", "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require the state-scoped reply o to open or u to update the PR before publication, recheck the preview against current evidence, and never merge or deploy. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready before confirmation or PR opened after publication.", "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.", @@ -559,7 +559,26 @@ func ExportCollisions(repo string, files map[string][]byte) []string { } func WriteExport(repo string, files map[string][]byte) error { - if problems := ExportCollisions(repo, files); len(problems) > 0 { + return writeExport(repo, files, nil) +} + +func WriteExportForRepair(repo string, files map[string][]byte, repairPaths map[string]bool) error { + return writeExport(repo, files, repairPaths) +} + +func writeExport(repo string, files map[string][]byte, repairPaths map[string]bool) error { + problems := ExportCollisions(repo, files) + if len(problems) > 0 { + remaining := []string{} + for _, problem := range problems { + path := strings.TrimPrefix(problem, "stale generated path modified downstream: ") + if !repairPaths[path] { + remaining = append(remaining, problem) + } + } + problems = remaining + } + if len(problems) > 0 { return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) } for relative, expectedHash := range previousFiles(repo) { @@ -568,7 +587,7 @@ func WriteExport(repo string, files map[string][]byte) error { } target := filepath.Join(repo, filepath.FromSlash(relative)) current, err := os.ReadFile(target) - if err == nil && SHA256Bytes(current) == expectedHash { + if err == nil && (SHA256Bytes(current) == expectedHash || repairPaths[relative]) { if err := os.Remove(target); err != nil { return err } diff --git a/boatstack/export_test.go b/boatstack/export_test.go index 4501baf..ac7681a 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -295,7 +295,7 @@ func TestExportAndDriftCheck(t *testing.T) { } } update := string(bundle.Files[".cursor/commands/boatstack-update.md"]) - for _, expected := range []string{"check-update", "prepare-update-pr", "publish-update-pr", "operation-status", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "Reply `o` to open update PR.", "full reply open update PR for compatibility", "Never stage, commit, push, or open the update PR through free-form terminal calls", "Never merge"} { + for _, expected := range []string{"check-update", "prepare-update-pr", "publish-update-pr", "operation-status", "chore/update-boatstack-v", "BOATSTACK_MODE=update", "BOATSTACK_REPAIR=1", "repair-status", "checksum-verify the target helper", "Reply `o` to open update PR.", "full reply open update PR for compatibility", "Never stage, commit, push, or open the update PR through free-form terminal calls", "Never merge"} { if !strings.Contains(update, expected) { t.Fatalf("update adapter is missing %q", expected) } @@ -595,3 +595,28 @@ func TestExportRemovesOnlyUnmodifiedStaleGeneratedPath(t *testing.T) { t.Fatal("unmodified stale generated path was not removed") } } + +func TestRepairMayRemoveOnlyExplicitDriftedGeneratedPath(t *testing.T) { + repo := t.TempDir() + config := testConfig() + raw, _ := MarshalJSON(config) + bundle, _ := BuildExportBundle("config.json", config, raw, "boatstack") + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + stale := ".cursor/commands/retro.md" + delete(bundle.Files, stale) + path := filepath.Join(repo, filepath.FromSlash(stale)) + if err := os.WriteFile(path, []byte("locally drifted generated content\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteExport(repo, bundle.Files); err == nil { + t.Fatal("ordinary export removed drifted generated state") + } + if err := WriteExportForRepair(repo, bundle.Files, map[string]bool{stale: true}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("fingerprinted repair did not remove its exact stale generated path") + } +} diff --git a/boatstack/hooks.go b/boatstack/hooks.go index c8dc38c..29cd956 100644 --- a/boatstack/hooks.go +++ b/boatstack/hooks.go @@ -434,6 +434,10 @@ func loadHookConfig(path string) (map[string]any, error) { } func mergeHostHook(config map[string]any, host string) error { + return mergeHostHookWithOwnership(config, host, nil, false) +} + +func mergeHostHookWithOwnership(config map[string]any, host string, installed map[string]any, repair bool) error { hooks, ok := config["hooks"].(map[string]any) if config["hooks"] == nil { hooks = map[string]any{} @@ -441,6 +445,42 @@ func mergeHostHook(config map[string]any, host string) error { } else if !ok { return fmt.Errorf("host hook config has non-object hooks") } + desiredEvents := map[string]bool{} + for _, event := range hookEvents(host) { + desiredEvents[event] = true + } + // Retired events are an ordinary template migration when every Boatstack + // entry exactly matches the committed fragment from the installed release. + // The incoming helper may remove those entries before validating its own + // event set; otherwise the updater would be blocked by the state it owns. + for event, raw := range hooks { + if desiredEvents[event] || !containsBoatstackHook(raw) { + continue + } + entries, entriesOK := raw.([]any) + if !entriesOK { + return fmt.Errorf("host hook event %s is not a list", event) + } + kept := []any{} + owned := 0 + verified := installed != nil && installed[event] != nil + for _, entry := range entries { + if !containsBoatstackHook(entry) { + kept = append(kept, entry) + continue + } + owned++ + verified = verified && sameJSON(entry, installed[event]) + } + if owned > 0 && !verified && !repair { + return fmt.Errorf("%s Boatstack hook is attached to unsupported event %s; rerun the update with --repair only after reviewing the owned-state preview", host, event) + } + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + } for _, event := range hookEvents(host) { entries := []any{} if existing := hooks[event]; existing != nil { @@ -452,19 +492,30 @@ func mergeHostHook(config map[string]any, host string) error { } kept := []any{} found := 0 + verified := true for _, entry := range entries { if containsBoatstackHook(entry) { found++ - if err := validateBoatstackHookEntry(host, event, entry); err != nil { - return err + isDesired := sameJSON(entry, desiredHostHookForEvent(host, event)) + isInstalled := installed != nil && installed[event] != nil && sameJSON(entry, installed[event]) + if installed != nil && !isDesired && !isInstalled { + verified = false + } + if installed == nil { + if err := validateBoatstackHookEntry(host, event, entry); err != nil { + return err + } } continue } kept = append(kept, entry) } - if found > 1 { + if found > 1 && (installed == nil || !verified) && !repair { return fmt.Errorf("ambiguous Boatstack hook collision in %s", event) } + if found > 0 && !verified && !repair { + return fmt.Errorf("drifted %s Boatstack safety hook for %s; rerun the update with --repair only after reviewing the owned-state preview", host, event) + } kept = append(kept, desiredHostHookForEvent(host, event)) hooks[event] = kept } @@ -490,6 +541,14 @@ func InstallHostHooks(repo string, adapters []string) error { // PrepareHostHooks renders and validates every selected host document without // writing, allowing initialization to fail before entering its commit phase. func PrepareHostHooks(repo string, adapters []string) (map[string][]byte, error) { + return prepareHostHooks(repo, adapters, false) +} + +func PrepareHostHooksForUpdate(repo string, adapters []string, repair bool) (map[string][]byte, error) { + return prepareHostHooks(repo, adapters, repair) +} + +func prepareHostHooks(repo string, adapters []string, repair bool) (map[string][]byte, error) { prepared := map[string][]byte{} for _, host := range []string{"cursor", "claude", "codex", "gemini"} { if !contains(adapters, host) { @@ -500,7 +559,17 @@ func PrepareHostHooks(repo string, adapters []string) (map[string][]byte, error) if err != nil { return nil, err } - if err := mergeHostHook(config, host); err != nil { + var installed map[string]any + if repair || fileExists(filepath.Join(repo, ".product-loop", "hooks", host+".fragment.json")) { + installed, err = loadInstalledHookEvents(repo, host) + if err != nil { + installed, err = loadCommittedInstalledHookEvents(repo, host) + if err != nil { + return nil, fmt.Errorf("prepare %s host hooks: cannot verify installed ownership: %w", host, err) + } + } + } + if err := mergeHostHookWithOwnership(config, host, installed, repair); err != nil { return nil, fmt.Errorf("prepare %s host hooks in %s: %w", host, path, err) } value, err := MarshalJSON(config) @@ -515,6 +584,19 @@ func PrepareHostHooks(repo string, adapters []string) (map[string][]byte, error) return prepared, nil } +func InstallHostHooksForUpdate(repo string, adapters []string, repair bool) error { + prepared, err := PrepareHostHooksForUpdate(repo, adapters, repair) + if err != nil { + return err + } + for _, path := range sortedKeys(prepared) { + if err := atomicWrite(path, prepared[path]); err != nil { + return err + } + } + return nil +} + func CheckHostHooks(repo string, adapters []string) error { return checkHostHooks(repo, adapters, func(host, event string) (any, error) { return desiredHostHookForEvent(host, event), nil @@ -526,29 +608,50 @@ func CheckHostHooks(repo string, adapters []string) error { // comparing an old, healthy hook with the incoming release template would // misclassify an intentional template migration as user drift. func CheckInstalledHostHooks(repo string, adapters []string) error { - fragments := map[string]map[string]any{} - return checkHostHooks(repo, adapters, func(host, event string) (any, error) { - fragment := fragments[host] - if fragment == nil { - path := filepath.Join(repo, ".product-loop", "hooks", host+".fragment.json") - value, err := os.ReadFile(path) + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if !contains(adapters, host) { + continue + } + installed, err := loadInstalledHookEvents(repo, host) + if err != nil { + installed, err = loadCommittedInstalledHookEvents(repo, host) if err != nil { - return nil, fmt.Errorf("cannot read installed %s hook fragment: %w", host, err) + return fmt.Errorf("cannot read installed %s hook fragment: %w", host, err) + } + } + config, err := loadHookConfig(hostHookConfigPath(repo, host)) + if err != nil { + return err + } + hooks, ok := config["hooks"].(map[string]any) + if !ok { + return fmt.Errorf("missing %s hooks", host) + } + for event, expected := range installed { + entries, ok := hooks[event].([]any) + if !ok { + return fmt.Errorf("missing installed %s safety event %s", host, event) } - if err := DecodeJSON("load installed host hook fragment", path, value, &fragment); err != nil { - return nil, err + matches := 0 + for _, entry := range entries { + if containsBoatstackHook(entry) { + matches++ + if !sameJSON(entry, expected) { + return fmt.Errorf("drifted %s Boatstack safety hook", host) + } + } } - if fragment["schema_version"] != float64(1) || fragment["host"] != host { - return nil, fmt.Errorf("invalid installed %s hook fragment identity", host) + if matches < 1 { + return fmt.Errorf("expected an installed %s Boatstack safety hook for %s; found %d", host, event, matches) } - fragments[host] = fragment } - events, ok := fragment["events"].(map[string]any) - if !ok || events[event] == nil { - return nil, fmt.Errorf("installed %s hook fragment is missing %s", host, event) + for event, raw := range hooks { + if installed[event] == nil && containsBoatstackHook(raw) { + return fmt.Errorf("drifted %s Boatstack safety hook on unowned event %s", host, event) + } } - return events[event], nil - }) + } + return nil } func checkHostHooks(repo string, adapters []string, expectedForEvent func(host, event string) (any, error)) error { diff --git a/boatstack/init.go b/boatstack/init.go index 5ade1e4..b03c97a 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -18,6 +18,8 @@ type InitOptions struct { IntegrationChoice string Yes bool Update bool + Repair bool + AllowDowngrade bool Input io.Reader Output io.Writer } @@ -180,6 +182,36 @@ func promptLine(reader *bufio.Reader, output io.Writer, prompt string) (string, return strings.TrimSpace(value), nil } +func requestInstallationRepair(options *InitOptions, result InstallationRepairResult, reader *bufio.Reader) error { + fmt.Fprintf(options.Output, "Boatstack found recoverable drift in Boatstack-owned control state (%s -> %s, %s):\n", result.InstalledVersion, result.TargetVersion, result.Direction) + for _, item := range result.Items { + if item.Classification == RepairOwnedDrifted { + fmt.Fprintf(options.Output, " %s: %s\n", item.Path, item.Reason) + } + } + fmt.Fprintln(options.Output, "Repair package: "+result.PackageFingerprint) + fmt.Fprintln(options.Output, "The repair will remain in this fresh update branch and its update PR.") + if options.Yes { + return fmt.Errorf("recoverable Boatstack-owned drift requires explicit repair authority\nNEXT=%s", installationRepairRetryCommand(result.TargetVersion)) + } + answer, err := promptLine(reader, options.Output, "Repair Boatstack-owned state and continue the update? [y/N] ") + if err != nil { + return err + } + if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + return fmt.Errorf("update left unchanged\nNEXT=%s", installationRepairRetryCommand(result.TargetVersion)) + } + options.Repair = true + return nil +} + +func installationRepairRetryCommand(version string) string { + if runtime.GOOS == "windows" { + return `$env:BOATSTACK_MODE="update"; $env:BOATSTACK_VERSION="` + version + `"; $env:BOATSTACK_REPO=(Get-Location).Path; $env:BOATSTACK_YES="1"; $env:BOATSTACK_REPAIR="1"; irm https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.ps1 | iex` + } + return `BOATSTACK_MODE=update BOATSTACK_VERSION=` + version + ` BOATSTACK_REPO="$PWD" BOATSTACK_YES=1 BOATSTACK_REPAIR=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/` + version + `/install.sh)"` +} + func copyHelper(source, repo string) (string, string, error) { if source == "" { var err error @@ -308,6 +340,10 @@ func RunInit(options InitOptions) (returnErr error) { reader := bufio.NewReader(options.Input) configPath := filepath.Join(repo, ".boatstack-project.json") configExists := fileExists(configPath) + installed := fileExists(filepath.Join(repo, ".product-loop", "generated.lock.json")) || fileExists(filepath.Join(repo, ".product-loop", "bin", helperName())) + if installed && !options.Update { + return fmt.Errorf("Boatstack is already installed; use update, or invoke the verified installer with --repair when owned control state prevents updating") + } if options.Update && !configExists { return fmt.Errorf("Boatstack update requires an existing .boatstack-project.json") } @@ -348,14 +384,34 @@ func RunInit(options InitOptions) (returnErr error) { } config = defaultConfig(repo, testCommand) } + var repairResult InstallationRepairResult if options.Update { - if err := ValidateUpdateWorkspace(repo, config); err != nil { + repairResult, err = ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade) + if err != nil { + return err + } + if repairResult.Direction == "DOWNGRADE" && (!options.Repair || !options.AllowDowngrade) { + return fmt.Errorf("Boatstack %s to %s is a downgrade; rerun with both --repair and --allow-downgrade after reviewing the target release", repairResult.InstalledVersion, repairResult.TargetVersion) + } + if repairResult.VerificationStatus == "BLOCKED" { + return fmt.Errorf("Boatstack update cannot safely repair this installation: %s", strings.Join(repairResult.Blockers, "; ")) + } + if repairResult.VerificationStatus == "REPAIR_AVAILABLE" && !options.Repair { + if err := requestInstallationRepair(&options, repairResult, reader); err != nil { + return err + } + } + if err := ValidateUpdateWorkspaceForRepair(repo, config, repairResult, options.Repair); err != nil { return err } } var preservedStates map[string]IntegrationState if options.Update { preservedStates, err = readInstalledIntegrations(repo, config) + if err != nil && options.Repair && len(config.Integrations) > 0 { + preservedStates = config.Integrations + err = nil + } if err != nil { return err } @@ -405,6 +461,14 @@ func RunInit(options InitOptions) (returnErr error) { } } previousGenerated := previousFiles(repo) + var repairGeneratedLock []byte + if options.Update && options.Repair && len(previousGenerated) == 0 { + var recoverErr error + repairGeneratedLock, previousGenerated, recoverErr = committedGeneratedProvenance(repo) + if recoverErr != nil { + return recoverErr + } + } bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") if err != nil { return err @@ -412,11 +476,23 @@ func RunInit(options InitOptions) (returnErr error) { if err := ValidateJSON("validate project configuration before initialization", configPath, rawConfig); err != nil { return err } - if _, err := PrepareHostHooks(repo, config.Adapters); err != nil { + if _, err := PrepareHostHooksForUpdate(repo, config.Adapters, options.Update && options.Repair); err != nil { return err } if problems := ExportCollisions(repo, bundle.Files); len(problems) > 0 { - return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) + if options.Update && options.Repair { + allowed := repairOwnedPaths(repairResult) + remaining := []string{} + for _, problem := range problems { + if !allowed[problem] { + remaining = append(remaining, problem) + } + } + problems = remaining + } + if len(problems) > 0 { + return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) + } } paths := sortedKeys(bundle.Files) fmt.Fprintf(options.Output, "\nBoatstack will generate %d paths:\n", len(paths)) @@ -445,6 +521,21 @@ func RunInit(options InitOptions) (returnErr error) { return err } } + if options.Update && options.Repair { + currentRepair, classifyErr := ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade) + if classifyErr != nil { + return classifyErr + } + if currentRepair.PackageFingerprint != repairResult.PackageFingerprint { + return fmt.Errorf("Boatstack-owned repair state changed after preview; inspect the new repair-status before retrying") + } + backup, backupErr := writeInstallationRepairBackup(repo, repairResult) + if backupErr != nil { + return fmt.Errorf("create Boatstack repair backup: %w", backupErr) + } + repairResult.BackupPath = backup + fmt.Fprintf(options.Output, "Repair package %s backed up at %s.\n", repairResult.PackageFingerprint, backup) + } snapshot, err := beginRepositorySnapshot(repo) if err != nil { return err @@ -482,13 +573,28 @@ func RunInit(options InitOptions) (returnErr error) { if err := initCheckpoint("config-written"); err != nil { return fmt.Errorf("initialization checkpoint config-written: %w", err) } - if err := WriteExport(repo, bundle.Files); err != nil { - return err + if len(repairGeneratedLock) > 0 { + lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json") + if err := rejectSymlinkComponents(repo, lockPath); err != nil { + return err + } + if err := atomicWrite(lockPath, repairGeneratedLock); err != nil { + return fmt.Errorf("restore generated provenance inside repair transaction: %w", err) + } + } + var writeErr error + if options.Update && options.Repair { + writeErr = WriteExportForRepair(repo, bundle.Files, repairOwnedPaths(repairResult)) + } else { + writeErr = WriteExport(repo, bundle.Files) + } + if writeErr != nil { + return writeErr } if err := initCheckpoint("export-written"); err != nil { return fmt.Errorf("initialization checkpoint export-written: %w", err) } - if err := InstallHostHooks(repo, config.Adapters); err != nil { + if err := InstallHostHooksForUpdate(repo, config.Adapters, options.Update && options.Repair); err != nil { return err } if err := initCheckpoint("hooks-written"); err != nil { @@ -632,6 +738,9 @@ func injectExecutionInterceptor(repo, file string) error { end := strings.Index(text, interceptorFooter) injection := interceptorHeader + strings.TrimSpace(ExecutionBoundaryDX) + interceptorFooter + if strings.Count(text, interceptorHeader) != strings.Count(text, interceptorFooter) || strings.Count(text, interceptorHeader) > 1 || (start >= 0 && end < start) { + return fmt.Errorf("ambiguous Boatstack execution interceptor markers in %s; preserve the file and repair the marker boundary manually", path) + } if start >= 0 && end > start { text = text[:start] + injection + text[end+len(interceptorFooter):] } else { @@ -664,12 +773,34 @@ func RunUpdate(options InitOptions) error { if err != nil { return err } + if options.Input == nil { + options.Input = os.Stdin + } + if options.Output == nil { + options.Output = os.Stdout + } + config, _, configErr := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if configErr != nil { + return configErr + } + preflight, classifyErr := ClassifyInstallationRepair(repo, config.Adapters, options.AllowDowngrade) + if classifyErr != nil { + return classifyErr + } + if preflight.VerificationStatus == "REPAIR_AVAILABLE" && !options.Repair { + reader := bufio.NewReader(options.Input) + options.Input = reader + if err := requestInstallationRepair(&options, preflight, reader); err != nil { + return err + } + } branch := strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) - packageFingerprint := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256)) + repairAuthority := fmt.Sprintf("repair=%t\x00allow-downgrade=%t", options.Repair, options.AllowDowngrade) + packageFingerprint := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256 + "\x00" + repairAuthority)) receipt, err := PrepareOperation(OperationPrepareOptions{ Repo: repo, Kind: "install-update", Scope: OperationScope{Worktree: filepath.Base(repo), HeadBranch: branch}, Target: "boatstack-install:" + Version, PackageFingerprint: packageFingerprint, - AuthorizationFingerprint: SHA256Bytes([]byte("update-request\x00" + branch + "\x00" + packageFingerprint)), + AuthorizationFingerprint: SHA256Bytes([]byte("update-request\x00" + branch + "\x00" + packageFingerprint + "\x00" + repairAuthority)), RetryClass: "ATOMIC_LOCAL", MaxAttempts: 2, ExpectedPostcondition: "the generated runtime, adapters, hooks, and preserved integration state match the pinned release", }) diff --git a/boatstack/init_test.go b/boatstack/init_test.go index db88cd5..f047be7 100644 --- a/boatstack/init_test.go +++ b/boatstack/init_test.go @@ -124,6 +124,28 @@ func TestInitFreshThirdPartyPythonRepositoryWithValidConfig(t *testing.T) { } } +func TestInitDoesNotResetAnExistingInstallation(t *testing.T) { + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(`{"scripts":{"test":"node --test"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatal(err) + } + configBefore, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json")) + err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &bytes.Buffer{}}) + if err == nil || !strings.Contains(err.Error(), "already installed") { + t.Fatalf("existing installation was reinitialized: %v", err) + } + configAfter, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json")) + if !bytes.Equal(configBefore, configAfter) { + t.Fatal("failed reinstall changed project configuration") + } +} + func TestInitRollsBackRepositoryWhenPostInstallVerificationFails(t *testing.T) { repo := t.TempDir() if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { diff --git a/boatstack/installation_repair.go b/boatstack/installation_repair.go new file mode 100644 index 0000000..06c0099 --- /dev/null +++ b/boatstack/installation_repair.go @@ -0,0 +1,487 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + RepairCurrent = "CURRENT" + RepairOwnedStale = "OWNED_STALE" + RepairOwnedDrifted = "OWNED_DRIFTED" + RepairUserOwned = "USER_OWNED" + RepairAmbiguous = "AMBIGUOUS" + RepairUnsafe = "UNSAFE" +) + +type InstallationRepairItem struct { + Path string `json:"path"` + Host string `json:"host,omitempty"` + Event string `json:"event,omitempty"` + Classification string `json:"classification"` + Reason string `json:"reason"` + CurrentSHA256 string `json:"current_sha256,omitempty"` +} + +func currentFileHash(path string) string { + hash, err := SHA256File(path) + if err != nil { + return "" + } + return hash +} + +func unsafeRepairPath(repo, path string) bool { + if err := rejectSymlinkComponents(repo, path); err != nil { + return true + } + info, err := os.Lstat(path) + return err == nil && info.Mode()&os.ModeSymlink != 0 +} + +type InstallationRepairResult struct { + SchemaVersion int `json:"schema_version"` + VerificationStatus string `json:"verification_status"` + InstalledVersion string `json:"installed_version,omitempty"` + TargetVersion string `json:"target_version"` + Direction string `json:"direction"` + HeadBranch string `json:"head_branch"` + StartingHeadCommit string `json:"starting_head_commit"` + Items []InstallationRepairItem `json:"items"` + PreservedIntegrations map[string]IntegrationState `json:"preserved_integrations,omitempty"` + PackageFingerprint string `json:"package_fingerprint"` + BackupPath string `json:"backup_path,omitempty"` + Blockers []string `json:"blockers,omitempty"` + NextOperation string `json:"next_operation"` +} + +func installedVersion(repo string) (string, error) { + for _, candidate := range []string{ + filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), + filepath.Join(repo, ".product-loop", "generated.lock.json"), + } { + value, err := os.ReadFile(candidate) + if err != nil { + continue + } + var identity struct { + BoatstackVersion string `json:"boatstack_version"` + } + if json.Unmarshal(value, &identity) == nil && strings.TrimSpace(identity.BoatstackVersion) != "" { + return normalizedVersion(identity.BoatstackVersion) + } + } + return "", fmt.Errorf("installed Boatstack version cannot be established from owned provenance") +} + +func updateDirection(installed, target string) (string, error) { + comparison, err := compareVersions(installed, target) + if err != nil { + return "", err + } + switch { + case comparison < 0: + return "UPGRADE", nil + case comparison > 0: + return "DOWNGRADE", nil + default: + return "SAME_VERSION", nil + } +} + +func sameJSON(left, right any) bool { + a, errA := json.Marshal(left) + b, errB := json.Marshal(right) + return errA == nil && errB == nil && string(a) == string(b) +} + +func decodeInstalledHookEvents(path string, value []byte, host string) (map[string]any, error) { + var fragment struct { + SchemaVersion int `json:"schema_version"` + Host string `json:"host"` + Events map[string]any `json:"events"` + } + if err := DecodeJSON("load installed host hook fragment", path, value, &fragment); err != nil { + return nil, err + } + if fragment.SchemaVersion != 1 || fragment.Host != host || len(fragment.Events) == 0 { + return nil, fmt.Errorf("invalid installed %s hook fragment identity", host) + } + return fragment.Events, nil +} + +func loadInstalledHookEvents(repo, host string) (map[string]any, error) { + path := filepath.Join(repo, ".product-loop", "hooks", host+".fragment.json") + value, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return decodeInstalledHookEvents(path, value, host) +} + +func loadCommittedInstalledHookEvents(repo, host string) (map[string]any, error) { + relative := filepath.ToSlash(filepath.Join(".product-loop", "hooks", host+".fragment.json")) + expected, ok := previousFiles(repo)[relative] + if !ok { + return nil, fmt.Errorf("installed %s hook fragment has no generated provenance", host) + } + value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output() + if err != nil || SHA256Bytes(value) != expected { + return nil, fmt.Errorf("committed %s hook fragment does not match generated provenance", host) + } + return decodeInstalledHookEvents("HEAD:"+relative, value, host) +} + +func classifyHookState(repo, host string) []InstallationRepairItem { + path := hostHookConfigPath(repo, host) + currentHash := currentFileHash(path) + relative, _ := filepath.Rel(repo, path) + relative = filepath.ToSlash(relative) + if unsafeRepairPath(repo, path) { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration uses a symlinked path"}} + } + if _, err := os.Lstat(path); os.IsNotExist(err) { + _, fragmentErr := loadInstalledHookEvents(repo, host) + if fragmentErr != nil { + _, fragmentErr = loadCommittedInstalledHookEvents(repo, host) + } + if fragmentErr == nil { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairOwnedStale, Reason: "missing installed host configuration can be reconstructed from its ownership fragment"}} + } + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "host configuration and its ownership fragment are both missing"}} + } else if err != nil { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration path cannot be inspected"}} + } + config, configErr := loadHookConfig(path) + if configErr != nil { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host configuration is not valid JSON", CurrentSHA256: currentHash}} + } + hooks, ok := config["hooks"].(map[string]any) + if !ok { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "host hooks field is not an object", CurrentSHA256: currentHash}} + } + installed, installedErr := loadInstalledHookEvents(repo, host) + if installedErr != nil { + installed, installedErr = loadCommittedInstalledHookEvents(repo, host) + } + if installedErr != nil { + exactTarget := true + for _, event := range hookEvents(host) { + entries, entriesOK := hooks[event].([]any) + matches := 0 + if entriesOK { + for _, entry := range entries { + if containsBoatstackHook(entry) { + matches++ + exactTarget = exactTarget && sameJSON(entry, desiredHostHookForEvent(host, event)) + } + } + } + exactTarget = exactTarget && entriesOK && matches == 1 + } + for event, raw := range hooks { + if !contains(hookEvents(host), event) && containsBoatstackHook(raw) { + exactTarget = false + } + } + if exactTarget { + fragmentPath := filepath.ToSlash(filepath.Join(".product-loop", "hooks", host+".fragment.json")) + return []InstallationRepairItem{{Path: fragmentPath, Host: host, Classification: RepairOwnedDrifted, Reason: "missing ownership fragment can be reconstructed from exact target hooks"}} + } + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "installed hook ownership fragment is missing and current hooks do not exactly match the target", CurrentSHA256: currentHash}} + } + desiredEvents := map[string]bool{} + for _, event := range hookEvents(host) { + desiredEvents[event] = true + } + items := []InstallationRepairItem{} + for event, raw := range hooks { + entries, ok := raw.([]any) + if !ok { + if containsBoatstackHook(raw) { + items = append(items, InstallationRepairItem{Path: relative, Host: host, Event: event, Classification: RepairUnsafe, Reason: "Boatstack hook event is not a list", CurrentSHA256: currentHash}) + } + continue + } + ownedEntries := []any{} + for _, entry := range entries { + if containsBoatstackHook(entry) { + ownedEntries = append(ownedEntries, entry) + } + } + if len(ownedEntries) == 0 { + continue + } + classification := RepairOwnedDrifted + reason := "Boatstack-marked hook differs from its installed ownership fragment" + allInstalled := installed[event] != nil + allDesired := desiredEvents[event] + for _, entry := range ownedEntries { + allInstalled = allInstalled && sameJSON(entry, installed[event]) + allDesired = allDesired && sameJSON(entry, desiredHostHookForEvent(host, event)) + } + switch { + case allDesired && len(ownedEntries) == 1: + classification, reason = RepairCurrent, "hook matches the target release" + case allInstalled: + classification, reason = RepairOwnedStale, "hook exactly matches installed provenance and can be migrated" + case len(ownedEntries) > 1: + classification, reason = RepairAmbiguous, "multiple non-identical Boatstack-marked hooks require review" + } + items = append(items, InstallationRepairItem{Path: relative, Host: host, Event: event, Classification: classification, Reason: reason, CurrentSHA256: currentHash}) + } + return items +} + +func classifyExecutionInterceptor(repo, host string) []InstallationRepairItem { + relative := map[string]string{"cursor": ".cursorrules", "claude": "CLAUDE.md", "gemini": "GEMINI.md"}[host] + if relative == "" { + return nil + } + path := filepath.Join(repo, relative) + if unsafeRepairPath(repo, path) { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "execution interceptor uses a symlinked path"}} + } + value, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairUnsafe, Reason: "execution interceptor file cannot be read"}} + } + text := string(value) + starts := strings.Count(text, interceptorHeader) + ends := strings.Count(text, interceptorFooter) + if starts == 0 && ends == 0 { + return nil + } + if starts != 1 || ends != 1 || strings.Index(text, interceptorFooter) < strings.Index(text, interceptorHeader) { + return []InstallationRepairItem{{Path: relative, Host: host, Classification: RepairAmbiguous, Reason: "execution interceptor markers are incomplete or duplicated", CurrentSHA256: SHA256Bytes(value)}} + } + expected := interceptorHeader + strings.TrimSpace(ExecutionBoundaryDX) + interceptorFooter + start := strings.Index(text, interceptorHeader) + end := strings.Index(text, interceptorFooter) + len(interceptorFooter) + classification, reason := RepairOwnedStale, "marker-bounded Boatstack interceptor can be migrated" + if text[start:end] == expected { + classification, reason = RepairCurrent, "marker-bounded Boatstack interceptor matches the target release" + } + return []InstallationRepairItem{{Path: relative, Host: host, Classification: classification, Reason: reason, CurrentSHA256: SHA256Bytes(value)}} +} + +func ClassifyInstallationRepair(repoPath string, adapters []string, allowDowngrade bool) (InstallationRepairResult, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return InstallationRepairResult{}, err + } + target, err := normalizedVersion(Version) + if err != nil { + return InstallationRepairResult{}, err + } + result := InstallationRepairResult{ + SchemaVersion: 1, VerificationStatus: "VERIFIED", TargetVersion: target, NextOperation: "update", + HeadBranch: strings.TrimSpace(gitOutput(repo, "branch", "--show-current")), StartingHeadCommit: strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")), + } + installed, versionErr := installedVersion(repo) + if versionErr != nil { + result.Direction = "UNKNOWN" + result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/bin/install.lock.json", Classification: RepairAmbiguous, Reason: versionErr.Error()}) + } else { + result.InstalledVersion = installed + result.Direction, err = updateDirection(installed, target) + if err != nil { + return InstallationRepairResult{}, err + } + if result.Direction == "DOWNGRADE" && !allowDowngrade { + result.Blockers = append(result.Blockers, "downgrades require both --repair and --allow-downgrade") + } + } + installLockPath := filepath.Join(repo, ".product-loop", "bin", "install.lock.json") + if unsafeRepairPath(repo, installLockPath) { + result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/bin/install.lock.json", Classification: RepairUnsafe, Reason: "install provenance uses a symlinked path"}) + } else if provenanceErr := CheckExistingInstallProvenance(repo); provenanceErr != nil { + classification := RepairOwnedDrifted + if strings.Contains(strings.ToLower(provenanceErr.Error()), "unsafe") || strings.Contains(strings.ToLower(provenanceErr.Error()), "symlink") { + classification = RepairUnsafe + } + if result.InstalledVersion == "" { + classification = RepairAmbiguous + } + result.Items = append(result.Items, InstallationRepairItem{ + Path: ".product-loop/bin/install.lock.json", Classification: classification, + Reason: "local helper provenance needs reconstruction: " + provenanceErr.Error(), + CurrentSHA256: currentFileHash(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")), + }) + } + for _, host := range []string{"cursor", "claude", "codex", "gemini"} { + if contains(adapters, host) { + result.Items = append(result.Items, classifyHookState(repo, host)...) + result.Items = append(result.Items, classifyExecutionInterceptor(repo, host)...) + } + } + previous := previousFiles(repo) + if len(previous) == 0 { + classification := RepairOwnedDrifted + if result.InstalledVersion == "" { + classification = RepairAmbiguous + } + result.Items = append(result.Items, InstallationRepairItem{Path: ".product-loop/generated.lock.json", Classification: classification, Reason: "generated ownership provenance needs reconstruction", CurrentSHA256: currentFileHash(filepath.Join(repo, ".product-loop", "generated.lock.json"))}) + } else { + for relative, expected := range previous { + absolute := filepath.Join(repo, filepath.FromSlash(relative)) + if unsafeRepairPath(repo, absolute) { + result.Items = append(result.Items, InstallationRepairItem{Path: filepath.ToSlash(relative), Classification: RepairUnsafe, Reason: "generated ownership path uses a symlink"}) + continue + } + value, readErr := os.ReadFile(absolute) + if readErr == nil && SHA256Bytes(value) == expected { + continue + } + classification := RepairOwnedStale + reason := "owned generated file is missing and can be reconstructed" + if readErr == nil { + classification, reason = RepairOwnedDrifted, "installer-owned generated file differs from installed provenance" + } + result.Items = append(result.Items, InstallationRepairItem{Path: filepath.ToSlash(relative), Classification: classification, Reason: reason, CurrentSHA256: currentFileHash(filepath.Join(repo, filepath.FromSlash(relative)))}) + } + } + config, _, configErr := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if configErr == nil { + states, stateErr := readInstalledIntegrations(repo, config) + if stateErr == nil { + result.PreservedIntegrations = states + } else if len(config.Integrations) > 0 { + result.PreservedIntegrations = config.Integrations + } + } + sort.Slice(result.Items, func(i, j int) bool { + if result.Items[i].Path == result.Items[j].Path { + return result.Items[i].Event < result.Items[j].Event + } + return result.Items[i].Path < result.Items[j].Path + }) + needsRepair := false + for _, item := range result.Items { + switch item.Classification { + case RepairOwnedDrifted: + needsRepair = true + case RepairUserOwned, RepairAmbiguous, RepairUnsafe: + result.Blockers = append(result.Blockers, item.Path+": "+item.Reason) + } + } + if len(result.Blockers) > 0 { + result.VerificationStatus = "BLOCKED" + result.NextOperation = "resolve_blocker" + } else if needsRepair { + result.VerificationStatus = "REPAIR_AVAILABLE" + result.NextOperation = "update_with_repair" + } + fingerprintValue, _ := json.Marshal(struct { + Installed string `json:"installed"` + Target string `json:"target"` + Direction string `json:"direction"` + Branch string `json:"branch"` + Head string `json:"head"` + Items []InstallationRepairItem `json:"items"` + }{result.InstalledVersion, result.TargetVersion, result.Direction, result.HeadBranch, result.StartingHeadCommit, result.Items}) + result.PackageFingerprint = SHA256Bytes(fingerprintValue) + return result, nil +} + +func repairOwnedPaths(result InstallationRepairResult) map[string]bool { + paths := map[string]bool{} + for _, item := range result.Items { + if item.Classification == RepairOwnedStale || item.Classification == RepairOwnedDrifted { + paths[item.Path] = true + } + } + return paths +} + +func writeInstallationRepairBackup(repo string, result InstallationRepairResult) (string, error) { + common, err := gitCommonDir(repo) + if err != nil { + return "", err + } + directory := filepath.Join(common, "boatstack", "repair-backups", result.PackageFingerprint) + if err := rejectSymlinkComponents(common, directory); err != nil { + return "", err + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return "", err + } + for relative := range repairOwnedPaths(result) { + source := filepath.Join(repo, filepath.FromSlash(relative)) + value, readErr := os.ReadFile(source) + if os.IsNotExist(readErr) { + continue + } + if readErr != nil { + return "", readErr + } + target := filepath.Join(directory, filepath.FromSlash(relative)) + if err := atomicWriteMode(target, value, 0o600); err != nil { + return "", err + } + } + manifest, err := MarshalJSON(map[string]any{ + "schema_version": 1, "created_at": time.Now().UTC().Format(time.RFC3339), + "package_fingerprint": result.PackageFingerprint, "items": result.Items, + }) + if err != nil { + return "", err + } + if err := atomicWriteMode(filepath.Join(directory, "repair.json"), manifest, 0o600); err != nil { + return "", err + } + receipt := result + receipt.BackupPath = filepath.ToSlash(filepath.Join("boatstack", "repair-backups", result.PackageFingerprint)) + receiptValue, err := MarshalJSON(receipt) + if err != nil { + return "", err + } + version, err := safeCacheSegment(result.TargetVersion, "repair target version") + if err != nil { + return "", err + } + receiptPath := filepath.Join(common, "boatstack", "updates", version, "repair.json") + if err := atomicWriteMode(receiptPath, receiptValue, 0o600); err != nil { + return "", err + } + return directory, nil +} + +func loadInstallationRepairReceipt(repo, version string) (*InstallationRepairResult, error) { + common, err := gitCommonDir(repo) + if err != nil { + return nil, err + } + segment, err := safeCacheSegment(version, "repair target version") + if err != nil { + return nil, err + } + path := filepath.Join(common, "boatstack", "updates", segment, "repair.json") + value, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var result InstallationRepairResult + if err := DecodeJSON("load installation repair receipt", path, value, &result); err != nil { + return nil, err + } + if result.SchemaVersion != 1 || result.TargetVersion != version || result.PackageFingerprint == "" || result.BackupPath == "" { + return nil, fmt.Errorf("installation repair receipt identity is invalid") + } + if result.HeadBranch != strings.TrimSpace(gitOutput(repo, "branch", "--show-current")) || result.StartingHeadCommit != strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")) { + return nil, nil + } + return &result, nil +} diff --git a/boatstack/installation_repair_test.go b/boatstack/installation_repair_test.go new file mode 100644 index 0000000..aaf8025 --- /dev/null +++ b/boatstack/installation_repair_test.go @@ -0,0 +1,336 @@ +package boatstack + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestOwnedRetiredHookEventMigratesWithoutRepair(t *testing.T) { + retired := desiredHostHookForEvent("cursor", "beforeShellExecution") + installed := map[string]any{"retiredCursorEvent": retired} + config := map[string]any{ + "version": float64(1), + "hooks": map[string]any{ + "retiredCursorEvent": []any{retired, map[string]any{"command": "./keep-user-hook.sh"}}, + }, + } + if err := mergeHostHookWithOwnership(config, "cursor", installed, false); err != nil { + t.Fatal(err) + } + hooks := config["hooks"].(map[string]any) + retiredEntries := hooks["retiredCursorEvent"].([]any) + if len(retiredEntries) != 1 || containsBoatstackHook(retiredEntries) { + t.Fatalf("retired owned hook was not removed without disturbing user hook: %#v", retiredEntries) + } + for _, event := range hookEvents("cursor") { + if !containsBoatstackHook(hooks[event]) { + t.Fatalf("target event %s was not installed", event) + } + } +} + +func TestExactInstalledHookDuplicatesAreDeduplicated(t *testing.T) { + entry := desiredHostHookForEvent("cursor", "beforeShellExecution") + config := map[string]any{"version": float64(1), "hooks": map[string]any{"beforeShellExecution": []any{entry, entry}}} + if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": entry}, false); err != nil { + t.Fatal(err) + } + entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any) + if len(entries) != 1 { + t.Fatalf("verified duplicate hooks were not deduplicated: %#v", entries) + } +} + +func TestDriftedOwnedHookRequiresRepairAndFingerprintIsStable(t *testing.T) { + installedEntry := desiredHostHookForEvent("cursor", "beforeShellExecution") + drifted := desiredHostHookForEvent("cursor", "beforeShellExecution") + drifted["timeout"] = float64(99) + config := map[string]any{"version": float64(1), "hooks": map[string]any{"beforeShellExecution": []any{drifted}}} + if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": installedEntry}, false); err == nil || !strings.Contains(err.Error(), "--repair") { + t.Fatalf("drifted hook did not require repair: %v", err) + } + if err := mergeHostHookWithOwnership(config, "cursor", map[string]any{"beforeShellExecution": installedEntry}, true); err != nil { + t.Fatal(err) + } + entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any) + if len(entries) != 1 || !sameJSON(entries[0], desiredHostHookForEvent("cursor", "beforeShellExecution")) { + t.Fatalf("repair did not replace the exact owned entry: %#v", entries) + } +} + +func driftCursorHook(t *testing.T, repo string) []byte { + t.Helper() + path := filepath.Join(repo, ".cursor", "hooks.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(before, &config); err != nil { + t.Fatal(err) + } + entries := config["hooks"].(map[string]any)["beforeShellExecution"].([]any) + entries[0].(map[string]any)["timeout"] = float64(99) + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, value, 0o644); err != nil { + t.Fatal(err) + } + return value +} + +func TestUpdateRepairPromptAndNonInteractiveRecovery(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + drifted := driftCursorHook(t, repo) + + var noninteractive bytes.Buffer + err := RunInit(InitOptions{Repo: repo, Update: true, Yes: true, Input: strings.NewReader(""), Output: &noninteractive}) + retryMarker := "BOATSTACK_REPAIR=1" + installerMarker := "v0.5.0/install.sh" + if runtime.GOOS == "windows" { + retryMarker = "BOATSTACK_REPAIR" + installerMarker = "v0.5.0/install.ps1" + } + if err == nil || !strings.Contains(err.Error(), retryMarker) || !strings.Contains(err.Error(), installerMarker) { + t.Fatalf("noninteractive update did not return one repair action: %v\n%s", err, noninteractive.String()) + } + if !strings.Contains(noninteractive.String(), "Repair package:") { + t.Fatalf("repair fingerprint was not displayed: %s", noninteractive.String()) + } + current, _ := os.ReadFile(filepath.Join(repo, ".cursor", "hooks.json")) + if !bytes.Equal(current, drifted) { + t.Fatal("failed update changed the drifted file") + } + + var declined bytes.Buffer + err = RunInit(InitOptions{Repo: repo, Update: true, Input: strings.NewReader("\n"), Output: &declined}) + if err == nil || !strings.Contains(declined.String(), "Repair Boatstack-owned state and continue the update? [y/N]") { + t.Fatalf("interactive update did not default to a visible repair refusal: %v\n%s", err, declined.String()) + } +} + +func TestRepairCompletesAndWritesGitCommonBackup(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + driftCursorHook(t, repo) + var output bytes.Buffer + if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Input: strings.NewReader(""), Output: &output}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "Repair package") || !strings.Contains(output.String(), "repair-backups") { + t.Fatalf("repair backup was not reported: %s", output.String()) + } + if err := CheckHostHooks(repo, []string{"cursor"}); err != nil { + t.Fatalf("repaired hooks do not match target: %v", err) + } +} + +func TestInteractiveRepairAuthorityIsBoundBeforeOperationLease(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + driftCursorHook(t, repo) + if err := RunUpdate(InitOptions{Repo: repo, Input: strings.NewReader("y\ny\n"), Output: &bytes.Buffer{}}); err != nil { + t.Fatal(err) + } + receipts, err := operationReceipts(repo) + if err != nil { + t.Fatal(err) + } + want := SHA256Bytes([]byte(Version + "\x00" + SourceCommit + "\x00" + ChecksumsSHA256 + "\x00repair=true\x00allow-downgrade=false")) + found := false + for _, receipt := range receipts { + if receipt.Kind == "install-update" && receipt.PackageFingerprint == want && receipt.State == OperationSucceeded { + found = true + } + } + if !found { + t.Fatalf("interactive repair did not create a repair-bound terminal operation: %#v", receipts) + } +} + +func TestRepairRejectsMixedUserAndOwnedHookEdits(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + driftCursorHook(t, repo) + path := filepath.Join(repo, ".cursor", "hooks.json") + value, _ := os.ReadFile(path) + var config map[string]any + if err := json.Unmarshal(value, &config); err != nil { + t.Fatal(err) + } + config["new_user_setting"] = "do-not-package" + value, _ = MarshalJSON(config) + if err := os.WriteFile(path, value, 0o644); err != nil { + t.Fatal(err) + } + project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + result, err := ClassifyInstallationRepair(repo, project.Adapters, false) + if err != nil { + t.Fatal(err) + } + if err := ValidateUpdateWorkspaceForRepair(repo, project, result, true); err == nil || !strings.Contains(err.Error(), "non-repairable changes") { + t.Fatalf("mixed user and owned edits entered repair: %v", err) + } +} + +func TestRepairNeverOverwritesPartialInterceptorBoundary(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + path := filepath.Join(repo, "CLAUDE.md") + value, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(strings.Replace(string(value), interceptorFooter, "", 1)), 0o644); err != nil { + t.Fatal(err) + } + project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + result, err := ClassifyInstallationRepair(repo, project.Adapters, false) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "markers") { + t.Fatalf("partial interceptor was not blocked: %#v", result) + } +} + +func TestRepairRejectsSymlinkedOwnedPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink fixture requires Unix permissions") + } + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + path := filepath.Join(repo, ".cursor", "hooks.json") + target := filepath.Join(t.TempDir(), "outside.json") + value, _ := os.ReadFile(path) + if err := os.WriteFile(target, value, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + project, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + result, err := ClassifyInstallationRepair(repo, project.Adapters, false) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "symlink") { + t.Fatalf("symlinked owned path was not blocked: %#v", result) + } +} + +func TestDowngradeRequiresRepairAndSeparateAuthority(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.6.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + result, err := ClassifyInstallationRepair(repo, []string{"cursor"}, false) + if err != nil { + t.Fatal(err) + } + if result.Direction != "DOWNGRADE" || result.VerificationStatus != "BLOCKED" || !strings.Contains(strings.Join(result.Blockers, " "), "--allow-downgrade") { + t.Fatalf("downgrade was not independently blocked: %#v", result) + } + result, err = ClassifyInstallationRepair(repo, []string{"cursor"}, true) + if err != nil || result.Direction != "DOWNGRADE" || result.VerificationStatus == "BLOCKED" { + t.Fatalf("explicit downgrade projection was not available: %#v %v", result, err) + } +} + +func TestRepairPreservesIntegrationFallbackWhenInstallLockIsMissing(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + if err := os.Remove(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")); err != nil { + t.Fatal(err) + } + config, _, err := LoadConfig(filepath.Join(repo, ".boatstack-project.json")) + if err != nil { + t.Fatal(err) + } + result, err := ClassifyInstallationRepair(repo, config.Adapters, false) + if err != nil { + t.Fatal(err) + } + if result.VerificationStatus != "REPAIR_AVAILABLE" || len(result.PreservedIntegrations) == 0 { + t.Fatalf("missing install lock was not recoverable from config: %#v", result) + } +} + +func TestRepairReconstructsCorruptGeneratedProvenance(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + lockPath := filepath.Join(repo, ".product-loop", "generated.lock.json") + if err := os.WriteFile(lockPath, []byte("{\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatal(err) + } + if len(previousFiles(repo)) == 0 { + t.Fatal("repair did not reconstruct generated provenance") + } +} + +func TestRepairReconstructsCorruptHookFragmentFromCommittedProvenance(t *testing.T) { + now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC) + withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { return ReleaseInfo{}, nil }) + repo, _ := updateInstalledRepo(t) + runGit(t, repo, "switch", "-c", "chore/update-boatstack-v0.5.0") + Version = "v0.5.0" + SourceCommit = "update-test-0.5.0" + fragmentPath := filepath.Join(repo, ".product-loop", "hooks", "cursor.fragment.json") + if err := os.WriteFile(fragmentPath, []byte("{\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := RunInit(InitOptions{Repo: repo, Update: true, Repair: true, Yes: true, Output: &bytes.Buffer{}}); err != nil { + t.Fatal(err) + } + if _, err := loadInstalledHookEvents(repo, "cursor"); err != nil { + t.Fatalf("repair did not reconstruct hook fragment: %v", err) + } +} diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 9c9c094..bbc2d86 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -18,6 +18,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Repair state | Ignored delivery mode, resume stage, active observation, attempt count, and superseded receipt references | Controlled repair and gate transitions | | Recovery status | Read-only active/published delivery, PR lifecycle, branch/SHA identity, ambiguity, and safe next transition | Before responding to CI, review, publication denial, or ordinary correction language | | Operation receipt | Ignored Git-common identity, fingerprinted authority, lease, durable attempt budget, expected postcondition, and secret-free completion observation | Before and after each managed mutation or external side effect | +| Installation repair receipt | Ignored Git-common installed/target version, direction, owned-state classifications, exact path hashes, repair fingerprint, and backup location | An update discovers or repairs Boatstack-owned control drift | | Gate receipt | Machine-local test or review transition bound to one delivery slice, base/head branches, commit, product diff, and evidence hash | A slice passes test or review | | Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes | | Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent | @@ -96,6 +97,8 @@ The generated host hook fragments and launchers are committed installation infra Operation receipts live under Git-common `boatstack/operations/v1`, never in Git history. They distinguish prepared, executing, unknown, retryable, and terminal work across turns and linked worktrees. Receipts contain hashes and bounded observations rather than commands, tool payloads, responses, credentials, or autonomous workflow intent. Terminal identities remain long enough to consume delayed duplicate events; old detail is compacted. +Installation repair receipts and backups live under Git-common `boatstack/updates/` and `boatstack/repair-backups/`. The checksum-verified target helper owns this recovery plane. Exact installed fragments migrate automatically; `--repair` covers only a displayed fingerprinted owned-state package. User-owned or ambiguous state is never converted into repair authority. + ## PR visual evidence boundary When `workflow.pr_visual_evidence` is enabled, the approved plan records whether screenshots are relevant and names no more than three review scenarios. PNG bytes and capability receipts live under Git-common Boatstack state; committed ledgers retain only compact metadata and hashes. PR schema v3 binds the policy, status, count, and manifest fingerprint to the preview. Screenshots are human-review evidence rather than mechanical correctness proof. diff --git a/boatstack/references/failure-moves.md b/boatstack/references/failure-moves.md index acfaf2d..a304ed3 100644 --- a/boatstack/references/failure-moves.md +++ b/boatstack/references/failure-moves.md @@ -14,6 +14,7 @@ Select a move only after locating the failure below its surface symptom. “Time | Test fidelity | Tests pass wrong code or reject correct code | Contract fixtures; collect/load gate; mutation/differential/human oracle | Treating more model-authored tests as truth | | Review miss | Defect found after same-agent review | Independent reviewer; risk checklist; mechanical enforcement | Expensive review everywhere | | Scope drift | Diff no longer maps to approved outcomes | Re-scope; split PR; update spec with approval | Hiding product changes in implementation | +| Update self-lockout | An installed helper, stale hook event, or damaged owned receipt blocks its own updater | Let the verified target helper classify state; migrate exact provenance automatically or offer fingerprinted `--repair` | Reinstalling blindly, overwriting user settings, or treating `--repair` as downgrade authority | | Security/tenancy | Trust boundary or data scope violated | Specialist review; invariant test; deny-by-default guard | Generic prompt mistaken for enforcement | | Integration/deploy | Local pass but runtime fails | Environment parity; canary; health checks; rollback | Treating staging as identical to production | | Documentation drift | Durable behavior and docs disagree | Update source-of-truth artifact; drift check | Growing instructions with unverified rules | diff --git a/boatstack/references/host-hook-contracts.md b/boatstack/references/host-hook-contracts.md index 199a80f..0933438 100644 --- a/boatstack/references/host-hook-contracts.md +++ b/boatstack/references/host-hook-contracts.md @@ -29,6 +29,13 @@ Deterministic schema, payload, decision, exit-code, and hydration fixtures block release. Live host checks are opt-in through `BOATSTACK_LIVE_HOST_TESTS=1` and report host availability separately from deterministic conformance. +During an update, the committed fragment from the installed release is the +ownership witness. Entries that match it exactly may move to new events or be +removed when an event retires, even when the target release no longer accepts +that old event. A marker-only or modified entry requires a fingerprinted +`--repair`; unrelated host entries are preserved, and malformed or mixed state +remains blocking. + Publication denials carry only secret-free recovery context: blocking feature and slice, branch relation, parent delivery, and the read-only next operation. Every host receives the same instruction to preserve edits and enter managed diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 21c8fc7..3bbd13f 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -379,9 +379,11 @@ After successful publication only, the publisher may use the ignored 24-hour rel ## Boatstack updates -`boatstack-update` is an infrastructure operation, not part of a feature plan. It first forces release discovery and proves the current installation is healthy. If the repository is not on its clean, current default branch, it changes nothing and returns **Update postponed**. +`boatstack-update` is an infrastructure operation, not part of a feature plan. It first forces release discovery and inspects the current installation. If the repository is not on its current default branch, or contains changes outside verified Boatstack-owned repair paths, it changes nothing and returns **Update postponed**. -For an available version, create `chore/update-boatstack-v`, run the installer pinned to that release in update mode, preserve the repository configuration, adapters, integrations, and unrelated host settings, then run `doctor`. The update transaction itself is a durable atomic-local operation. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show the release notes and link, exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Product paths or generated-state drift are blocking. +For an available version, create `chore/update-boatstack-v` and download and checksum-verify the target helper before consulting the installed runtime. The target helper classifies hook fragments, generated locks, helper provenance, and marker-bounded interceptors. Exact installed state migrates automatically. Recoverable owned drift is fingerprinted and, interactively, offered as **Repair Boatstack-owned state and continue the update? [y/N]**; noninteractive updates stop with one `--repair` retry. Repair backs up the exact paths in Git-common state and remains in the same update PR. User-owned, mixed, malformed, symlinked, or product state stays blocked. Downgrades require both `--repair` and `--allow-downgrade`. + +The update transaction is a durable atomic-local operation. It preserves repository configuration, adapters, integrations, and unrelated host settings, then runs `doctor`. After installation, `prepare-update-pr` verifies that every changed path is Boatstack-owned and atomically stores the exact non-empty publication package in Git-common runtime state. Show release and repair provenance, the exact generated diff, checksums, changed paths, integration state, rollout, and rollback. Use **Boatstack update ready** and exactly one action: Reply `o` to open update PR. Only the state-scoped `o` or compatible full reply authorizes `publish-update-pr` with that preview fingerprint. The publisher stages only the approved paths, reuses or creates the exact update commit, pushes normally, and reconciles the head branch before opening at most one PR. The PR body records release provenance, changed generated files, verification, rollout, and revert instructions. If a response is lost after GitHub accepted the request, the next invocation observes and returns the existing PR. If publication is unavailable, retain the prepared branch and provide one manual action. Never merge automatically. diff --git a/boatstack/safety.go b/boatstack/safety.go index 4aecbc8..45ca18a 100644 --- a/boatstack/safety.go +++ b/boatstack/safety.go @@ -94,7 +94,7 @@ func controlledPhaseTransition(command, stage string) bool { } readOnlyHelpers := map[string]bool{ "check-plan": true, "check-source-plan": true, "next-status": true, "delivery-status": true, - "recovery-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, + "recovery-status": true, "repair-status": true, "operation-status": true, "check-safety": true, "workspace-status": true, "diagnose-hook": true, "doctor": true, "version": true, } if readOnlyHelpers[fields[1]] { diff --git a/boatstack/update.go b/boatstack/update.go index 2b3fa52..8a2b76b 100644 --- a/boatstack/update.go +++ b/boatstack/update.go @@ -2,10 +2,12 @@ package boatstack import ( "context" + "encoding/json" "fmt" "io" "net/http" "os" + "os/exec" "path/filepath" "regexp" "sort" @@ -270,7 +272,7 @@ func CachedUpdate(repoPath string) (UpdateCheckResult, bool) { // information after a successful publication, but it cannot change that result. func PostShipUpdateNotice(repo string, feature string) (UpdateCheckResult, bool) { if feature != "" { - if state, err := LoadDeliveryState(repo, feature); err == nil && state.ActiveIndex + 1 < len(state.Slices) { + if state, err := LoadDeliveryState(repo, feature); err == nil && state.ActiveIndex+1 < len(state.Slices) { return UpdateCheckResult{}, false } } @@ -310,6 +312,22 @@ func CheckPreviousGeneratedState(repo string) error { return nil } +func committedGeneratedProvenance(repo string) ([]byte, map[string]string, error) { + relative := ".product-loop/generated.lock.json" + value, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output() + if err != nil { + return nil, nil, fmt.Errorf("cannot recover committed generated provenance: %w", err) + } + var lock struct { + Generator string `json:"generator"` + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(value, &lock); err != nil || lock.Generator != Generator || len(lock.Files) == 0 { + return nil, nil, fmt.Errorf("committed generated provenance is invalid") + } + return value, lock.Files, nil +} + func CheckExistingInstallProvenance(repo string) error { value, err := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) if err != nil { @@ -327,6 +345,12 @@ func CheckExistingInstallProvenance(repo string) error { if err != nil { return fmt.Errorf("invalid previous helper path: %w", err) } + if err := rejectSymlinkComponents(repo, binaryPath); err != nil { + return fmt.Errorf("previous Boatstack helper path is unsafe: %w", err) + } + if info, err := os.Lstat(binaryPath); err == nil && (!info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0) { + return fmt.Errorf("previous Boatstack helper path is unsafe") + } actual, err := SHA256File(binaryPath) if err != nil || actual != lock.BinarySHA256 { return fmt.Errorf("previous Boatstack helper does not match its install lock") @@ -335,6 +359,142 @@ func CheckExistingInstallProvenance(repo string) error { } func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { + result, err := ClassifyInstallationRepair(repo, config.Adapters, false) + if err != nil { + return err + } + if result.VerificationStatus == "BLOCKED" { + return fmt.Errorf("Boatstack update state is blocked: %s", strings.Join(result.Blockers, "; ")) + } + return ValidateUpdateWorkspaceForRepair(repo, config, result, false) +} + +func updateDirtyPaths(repo string) ([]string, error) { + value, err := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").Output() + if err != nil { + return nil, err + } + records := strings.Split(string(value), "\x00") + paths := []string{} + for index := 0; index < len(records); index++ { + record := records[index] + if record == "" { + continue + } + if len(record) < 4 || record[2] != ' ' { + return nil, fmt.Errorf("malformed Git status record") + } + paths = append(paths, filepath.ToSlash(record[3:])) + if (record[0] == 'R' || record[0] == 'C' || record[1] == 'R' || record[1] == 'C') && index+1 < len(records) { + index++ // -z emits the original rename/copy path as a second record. + } + } + return paths, nil +} + +func withoutBoatstackHooks(value []byte) ([]byte, error) { + var config map[string]any + if err := json.Unmarshal(value, &config); err != nil { + return nil, err + } + hooks, ok := config["hooks"].(map[string]any) + if !ok { + return json.Marshal(config) + } + for event, raw := range hooks { + entries, ok := raw.([]any) + if !ok { + continue + } + kept := []any{} + for _, entry := range entries { + if !containsBoatstackHook(entry) { + kept = append(kept, entry) + } + } + if len(kept) == 0 { + delete(hooks, event) + } else { + hooks[event] = kept + } + } + return json.Marshal(config) +} + +func withoutInterceptor(value []byte) ([]byte, error) { + text := string(value) + start := strings.Index(text, interceptorHeader) + end := strings.Index(text, interceptorFooter) + if start < 0 && end < 0 { + return value, nil + } + if start < 0 || end < start || strings.Count(text, interceptorHeader) != 1 || strings.Count(text, interceptorFooter) != 1 { + return nil, fmt.Errorf("ambiguous interceptor boundary") + } + return []byte(strings.TrimSpace(text[:start] + text[end+len(interceptorFooter):])), nil +} + +func dirtyChangeIsOwned(repo, relative string, config ProjectConfig) bool { + base, err := exec.Command("git", "-C", repo, "show", "HEAD:"+relative).Output() + if err != nil { + return false + } + current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) + for _, hookPath := range HostHookPaths(config.Adapters) { + if relative == filepath.ToSlash(hookPath) { + baseProjection, baseErr := withoutBoatstackHooks(base) + if os.IsNotExist(err) && baseErr == nil { + var skeleton map[string]any + if json.Unmarshal(baseProjection, &skeleton) != nil { + return false + } + for key, value := range skeleton { + if key == "version" { + continue + } + if key == "hooks" { + hooks, ok := value.(map[string]any) + if ok && len(hooks) == 0 { + continue + } + } + return false + } + return true + } + if err != nil { + return false + } + currentProjection, currentErr := withoutBoatstackHooks(current) + return baseErr == nil && currentErr == nil && string(baseProjection) == string(currentProjection) + } + } + if relative == ".cursorrules" || relative == "CLAUDE.md" || relative == "GEMINI.md" { + if err != nil { + return false + } + baseProjection, baseErr := withoutInterceptor(base) + currentProjection, currentErr := withoutInterceptor(current) + return baseErr == nil && currentErr == nil && string(baseProjection) == string(currentProjection) + } + if relative == ".product-loop/generated.lock.json" { + var lock struct { + Generator string `json:"generator"` + Files map[string]string `json:"files"` + } + return json.Unmarshal(base, &lock) == nil && lock.Generator == Generator && len(lock.Files) > 0 + } + expected, generated := previousFiles(repo)[relative] + if !generated { + return false + } + if SHA256Bytes(base) != expected { + return false + } + return os.IsNotExist(err) || err == nil +} + +func ValidateUpdateWorkspaceForRepair(repo string, config ProjectConfig, repairResult InstallationRepairResult, repair bool) error { version, err := normalizedVersion(Version) if err != nil { return err @@ -344,8 +504,27 @@ func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { if branch != wantBranch { return fmt.Errorf("update must run on %s; current branch is %s", wantBranch, branch) } - if gitOutput(repo, "status", "--porcelain") != "" { - return fmt.Errorf("update branch must start with a clean worktree") + dirtyPaths, statusErr := updateDirtyPaths(repo) + if statusErr != nil { + return fmt.Errorf("cannot inspect update worktree: %w", statusErr) + } + if len(dirtyPaths) > 0 { + allowed := map[string]bool{} + for _, item := range repairResult.Items { + if item.Classification == RepairOwnedStale || (repair && item.Classification == RepairOwnedDrifted) { + allowed[item.Path] = true + } + } + unexpected := []string{} + for _, path := range dirtyPaths { + if path != "" && (!allowed[path] || !dirtyChangeIsOwned(repo, path, config)) { + unexpected = append(unexpected, path) + } + } + if len(unexpected) > 0 { + sort.Strings(unexpected) + return fmt.Errorf("update branch must start with a clean worktree except for verified owned-state repair; non-repairable changes: %s", strings.Join(unexpected, ", ")) + } } defaultBranch := strings.TrimSpace(config.Project.DefaultBranch) if defaultBranch == "" { @@ -357,9 +536,17 @@ func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { return fmt.Errorf("update branch must start from the current origin/%s", defaultBranch) } if err := CheckPreviousGeneratedState(repo); err != nil { - return err + allowed := false + for _, item := range repairResult.Items { + if item.Classification == RepairOwnedStale || (repair && item.Classification == RepairOwnedDrifted) { + allowed = true + } + } + if !allowed { + return err + } } - if err := CheckInstalledHostHooks(repo, config.Adapters); err != nil { + if err := CheckInstalledHostHooks(repo, config.Adapters); err != nil && !repair { return fmt.Errorf("host-hook drift blocks update: %w", err) } var schemaProblems []string @@ -369,5 +556,8 @@ func ValidateUpdateWorkspace(repo string, config ProjectConfig) error { if len(schemaProblems) > 0 { return fmt.Errorf("config schema is behind; run /boatstack-update: %s", strings.Join(schemaProblems, ", ")) } - return CheckExistingInstallProvenance(repo) + if err := CheckExistingInstallProvenance(repo); err != nil && !repair { + return err + } + return nil } diff --git a/boatstack/update_publication.go b/boatstack/update_publication.go index abe976f..6a5db26 100644 --- a/boatstack/update_publication.go +++ b/boatstack/update_publication.go @@ -12,17 +12,18 @@ import ( const updatePublicationSchemaVersion = 1 type UpdatePublicationPreview struct { - SchemaVersion int `json:"schema_version"` - Version string `json:"version"` - BaseBranch string `json:"base_branch"` - HeadBranch string `json:"head_branch"` - StartingHeadCommit string `json:"starting_head_commit"` - ChangedPaths []string `json:"changed_paths"` - PackageFingerprint string `json:"package_fingerprint"` - Title string `json:"title"` - Body string `json:"body"` - PreviewPath string `json:"preview_path"` - Fingerprint string `json:"fingerprint"` + SchemaVersion int `json:"schema_version"` + Version string `json:"version"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + StartingHeadCommit string `json:"starting_head_commit"` + ChangedPaths []string `json:"changed_paths"` + PackageFingerprint string `json:"package_fingerprint"` + Title string `json:"title"` + Body string `json:"body"` + PreviewPath string `json:"preview_path"` + Fingerprint string `json:"fingerprint"` + Repair *InstallationRepairResult `json:"repair,omitempty"` } type UpdatePublishOptions struct { @@ -201,12 +202,22 @@ func PrepareUpdatePublication(repoPath, requestedVersion string) (UpdatePublicat if err != nil { return UpdatePublicationPreview{}, err } + repairReceipt, err := loadInstallationRepairReceipt(repo, version) + if err != nil { + return UpdatePublicationPreview{}, err + } + body := "## Why this change\n\nUpdate the repository-owned Boatstack infrastructure to " + version + ".\n\n## What changed\n\nOnly the fingerprinted Boatstack-generated files, host hooks, runtime provenance, and preserved integration state in this update package.\n" + if repairReceipt != nil { + body += "\nThe update also repairs fingerprinted Boatstack-owned control state. Repair package: `" + repairReceipt.PackageFingerprint + "`. The pre-repair files are retained in ignored Git-common state at `" + repairReceipt.BackupPath + "`.\n" + } + body += "\n## Verification\n\n- Boatstack doctor passed after installation.\n- Generated-file and hook projections are validated by the update transaction.\n\n## Rollback\n\nRevert this infrastructure-only commit and rerun the previously pinned installer.\n" preview := UpdatePublicationPreview{ SchemaVersion: updatePublicationSchemaVersion, Version: version, BaseBranch: baseBranch, HeadBranch: headBranch, StartingHeadCommit: gitOutput(repo, "rev-parse", "HEAD"), ChangedPaths: paths, PackageFingerprint: packageFingerprint, Title: "Update Boatstack to " + version, - Body: "## Why this change\n\nUpdate the repository-owned Boatstack infrastructure to " + version + ".\n\n## What changed\n\nOnly the fingerprinted Boatstack-generated files, host hooks, runtime provenance, and preserved integration state in this update package.\n\n## Verification\n\n- Boatstack doctor passed after installation.\n- Generated-file and hook projections are validated by the update transaction.\n\n## Rollback\n\nRevert this infrastructure-only commit and rerun the previously pinned installer.\n", + Body: body, PreviewPath: previewPath, + Repair: repairReceipt, } preview.Fingerprint, err = updatePreviewFingerprint(preview) if err != nil { diff --git a/boatstack/update_publication_test.go b/boatstack/update_publication_test.go index fd89105..2b3be0d 100644 --- a/boatstack/update_publication_test.go +++ b/boatstack/update_publication_test.go @@ -91,6 +91,30 @@ func TestPrepareUpdatePublicationIsAtomicAndRejectsProductPaths(t *testing.T) { } } +func TestUpdatePreviewCarriesFingerprintRepairProvenance(t *testing.T) { + repo := updatePublicationTestRepo(t, "v9.8.7") + result := InstallationRepairResult{ + SchemaVersion: 1, VerificationStatus: "REPAIR_AVAILABLE", InstalledVersion: "v9.8.6", + TargetVersion: "v9.8.7", Direction: "UPGRADE", PackageFingerprint: strings.Repeat("a", 64), + HeadBranch: "chore/update-boatstack-v9.8.7", StartingHeadCommit: gitOutput(repo, "rev-parse", "HEAD"), + Items: []InstallationRepairItem{{Path: ".cursor/commands/boatstack-update.md", Classification: RepairOwnedDrifted, Reason: "fixture drift"}}, + } + backup, err := writeInstallationRepairBackup(repo, result) + if err != nil { + t.Fatal(err) + } + preview, err := PrepareUpdatePublication(repo, "v9.8.7") + if err != nil { + t.Fatal(err) + } + if preview.Repair == nil || preview.Repair.PackageFingerprint != result.PackageFingerprint || preview.Repair.BackupPath != "boatstack/repair-backups/"+result.PackageFingerprint || !strings.Contains(preview.Body, result.PackageFingerprint) { + t.Fatalf("repair provenance missing from update preview: %#v", preview) + } + if backup == "" { + t.Fatal("repair backup path was not returned to the local operator") + } +} + func TestPublishUpdatePublicationOwnsCommitPushAndSinglePR(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("fake gh fixture uses a POSIX shell") diff --git a/boatstack/update_test.go b/boatstack/update_test.go index 29cc991..38a03d6 100644 --- a/boatstack/update_test.go +++ b/boatstack/update_test.go @@ -311,6 +311,17 @@ func TestUpdateRequiresCleanCurrentDedicatedBranch(t *testing.T) { } } +func TestUpdateDirtyPathsPreserveSpacesAndRejectRenames(t *testing.T) { + repo := updateCacheRepo(t) + if err := os.WriteFile(filepath.Join(repo, "space name.txt"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + paths, err := updateDirtyPaths(repo) + if err != nil || len(paths) != 1 || paths[0] != "space name.txt" { + t.Fatalf("NUL-delimited status paths were not preserved: %#v %v", paths, err) + } +} + func TestDoctorReadsCachedUpdateWithoutNetwork(t *testing.T) { now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC) withUpdateGlobals(t, "v0.4.0", now, func() (ReleaseInfo, error) { diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index 8b20199..feaa8ea 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 **14065 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 **14458 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 [`3fc5f6eb68c22007a46434b4af9dacc31dcc04ed`](https://github.com/operatorstack/intelligence-flow/tree/3fc5f6eb68c22007a46434b4af9dacc31dcc04ed/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 [`bfaa855fddf392520adb0e2324d38aff0421a7fb`](https://github.com/operatorstack/intelligence-flow/tree/bfaa855fddf392520adb0e2324d38aff0421a7fb/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/generated-files.md b/docs/generated-files.md index 1a5b473..c7af85e 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -54,9 +54,11 @@ One verified runtime is cached under the clone's Git common directory and keyed Independent clones do not share a Git common directory. Committed adapters survive a clone, but the ignored helper and repository-family cache do not; run the installer once in the new clone. -For an update, run `/boatstack-update` from a clean, current default branch. Boatstack creates `chore/update-boatstack-v`, verifies the tagged release and checksum, preserves integrations, and stores a fingerprinted non-empty update-PR preview under Git-common Boatstack state before asking for `o`. `publish-update-pr` owns the exact commit, normal push, and single-PR reconciliation. Release-check state in `.product-loop/bin/update-state.json`, operation receipts under Git-common `boatstack/operations/v1`, the update preview, and the platform helper remain ignored; the adapters, generated lock, hook fragments, and merged host settings belong in the update PR. +For an update, run `/boatstack-update` from a current default branch with no product or user-owned edits. Boatstack creates `chore/update-boatstack-v`, verifies the target helper before inspecting the installed runtime, preserves integrations, and stores a fingerprinted non-empty update-PR preview under Git-common Boatstack state before asking for `o`. Exact owned migrations are automatic. Explicit `--repair` backs up recoverable owned drift under Git-common `boatstack/repair-backups/` and keeps the repaired files in the same update PR. -An update refuses feature branches, dirty worktrees, stale default branches, changed generated files, and user-owned collisions. It never merges its own PR. +`publish-update-pr` owns the exact commit, normal push, and single-PR reconciliation. Release-check state in `.product-loop/bin/update-state.json`, operation receipts under Git-common `boatstack/operations/v1`, repair backups, the update preview, and the platform helper remain ignored; adapters, generated locks, hook fragments, and merged host settings belong in the update PR. + +An update refuses feature branches, stale default branches, product edits, user-owned collisions, mixed ownership, malformed host documents, and unsafe paths. `--repair` permits only fingerprinted Boatstack-owned drift. It never merges its own PR. If generated state looks wrong, run: diff --git a/docs/getting-started.md b/docs/getting-started.md index 63e68a8..2908963 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -236,7 +236,9 @@ After the feature PR is merged, switch to a clean, current default branch and ru /boatstack-update ``` -You may also ask, “Update Boatstack.” Boatstack checks the latest stable release, creates `chore/update-boatstack-v`, preserves the current configuration and integrations, runs `doctor`, and shows the exact infrastructure diff. Product files are outside the allowed update scope. +You may also ask, “Update Boatstack.” Boatstack checks the latest stable release, creates `chore/update-boatstack-v`, preserves the current configuration and integrations, and shows the exact infrastructure diff. If the installed helper cannot perform release discovery, Boatstack uses the official GitHub release endpoint and proceeds through the checksum-verified target installer. The old helper is never required to certify its own repair. + +Exact stale Boatstack state migrates automatically. If Boatstack finds recoverable owned drift, an interactive update shows the affected paths and asks whether to continue with `--repair`; pressing Enter declines. Noninteractive runs stop with one copyable `--repair` retry. The repair is backed up outside the worktree and remains visible in the same update PR. User-owned changes are never overwritten. Downgrades require the separate `--allow-downgrade` flag as well. When the preview is correct, reply: diff --git a/docs/public-claims.json b/docs/public-claims.json index affa7f1..eec6ffa 100644 --- a/docs/public-claims.json +++ b/docs/public-claims.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "source_commit": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", + "source_commit": "bfaa855fddf392520adb0e2324d38aff0421a7fb", "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" }, { "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:3fc5f6eb68c22007a46434b4af9dacc31dcc04ed" + "last_verified_version": "source:bfaa855fddf392520adb0e2324d38aff0421a7fb" } ] } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 72453dd..1387212 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -125,7 +125,13 @@ Release discovery uses a short, unauthenticated request to GitHub and a 24-hour ## The update reports generated drift -Boatstack found an installed generated file that no longer matches its previous lock. Review the named path and move durable project-owned content into `.boatstack-project.json` or repository documentation. Do not overwrite the drift merely to make the update pass. +Boatstack classifies the named path before writing. Exact installed state migrates automatically. If the path is provably Boatstack-owned but drifted, an interactive update shows the fingerprinted repair and asks whether to continue; a noninteractive update returns one retry using `--repair`. The repair is backed up outside the worktree and included in the same update PR. + +Do not use `--repair` for user-owned or mixed changes. Move durable project content into `.boatstack-project.json` or repository documentation first. A downgrade additionally requires `--allow-downgrade`; repair authority alone never removes newer behavior. + +## The installed helper or hook prevents updating + +Use the installer for the target release in update mode. It downloads and verifies the target helper before treating the installed helper's `doctor` result as diagnostic, so a missing helper or stale owned hook cannot disable recovery. Run `repair-status --repo . --json` to inspect the secret-free classification. Malformed host JSON, partial interceptor markers, symlinks, and unverifiable user content remain blocking and are never overwritten. ## A tool call repeats or publication appears stuck diff --git a/docs/why-these-steps.md b/docs/why-these-steps.md index bbfbca3..082580f 100644 --- a/docs/why-these-steps.md +++ b/docs/why-these-steps.md @@ -97,6 +97,8 @@ The paired product evaluation will use the same feature, lower-cost model, budge **Status:** release notification and update preparation behavior are verified in automated tests. This is not a claim that updates install themselves or may be merged without review. +**Repair boundary.** The checksum-verified target helper classifies installed control state instead of requiring the old helper to certify itself. Exact owned migrations are automatic; recoverable owned drift receives a fingerprinted `--repair` preview and Git-common backup in the update PR. User-owned changes and downgrades retain separate explicit boundaries. + ## Git worktree activation **What happened.** A Claude Code worktree contained the committed fail-closed hook but not `.product-loop/bin/`, which Git intentionally ignores. Every shell call was denied because the helper was absent, including the installer command that could have repaired it. diff --git a/install.ps1 b/install.ps1 index caa4dfa..dce35c3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,14 +1,32 @@ # Generated from operatorstack/intelligence-flow. +[CmdletBinding()] +param( + [switch]$Repair, + [switch]$AllowDowngrade +) $ErrorActionPreference = "Stop" $repository = "operatorstack/boatstack" $version = if ($env:BOATSTACK_VERSION) { $env:BOATSTACK_VERSION } else { "latest" } $targetRepo = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Location).Path } $mode = if ($env:BOATSTACK_MODE) { $env:BOATSTACK_MODE } else { "install" } +$repairRequested = $Repair -or $env:BOATSTACK_REPAIR -eq "1" +$downgradeRequested = $AllowDowngrade -or $env:BOATSTACK_ALLOW_DOWNGRADE -eq "1" if ($mode -notin @("install", "update")) { throw "BLOCKED: BOATSTACK_MODE must be install or update" } +$existingGeneratedLock = Test-Path -PathType Leaf (Join-Path $targetRepo ".product-loop/generated.lock.json") +$existingHelper = (Test-Path -PathType Leaf (Join-Path $targetRepo ".product-loop/bin/boatstack-helper")) -or (Test-Path -PathType Leaf (Join-Path $targetRepo ".product-loop/bin/boatstack-helper.exe")) +if ($mode -eq "install" -and ($existingGeneratedLock -or $existingHelper)) { + if ($repairRequested) { + $mode = "update" + Write-Host "Existing Boatstack installation detected; preserving its configuration and using update repair semantics." + } else { + throw "BLOCKED: Boatstack is already installed; use BOATSTACK_MODE=update, or add -Repair when owned control state prevents updating" + } +} + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "BLOCKED: Git is required because Boatstack operates on reviewable repository state" } @@ -21,16 +39,6 @@ $arch = switch ($architecture) { } $asset = "boatstack-helper_windows_${arch}.exe" -if ($mode -eq "update") { - $currentHelper = Join-Path $targetRepo ".product-loop/bin/boatstack-helper.exe" - if (-not (Test-Path -PathType Leaf $currentHelper)) { - throw "BLOCKED: current Boatstack helper is missing; repair the installation before updating" - } - & $currentHelper doctor --repo $targetRepo - if ($LASTEXITCODE -ne 0) { - throw "Current Boatstack installation must pass doctor before updating" - } -} $base = if ($version -eq "latest") { "https://github.com/$repository/releases/latest/download" } else { @@ -51,6 +59,22 @@ try { throw "BLOCKED: Boatstack binary checksum mismatch" } + if ($mode -eq "update") { + $currentHelper = Join-Path $targetRepo ".product-loop/bin/boatstack-helper.exe" + if (Test-Path -PathType Leaf $currentHelper) { + try { + & $currentHelper doctor --repo $targetRepo + if ($LASTEXITCODE -ne 0) { + Write-Warning "Current Boatstack doctor reported drift; the verified target helper will classify whether it is safely repairable." + } + } catch { + Write-Warning "Current Boatstack doctor reported drift; the verified target helper will classify whether it is safely repairable." + } + } else { + Write-Warning "Current Boatstack helper is missing; the verified target helper will classify whether it is safely repairable." + } + } + $commandName = if ($mode -eq "update") { "update" } else { "init" } $arguments = @($commandName, "--repo", $targetRepo, "--binary", $binary) if ($mode -eq "install" -and $env:BOATSTACK_INTEGRATIONS) { @@ -59,6 +83,12 @@ try { if ($env:BOATSTACK_YES -eq "1") { $arguments += "--yes" } + if ($repairRequested) { + $arguments += "--repair" + } + if ($downgradeRequested) { + $arguments += "--allow-downgrade" + } & $binary @arguments if ($LASTEXITCODE -ne 0) { throw "Boatstack initialization failed with exit code $LASTEXITCODE" diff --git a/install.sh b/install.sh index 9c7f56b..4163c20 100644 --- a/install.sh +++ b/install.sh @@ -6,12 +6,33 @@ repository="operatorstack/boatstack" version="${BOATSTACK_VERSION:-latest}" target_repo="${BOATSTACK_REPO:-$PWD}" mode="${BOATSTACK_MODE:-install}" +repair="${BOATSTACK_REPAIR:-0}" +allow_downgrade="${BOATSTACK_ALLOW_DOWNGRADE:-0}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --repair) repair=1 ;; + --allow-downgrade) allow_downgrade=1 ;; + *) echo "BLOCKED: unsupported installer argument: $1" >&2; exit 1 ;; + esac + shift +done case "$mode" in install|update) ;; *) echo "BLOCKED: BOATSTACK_MODE must be install or update" >&2; exit 1 ;; esac +if [ "$mode" = "install" ] && { [ -f "$target_repo/.product-loop/generated.lock.json" ] || [ -f "$target_repo/.product-loop/bin/boatstack-helper" ] || [ -f "$target_repo/.product-loop/bin/boatstack-helper.exe" ]; }; then + if [ "$repair" = "1" ]; then + mode="update" + echo "Existing Boatstack installation detected; preserving its configuration and using update repair semantics." + else + echo "BLOCKED: Boatstack is already installed; use BOATSTACK_MODE=update, or add --repair when owned control state prevents updating" >&2 + exit 1 + fi +fi + case "$(uname -s)" in Darwin) os_name="darwin" ;; Linux) os_name="linux" ;; @@ -31,11 +52,6 @@ command -v git >/dev/null 2>&1 || { echo "BLOCKED: Git is required because Boats extension="" [ "$os_name" = "windows" ] && extension=".exe" asset="boatstack-helper_${os_name}_${arch}${extension}" -if [ "$mode" = "update" ]; then - current_helper="$target_repo/.product-loop/bin/boatstack-helper${extension}" - [ -x "$current_helper" ] || { echo "BLOCKED: current Boatstack helper is missing; repair the installation before updating" >&2; exit 1; } - "$current_helper" doctor --repo "$target_repo" -fi if [ "$version" = "latest" ]; then base="https://github.com/${repository}/releases/latest/download" else @@ -62,6 +78,17 @@ fi [ "$expected" = "$actual" ] || { echo "BLOCKED: Boatstack binary checksum mismatch" >&2; exit 1; } chmod +x "$binary" +if [ "$mode" = "update" ]; then + current_helper="$target_repo/.product-loop/bin/boatstack-helper${extension}" + if [ -x "$current_helper" ]; then + if ! "$current_helper" doctor --repo "$target_repo"; then + echo "Current Boatstack doctor reported drift; the verified target helper will classify whether it is safely repairable." >&2 + fi + else + echo "Current Boatstack helper is missing; the verified target helper will classify whether it is safely repairable." >&2 + fi +fi + command_name="init" [ "$mode" = "update" ] && command_name="update" arguments=("$command_name" --repo "$target_repo" --binary "$binary") @@ -71,5 +98,11 @@ fi if [ "${BOATSTACK_YES:-0}" = "1" ]; then arguments+=(--yes) fi +if [ "$repair" = "1" ]; then + arguments+=(--repair) +fi +if [ "$allow_downgrade" = "1" ]; then + arguments+=(--allow-downgrade) +fi exec "$binary" "${arguments[@]}" diff --git a/labs/diagram-json/plan.lock.json b/labs/diagram-json/plan.lock.json index 27c859f..a2fd939 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": "3fc5f6eb68c22007a46434b4af9dacc31dcc04ed", + "source_commit": "bfaa855fddf392520adb0e2324d38aff0421a7fb", "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-23-bootstrap-safe-update-repair.md b/release-notes/2026-07-23-bootstrap-safe-update-repair.md new file mode 100644 index 0000000..f2f6325 --- /dev/null +++ b/release-notes/2026-07-23-bootstrap-safe-update-repair.md @@ -0,0 +1,3 @@ +### Repair an update without trusting the broken installation + +Boatstack updates now download and verify the target helper before diagnosing the installed runtime. Exact stale hook and generated-state migrations repair automatically. Recoverable Boatstack-owned drift receives a fingerprinted `--repair` preview, a Git-common backup, and remains visible in the same update PR. User-owned state is preserved, and downgrades require separate `--allow-downgrade` authority.