From f267e703162865653868387b26d3822dead1be8a Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 23 Jul 2026 08:46:19 +0100 Subject: [PATCH 1/2] fix: isolate repository administration routing --- .agents/skills/boatstack/SKILL.md | 6 +- .claude/skills/boatstack/SKILL.md | 6 +- .cursor/rules/boatstack.mdc | 3 +- .product-loop/generated.lock.json | 10 +- .../irreversible-operation-boundary.md | 2 + .product-loop/workflow.md | 2 + .../2026-07-23-recoverable-repository-sync.md | 3 + .../product-engineering-loop/SKILL.md | 6 + .../cmd/boatstack-helper/main.go | 30 +- .../product-engineering-loop/export.go | 17 +- .../product-engineering-loop/export_test.go | 18 +- .../irreversible-operation-boundary.md | 2 + .../references/workflow.md | 2 + .../product-engineering-loop/safety.go | 64 ++++- .../product-engineering-loop/safety_test.go | 36 +++ .../workspace_sync.go | 249 ++++++++++++++++ .../workspace_sync_test.go | 265 ++++++++++++++++++ 17 files changed, 701 insertions(+), 20 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-23-recoverable-repository-sync.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/workspace_sync.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go diff --git a/.agents/skills/boatstack/SKILL.md b/.agents/skills/boatstack/SKILL.md index 6d623652a..0e3b98559 100644 --- a/.agents/skills/boatstack/SKILL.md +++ b/.agents/skills/boatstack/SKILL.md @@ -1,13 +1,15 @@ --- name: boatstack -description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when ordinary free-form change language targets an active managed delivery. +description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when product behavior, implementation, test, review, delivery-evidence, CI, or publication changes target an active or current-branch published managed delivery. Do not use for repository administration such as branch sync, status, switching, worktree maintenance, or discarding local changes. --- # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery. If one exists and ordinary user language reports a problem or asks for a modification, automatically use repair even when the user did not name the operation. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. + +Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local `.product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main`. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks under a plain `### Technical details` Markdown heading. Codex must never emit raw `
` or `` tags; preserve the same content without collapse. Internal helper names must not appear in the primary response. diff --git a/.claude/skills/boatstack/SKILL.md b/.claude/skills/boatstack/SKILL.md index bd6dd9b73..418cba137 100644 --- a/.claude/skills/boatstack/SKILL.md +++ b/.claude/skills/boatstack/SKILL.md @@ -1,6 +1,6 @@ --- name: boatstack -description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when ordinary free-form change language targets an active managed delivery. +description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when product behavior, implementation, test, review, delivery-evidence, CI, or publication changes target an active or current-branch published managed delivery. Do not use for repository administration such as branch sync, status, switching, worktree maintenance, or discarding local changes. user-invocable: false --- @@ -8,7 +8,9 @@ user-invocable: false # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, check for an active managed delivery. If one exists and ordinary user language reports a problem or asks for a modification, automatically use repair even when the user did not name the operation. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. + +Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local `.product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main`. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command. Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. diff --git a/.cursor/rules/boatstack.mdc b/.cursor/rules/boatstack.mdc index a1eb623bb..13bd2d929 100644 --- a/.cursor/rules/boatstack.mdc +++ b/.cursor/rules/boatstack.mdc @@ -10,7 +10,8 @@ The source of truth is @.product-loop/workflow.md and @.product-loop/project.jso Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the explicit plan gate has produced approval.md and build activation has produced a valid plan lock. -Before modifying product code, check for an active managed delivery. When one exists and the user reports a problem or requests a modification in ordinary language, route through the Boatstack repair operation before editing. The repair operation records the exact request, compares it with approved intent, and either resumes the earliest affected stage or blocks for a plan amendment. If no managed delivery exists, continue ordinary conversation. +Before modifying product code, check for an active managed delivery. When one exists and the user reports a product behavior, implementation, test, review, or delivery-evidence problem or modification, route through the Boatstack repair operation before editing. The repair operation records the exact request, compares it with approved intent, and either resumes the earliest affected stage or blocks for a plan amendment. If no managed delivery exists, continue ordinary conversation. +Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local .product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. diff --git a/.product-loop/generated.lock.json b/.product-loop/generated.lock.json index b4945d85f..b7b2be6dc 100644 --- a/.product-loop/generated.lock.json +++ b/.product-loop/generated.lock.json @@ -11,12 +11,12 @@ "config_sha256": "71fe1cd9f09d71a6acbb5c8f46ff070c3ea158ca53a91d240e68bc7d6aeca782", "config_source": "project.json", "files": { - ".agents/skills/boatstack/SKILL.md": "738716e967cc070811ab430a5f2ea3a39e5fa9cac764661d0758ac8666b2239d", + ".agents/skills/boatstack/SKILL.md": "8b7658202601bcf41d237e43ad0bbc13a30a509344d5ccaacedd23f98237195e", ".claude/skills/auto-plan/SKILL.md": "0a6eabc446b87cc6e2092da3eebb7f08d41eac00bab3420d4fde8b2c88645767", ".claude/skills/boatstack-next/SKILL.md": "338cb6f02a3c7e64dd5efd0017910c4040bf95e3705082176aedd3bf3996b21c", ".claude/skills/boatstack-run/SKILL.md": "f1c8a45c4e7c4c26e5321ccd38f9fa9f523309dee5302144308ee8bb222fa43e", ".claude/skills/boatstack-update/SKILL.md": "f09024185ba3fc17529c9bef43bd3ec58af9295abc7cd930f1ee309f3b6d3c40", - ".claude/skills/boatstack/SKILL.md": "bdb7367f6ae0a575d1da615fc6af63ac2d452aa5566d8cf12a5b1db067ede39b", + ".claude/skills/boatstack/SKILL.md": "ba81fa3ab7ef12f031aedda5c8117e038d5daf28d693bf7ce90d23d36ea5d5e9", ".claude/skills/build/SKILL.md": "db00b6613c62718c99989028f8231a73422406184884b41c3e7008a96bf0106b", ".claude/skills/plan-gate/SKILL.md": "386a1f5650b6e50250b2f79e0eefe6ebc0e1faa67fbcf81d24ea3abb818ac044", ".claude/skills/repair/SKILL.md": "5776aa0851c23ff1447f11814a181e72ced68f924a37c744302c5d9e94557628", @@ -38,7 +38,7 @@ ".cursor/commands/test-gate.md": "c5d8c96c22fe69ed220e79c3ea37ab7013ce9771a48e13245770e2916b88f331", ".cursor/commands/workspace-cleanup.md": "a6b0395ce63af720f437bc9e6c15e0d57c962701bb64af29447b4a017e80db11", ".cursor/commands/workspace-cut.md": "913310b8d03903b12268a4c95cfbfbd456d7be967465a3d8b1c6dd9d3e3bbf1f", - ".cursor/rules/boatstack.mdc": "67ee451547d5033db53ed2da00825edd40ca56dca90a6352ac2941c4d2d0a309", + ".cursor/rules/boatstack.mdc": "fa76bbaaa6d5620fce65d71b9c064a773c630e7e56a6d0cc19375f5255932860", ".github/PULL_REQUEST_TEMPLATE/boatstack.md": "b99da15ef630721d8a6f348809a19ca34b6f0624c8a17639a177492409ce55ce", ".product-loop/.gitignore": "aaccb1a00557171b31d00a99a6a2666856e417964732490685cdcba9f02de491", ".product-loop/artifacts.md": "e4732b4d6eca6c539db456d6a8ded5e3018be2fbc8dade98e7a1a6c498acfed4", @@ -51,7 +51,7 @@ ".product-loop/hooks/guard.sh": "d6a245c72bad8f19e72fa8a853eacb8a488b3fc81b75fe87e6456f6142f12c07", ".product-loop/host-hook-contracts.md": "8c59507ee6b4465970d223737187ade904e2882aa0b3cdc2998ccf97fef1cdb1", ".product-loop/intake/.gitkeep": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ".product-loop/irreversible-operation-boundary.md": "6eecd67db959e8204943e34bd8a1ff6e57beb1ab75378f509f176971717ef40a", + ".product-loop/irreversible-operation-boundary.md": "1ff3404126a63d26f72f64bcb004f0e7d60875a2b642cd0572fc839e394dfaca", ".product-loop/project.json": "f69f0f252d2d84cf12608661e10304d4a064be450d6e7e8434ba7460a39a03e1", ".product-loop/templates/adr.md": "4b1f9c83e155f9e021156c90ca36c6faefa6d1c73b9092d395ca3e6f1f484af2", ".product-loop/templates/approval.md": "8974a30154cca25ddab2080c9c69df084d6f1ebfec6086ada72641dd74705ae2", @@ -63,7 +63,7 @@ ".product-loop/templates/plan.md": "0558371b549b79071c648372a5a223d45d3d11d3e3c892e55bda85aa7412f038", ".product-loop/templates/questions.md": "680d051a1b04de54913c74375d70bb39584b032862142ad2f4b3a9333900671f", ".product-loop/templates/test-plan.md": "4d9798f87d04ddcac2564487665bd2f0685985fa6af3144ceeda64878d622c1b", - ".product-loop/workflow.md": "d529b4290b3b65ddcf0bf0017d5f5340bfa60402fb1f2fe604c6e4a33bee333f" + ".product-loop/workflow.md": "c2eebd03c946a5c739cddaa905a1f87ba5d5bda31aa3b9418934990f896e6bac" }, "generator": "boatstack-exporter", "integrations": { diff --git a/.product-loop/irreversible-operation-boundary.md b/.product-loop/irreversible-operation-boundary.md index ecc9fbf7e..4d978e5ea 100644 --- a/.product-loop/irreversible-operation-boundary.md +++ b/.product-loop/irreversible-operation-boundary.md @@ -16,6 +16,8 @@ The guard always denies: There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary. +Recoverable repository alignment is not an exception to this policy. Raw `git reset --hard`, `git clean`, and forced history replacement remain denied. The project-local `workspace-sync` helper may align one exact local branch to one freshly fetched remote branch only after it creates and verifies Git recovery refs for the original branch and any staged, unstaged, or untracked work. It blocks active managed-delivery branches and reports the retained recovery refs. + ## Failure response After an external-write failure: diff --git a/.product-loop/workflow.md b/.product-loop/workflow.md index e700f6e07..b90283905 100644 --- a/.product-loop/workflow.md +++ b/.product-loop/workflow.md @@ -39,6 +39,8 @@ Every installed host routes supported shell and MCP events through Boatstack's i After an external-write failure, preserve state and use only read-only diagnosis. Do not escalate privileges, broaden the target, or invent a reset. Use a transactional retry only when retry safety is demonstrated; otherwise stop and fix forward. Destructive recovery is operator-only outside Boatstack. See `irreversible-operation-boundary.md` for the classified operations and evaluation status. +Repository administration is not a delivery transition. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes do not enter `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. Use the project-local `workspace-sync` helper for recoverable branch alignment: it fetches the exact remote source, preserves the original branch and dirty worktree under verified Git refs, updates the branch in its owning worktree, and verifies the final ref and clean status. A raw destructive-Git denial must return this one recovery action immediately without plan inspection or repository-wide discovery. + Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. A missing, drifted, or failing helper denies execution and requires reinstall or repair. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; preserve fail-closed behavior, reload the Cursor window, and retry before diagnosing the Boatstack installation. ## User-facing response contract diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-23-recoverable-repository-sync.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-23-recoverable-repository-sync.md new file mode 100644 index 000000000..a013496f2 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-23-recoverable-repository-sync.md @@ -0,0 +1,3 @@ +### Keep repository administration out of product delivery + +Boatstack now routes branch synchronization and dirty-worktree cleanup through one agent-agnostic, recovery-backed workspace operation instead of auto-plan or repair. Raw destructive Git remains denied; the new helper preserves the original branch and local changes under verified Git refs before alignment. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md index bf5537e28..41db58537 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -43,6 +43,12 @@ Read [irreversible-operation-boundary.md](references/irreversible-operation-boun This enforcement is defense in depth, not a complete sandbox. Keep least-privilege service credentials and service-side destructive approval in place. +## Keep repository administration outside delivery + +Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes are repository administration, not product intent. Never route them to `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. For an explicit branch and remote ref, use the project-local `workspace-sync` helper. It fetches the exact source, checkpoints branch and dirty-worktree state, aligns the branch in its owning worktree, and returns verified recovery refs. + +For requests such as “ensure main is same as origin/main remove any current changes,” inspect only the named refs and worktree, then invoke `.product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main`. If the guard denies a raw hard reset or clean, report the denial and this single recovery action immediately. Do not inspect feature plans, scan the repository, search for the helper, or retry destructive Git. + ## Bound the outcome For ordinary feature work, define one bounded outcome: diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index 192e789b6..3aa8f39f2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -927,9 +927,35 @@ func workspaceStatusCommand(arguments []string) int { return 0 } +func workspaceSyncCommand(arguments []string) int { + flags := flag.NewFlagSet("workspace-sync", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the branch to synchronize") + branch := flags.String("branch", "", "local branch to align; defaults to the current branch") + source := flags.String("source", "", "remote branch to fetch and align to, for example origin/main") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if strings.TrimSpace(*source) == "" { + return fail(fmt.Errorf("workspace-sync requires --source")) + } + result, err := boatstack.SyncWorkspace(boatstack.WorkspaceSyncOptions{Repo: *repo, Branch: *branch, Source: *source}) + if err != nil { + return fail(err) + } + value, err := boatstack.MarshalJSON(result) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + if result.Status == "BLOCKED" { + return 1 + } + return 0 +} + 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] { @@ -1001,6 +1027,8 @@ func run() int { return workspaceCleanupCommand(os.Args[2:]) case "workspace-status": return workspaceStatusCommand(os.Args[2:]) + case "workspace-sync": + return workspaceSyncCommand(os.Args[2:]) case "migrate-config": return migrateConfigCommand(os.Args[2:]) case "version": diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index 41dbe232c..4e6efcbf6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -22,6 +22,8 @@ var allowedAdapters = map[string]bool{ const executionModeDX = "\n\n**Execution Mode Notice:** Boatstack requires active tool use (such as shell execution) to run its commands and inspect the workspace. If you are currently operating in a Plan Mode, Read-Only Mode, or a state with restricted tool access, you MUST immediately inform the user that Boatstack requires full execution capabilities and ask them to approve exiting that mode or grant execution permissions before you can proceed. Do not attempt to mock or bypass the operation." +const repositoryAdministrationBoundary = `Repository administration is outside managed product delivery. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes must never route to auto-plan or repair unless the exact target branch belongs to an active managed delivery. For requests such as "ensure main is same is origin/main remove any current changes", inspect only the named refs and worktree, then invoke the exact project-local .product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main. The helper checkpoints recoverable state before alignment. If raw destructive Git is denied, report that denial and this one recovery action immediately; do not inspect plans, scan the repository, search for the helper, or retry the destructive command.` + var ( readCanonical = ReadCanonical readCanonicalDir = ReadCanonicalDir @@ -283,7 +285,7 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte } if contains(adapters, "cursor") { - rule := `--- + rule := fmt.Sprintf(`--- description: Use Boatstack for evidence-engineered planning, delivery repair, explicit approval, open implementation, evidence gates, and PR preparation. globs: alwaysApply: true @@ -293,14 +295,15 @@ The source of truth is @.product-loop/workflow.md and @.product-loop/project.jso Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation. -Before modifying product code, resolve complete Boatstack state. A saved feature plan latches managed authority: draft, approved, policy-ready, ambiguous, stale, or invalid pre-activation state cannot mutate product files, and only exact planning or activation transitions remain available. Once a current plan lock exists, preserve active managed delivery and published-delivery recovery behavior. Async task completion and conversation state never grant authority. When active or published work receives a CI failure, review finding, denied publication, problem, or modification in ordinary language, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no saved plan or managed delivery exists, continue ordinary conversation. +Before modifying product code, resolve complete Boatstack state. A saved feature plan latches managed authority: draft, approved, policy-ready, ambiguous, stale, or invalid pre-activation state cannot mutate product files, and only exact planning or activation transitions remain available. Once a current plan lock exists, preserve active managed delivery and published-delivery recovery behavior. Async task completion and conversation state never grant authority. When active or published work receives a product behavior, implementation, test, review, delivery-evidence, CI, or publication problem or modification, route through the Boatstack repair operation before editing. Never ask the user to manually repeat a denied push or PR mutation. If no saved plan or managed delivery exists, continue ordinary conversation. +%s Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. When the user naturally asks Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package, generate an evidence-limited ad-hoc PR brief. Use the committed branch diff and observed checks, label missing evidence NOT_VERIFIED, and never imply Boatstack approval or passed gates. This is natural-language behavior, not a /pr-brief command. Preview the exact title and body before asking for one open/update confirmation. When the user asks to update Boatstack itself, use /boatstack-update. Release discovery is read-only and cached; repository mutation begins only from a clean current default branch and is isolated in a versioned chore/update-boatstack branch. Preview the exact infrastructure diff before requiring open update PR. Never mix a Boatstack update into product work or merge it automatically. Do not branch behavior on model name, provider, or price; branch on observed work state and evidence. Boatstack's repository hooks deny high-confidence irreversible operations across every agent call. There is no in-session bypass. Preserve failed external state, use read-only diagnosis and fix-forward recovery, and leave intentional destructive recovery to an operator-owned surface outside Boatstack. -` +`, repositoryAdministrationBoundary) files[fmt.Sprintf(".cursor/rules/%s.mdc", adapterName)], err = GeneratedFrontmatter(rule + executionModeDX + "\n" + ExecutionBoundaryDX) if err != nil { return ExportBundle{}, err @@ -312,12 +315,14 @@ Boatstack's repository hooks deny high-confidence irreversible operations across adapterSkill := fmt.Sprintf(`--- name: %s -description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when ordinary free-form change language, CI failure, review feedback, or a denied publication targets an active or current-branch published managed delivery. +description: Use when the user asks what is next in Boatstack, asks Boatstack to run a feature through ship, or asks Boatstack to auto-plan, repair, approve a plan, build, test, review, ship, update Boatstack, or run a retrospective. Also use automatically when product behavior, implementation, test, review, delivery-evidence, CI, or publication changes target an active or current-branch published managed delivery. Do not use for repository administration such as branch sync, status, switching, worktree maintenance, or discarding local changes. --- # Boatstack adapter - Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair for ordinary failure or change language. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid managed operations are next, boatstack-next, run, boatstack-run, auto-plan, plan-gate, build, repair, test-gate, review-gate/review, ship-gate/ship, boatstack-update, retro, workspace-cut, and workspace-cleanup. Route next and natural-language questions such as "what's next in Boatstack?" to the read-only boatstack-next operation. Route run and requests such as "run Boatstack through ship" to boatstack-run. Before any product edit, resolve complete Boatstack state. Once auto-plan creates a saved feature plan, draft, approved, policy-ready, ambiguous, stale, or invalid state denies product mutation until controlled activation creates a current lock; conversation and async completion never grant authority. For an active or current-branch published managed delivery, automatically use repair only for product behavior, implementation, test, review, or delivery-evidence failures and changes. Never instruct the user to manually repeat a push or PR mutation denied by the safety hook. + +%s Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. @@ -342,7 +347,7 @@ When the user asks to update Boatstack, run the boatstack-update operation. Neve For a managed ship, use the internal pr-context operation with --feature to project the feature spec, accepted decisions, actual committed diff, evidence ledger, review findings, gaps, rollout, and rollback into the required pr.md artifact. Inspect the returned changed files, diff stat, high-risk matches, and the actual diff before writing claims; commits alone are not authoritative. Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. Add UI evidence, security/privacy, migration, or operations sections only when relevant. For a natural-language request to improve an existing or ad-hoc PR, run pr-context without --feature and use the same reviewer-first format from observed branch facts, but mark unavailable approval or gate evidence as NOT_VERIFIED. Never create or advertise a /pr-brief command. Validate with check-pr and always show the exact title and rendered body before publication. Ask for state-scoped o to open or u to update the PR. Only after the matching shortcut or compatible full reply, commit only pr.md, revalidate the unchanged preview fingerprint, and invoke the internal publish-pr operation with the selected action. It may perform a normal push but never force-push. Any intervening product diff or evidence change invalidates the preview. Keep model attribution inside collapsed provenance. Internal helper names and hashes stay out of the primary response. If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside Boatstack operations. If Spec Kit is enabled, use it to generate or cross-check artifacts; never invoke speckit.implement to bypass Boatstack's plan approval and build gate. - `, adapterName) + executionModeDX + `, adapterName, repositoryAdministrationBoundary) + executionModeDX if contains(adapters, "claude") { claudeAdapterSkill := strings.Replace( adapterSkill, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go index ac7681ab2..cac58fb8a 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export_test.go @@ -314,7 +314,7 @@ func TestExportAndDriftCheck(t *testing.T) { } } cursorRule := string(bundle.Files[".cursor/rules/boatstack.mdc"]) - for _, expected := range []string{"alwaysApply: true", "Before modifying product code", "active managed delivery", "repair operation", "ordinary language"} { + for _, expected := range []string{"alwaysApply: true", "Before modifying product code", "active managed delivery", "repair operation", "product behavior"} { if !strings.Contains(cursorRule, expected) { t.Fatalf("Cursor rule is missing conversational repair routing %q", expected) } @@ -489,12 +489,26 @@ func TestPortableHostAdaptersShareWorkflowAndArtifactContract(t *testing.T) { "codex": string(bundle.Files[".agents/skills/boatstack/SKILL.md"]), } for host, surface := range hostSurfaces { - for _, expected := range []string{".product-loop/project.json", ".product-loop/workflow.md"} { + for _, expected := range []string{ + ".product-loop/project.json", + ".product-loop/workflow.md", + "Repository administration is outside managed product delivery", + "Branch synchronization, status, switching, worktree maintenance", + "must never route to auto-plan or repair", + "ensure main is same is origin/main remove any current changes", + ".product-loop/bin/boatstack-helper workspace-sync --repo . --branch main --source origin/main", + "do not inspect plans, scan the repository, search for the helper", + } { if !strings.Contains(surface, expected) { t.Fatalf("%s adapter does not reference shared repository contract %q", host, expected) } } } + for host, surface := range hostSurfaces { + if strings.Contains(surface, "automatically use repair for ordinary failure or change language") { + t.Fatalf("%s adapter retains broad free-form repair capture", host) + } + } for _, operation := range []string{"next", "boatstack-next", "run", "boatstack-run", "auto-plan", "plan-gate", "build", "test-gate", "review-gate", "ship-gate", "boatstack-update", "retro"} { if !strings.Contains(hostSurfaces["codex"], operation) { t.Fatalf("Codex router does not declare portable operation %q", operation) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md b/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md index b36d0cbd8..246d0c610 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/irreversible-operation-boundary.md @@ -14,6 +14,8 @@ The guard always denies: There is no break-glass token or in-session override. Intentional destructive recovery belongs to a separately controlled operator surface outside Boatstack. Agents may edit source that describes a dangerous operation for review, but may not execute it; an operational diff containing that capability blocks build activation and subsequent gates until it is removed or transferred to the operator boundary. +Recoverable repository alignment is not an exception to this policy. Raw `git reset --hard`, `git clean`, and forced history replacement remain denied. The project-local `workspace-sync` helper may align one exact local branch to one freshly fetched remote branch only after it creates and verifies Git recovery refs for the original branch and any staged, unstaged, or untracked work. It blocks active managed-delivery branches and reports the retained recovery refs. + ## Failure response After an external-write failure: diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index 68bfe66ee..7c5cb0837 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -42,6 +42,8 @@ Every installed host routes supported shell and MCP events through Boatstack's i After an external-write failure, preserve state and use only read-only diagnosis. Do not escalate privileges, broaden the target, or invent a reset. Use a transactional retry only when retry safety is demonstrated; otherwise stop and fix forward. Destructive recovery is operator-only outside Boatstack. See `irreversible-operation-boundary.md` for the classified operations and evaluation status. +Repository administration is not a delivery transition. Branch synchronization, status, switching, worktree maintenance, and requests to discard local changes do not enter `auto-plan` or `repair` unless the exact target branch belongs to an active managed delivery. Use the project-local `workspace-sync` helper for recoverable branch alignment: it fetches the exact remote source, preserves the original branch and dirty worktree under verified Git refs, updates the branch in its owning worktree, and verifies the final ref and clean status. A raw destructive-Git denial must return this one recovery action immediately without plan inspection or repository-wide discovery. + Hooks are defense in depth rather than a complete sandbox. Protected systems still require least-privilege credentials, scoped service roles, backups, and service-side destructive approval. A missing, drifted, or failing helper denies execution and requires reinstall or repair. Cursor's exact `MainThreadShellExec not initialized` error occurs before the Boatstack hook starts; preserve fail-closed behavior, reload the Cursor window, and retry before diagnosing the Boatstack installation. ## User-facing response contract diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index 45ca18ac9..5bdfe5a7f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -112,6 +112,59 @@ func controlledPhaseTransition(command, stage string) bool { } } +func controlledWorkspaceSync(repo, command string) bool { + if strings.ContainsAny(command, "\n`><;&|") || strings.Contains(command, "$(") { + return false + } + fields := strings.Fields(strings.TrimSpace(command)) + if len(fields) < 4 || fields[1] != "workspace-sync" { + return false + } + executable := fields[0] + if !filepath.IsAbs(executable) { + executable = filepath.Join(repo, filepath.FromSlash(executable)) + } + executable, err := filepath.Abs(executable) + if err != nil { + return false + } + expected := filepath.Join(repo, ".product-loop", "bin", helperName()) + expected, err = filepath.Abs(expected) + if err != nil || filepath.Clean(executable) != filepath.Clean(expected) { + return false + } + info, err := os.Lstat(executable) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return false + } + seenSource := false + for index := 2; index < len(fields); index += 2 { + if index+1 >= len(fields) { + return false + } + switch fields[index] { + case "--source": + seenSource = strings.TrimSpace(fields[index+1]) != "" + case "--branch": + if strings.TrimSpace(fields[index+1]) == "" { + return false + } + case "--repo": + candidate := fields[index+1] + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(repo, filepath.FromSlash(candidate)) + } + absolute, absErr := filepath.Abs(candidate) + if absErr != nil || filepath.Clean(absolute) != filepath.Clean(repo) { + return false + } + default: + return false + } + } + return seenSource +} + func attemptedRepositoryPath(repo string, input any) string { keys := map[string]bool{"path": true, "file_path": true, "filepath": true, "target_path": true, "destination": true} var visit func(any) string @@ -391,12 +444,15 @@ func ClassifyCommand(repo, command string) []SafetyFinding { return []SafetyFinding{finding} } } + if strings.Contains(command, "workspace-sync") && !isPureReadOnlyCommand(command) && !controlledWorkspaceSync(repo, command) { + return []SafetyFinding{{Category: "workspace-sync-bypass", Reason: "recoverable branch alignment must use the exact project-local Boatstack helper", Source: "command"}} + } findings := classifySafetyText(command, "command") if len(findings) > 0 { return dedupeFindings(findings) } if !isPureReadOnlyCommand(command) { - if finding, blocked := preActivationFinding(repo, ""); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) { + if finding, blocked := preActivationFinding(repo, ""); blocked && !controlledPhaseTransition(command, finding.WorkflowStage) && !controlledWorkspaceSync(repo, command) { return []SafetyFinding{finding} } } @@ -951,6 +1007,12 @@ func denialMessage(host string, finding SafetyFinding) string { return "Boatstack could not verify the durable operation state." + context + ". Inspect operation-status before retrying." } } + if finding.Category == "git-history-destruction" { + return "Boatstack denied raw destructive Git cleanup. Use the project-local workspace-sync operation to checkpoint current state and align the exact branch; do not scan delivery artifacts or retry the destructive command." + } + if finding.Category == "workspace-sync-bypass" { + return "Boatstack denied an unverified workspace sync. Invoke only the exact project-local workspace-sync helper for the current repository." + } return "Boatstack denied an irreversible operation (" + finding.Category + "). Preserve the current state and use read-only diagnosis or fix-forward recovery; destructive recovery is operator-only outside the agent workflow." } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go index bb593fb29..4e522e408 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety_test.go @@ -73,6 +73,42 @@ func TestIrreversibleCommandCorpusIsDenied(t *testing.T) { } } +func TestWorkspaceSyncIsTheOnlyAllowedRepositoryAlignmentCommand(t *testing.T) { + repo := safetyTestRepo(t) + writeValidSavedFeaturePlan(t, repo, "pending-feature") + helper := filepath.Join(repo, ".product-loop", "bin", helperName()) + if err := os.MkdirAll(filepath.Dir(helper), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helper, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + command := ".product-loop/bin/" + helperName() + " workspace-sync --repo . --branch main --source origin/main" + if findings := ClassifyCommand(repo, command); len(findings) != 0 { + t.Fatalf("exact project-local workspace sync was denied: %#v", findings) + } + for _, command := range []string{ + "boatstack-helper workspace-sync --repo . --branch main --source origin/main", + "/tmp/boatstack-helper workspace-sync --repo . --branch main --source origin/main", + ".product-loop/bin/" + helperName() + " workspace-sync --repo /tmp --branch main --source origin/main", + } { + findings := ClassifyCommand(repo, command) + if len(findings) == 0 || findings[0].Category != "workspace-sync-bypass" { + t.Fatalf("unverified workspace sync was allowed: %s %#v", command, findings) + } + } + raw := ClassifyCommand(repo, "git reset --hard origin/main") + if len(raw) == 0 || raw[0].Category != "git-history-destruction" { + t.Fatalf("raw hard reset was not denied: %#v", raw) + } + message := denialMessage("cursor", raw[0]) + for _, expected := range []string{"project-local workspace-sync", "do not scan delivery artifacts", "do not", "retry"} { + if !strings.Contains(message, expected) { + t.Fatalf("hard-reset denial omitted %q: %s", expected, message) + } + } +} + func TestInvokedSymlinkFailsClosed(t *testing.T) { repo := safetyTestRepo(t) target := filepath.Join(repo, "target.py") diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync.go new file mode 100644 index 000000000..01c70e3eb --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync.go @@ -0,0 +1,249 @@ +package boatstack + +import ( + "fmt" + "path/filepath" + "strings" + "time" +) + +const workspaceSyncSchemaVersion = 1 + +var workspaceSyncNow = time.Now + +// WorkspaceSyncOptions identifies one local branch and one remote branch that +// should become identical. Empty Branch means the branch checked out in Repo. +type WorkspaceSyncOptions struct { + Repo string + Branch string + Source string +} + +// WorkspaceSync reports the verified result and the Git refs that retain the +// pre-sync branch and worktree state. +type WorkspaceSync struct { + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` // NO_CHANGE | SYNCED | BLOCKED + Branch string `json:"branch,omitempty"` + Source string `json:"source,omitempty"` + WorktreePath string `json:"worktree_path,omitempty"` + OldCommit string `json:"old_commit,omitempty"` + NewCommit string `json:"new_commit,omitempty"` + RecoveryRef string `json:"recovery_ref,omitempty"` + CheckpointRef string `json:"checkpoint_ref,omitempty"` + Reason string `json:"reason"` +} + +func blockedWorkspaceSync(result WorkspaceSync, reason string) WorkspaceSync { + result.SchemaVersion = workspaceSyncSchemaVersion + result.Status = "BLOCKED" + result.Reason = reason + return result +} + +func normalizeRemoteSource(repo, source string) (string, string, string, error) { + source = strings.TrimSpace(source) + source = strings.TrimPrefix(source, "refs/remotes/") + parts := strings.SplitN(source, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", fmt.Errorf("source must name a remote branch such as origin/main") + } + remote, branch := parts[0], parts[1] + ref := "refs/remotes/" + remote + "/" + branch + if _, err := workspaceGit(repo, "check-ref-format", ref); err != nil { + return "", "", "", fmt.Errorf("source %q is not a valid remote branch", source) + } + if _, err := workspaceGit(repo, "remote", "get-url", remote); err != nil { + return "", "", "", fmt.Errorf("remote %q does not exist", remote) + } + return remote, branch, ref, nil +} + +func activeDeliveryOwningBranch(repo, branch string) (string, error) { + paths := []string{repo} + if output, err := workspaceGit(repo, "worktree", "list", "--porcelain"); err == nil { + paths = paths[:0] + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, "worktree ") { + paths = append(paths, strings.TrimSpace(strings.TrimPrefix(line, "worktree "))) + } + } + } + seen := map[string]bool{} + for _, path := range paths { + active, err := ActiveManagedDeliveries(path) + if err != nil { + return "", err + } + for _, feature := range active { + key := path + "\x00" + feature + if seen[key] { + continue + } + seen[key] = true + state, loadErr := LoadDeliveryState(path, feature) + if loadErr != nil { + return "", loadErr + } + if stateMatchesBranch(state, branch) { + return feature, nil + } + } + } + return "", nil +} + +func syncRecoveryRefs(branch, oldCommit string) (string, string) { + now := workspaceSyncNow().UTC() + fingerprint := SHA256Bytes([]byte(branch + "\x00" + oldCommit + "\x00" + now.Format(time.RFC3339Nano))) + base := "refs/boatstack/recovery/workspace-sync/" + now.Format("20060102T150405Z") + "-" + fingerprint[:12] + return base + "/head", base + "/worktree" +} + +func rollbackWorkspaceCheckpoint(worktreePath, recoveryRef string, checkpointCreated bool) { + if checkpointCreated { + _, _ = workspaceGit(worktreePath, "stash", "pop", "--index") + } + if recoveryRef != "" { + _, _ = workspaceGit(worktreePath, "update-ref", "-d", recoveryRef) + } +} + +// SyncWorkspace aligns one local branch with a freshly fetched remote branch. +// It creates recovery refs before changing the branch or its owning worktree. +func SyncWorkspace(options WorkspaceSyncOptions) (WorkspaceSync, error) { + result := WorkspaceSync{SchemaVersion: workspaceSyncSchemaVersion} + repo, err := ResolveRepository(options.Repo) + if err != nil { + return blockedWorkspaceSync(result, err.Error()), nil + } + + branch := strings.TrimSpace(options.Branch) + if branch == "" { + branch, _ = workspaceGit(repo, "branch", "--show-current") + branch = strings.TrimSpace(branch) + } + if branch == "" { + return blockedWorkspaceSync(result, "A named branch is required when the current worktree is detached."), nil + } + result.Branch = branch + if _, err := workspaceGit(repo, "check-ref-format", "refs/heads/"+branch); err != nil { + return blockedWorkspaceSync(result, fmt.Sprintf("Branch %q is not a valid local branch.", branch)), nil + } + + remote, remoteBranch, sourceRef, err := normalizeRemoteSource(repo, options.Source) + if err != nil { + return blockedWorkspaceSync(result, err.Error()), nil + } + result.Source = strings.TrimPrefix(sourceRef, "refs/remotes/") + + if owner, ownerErr := activeDeliveryOwningBranch(repo, branch); ownerErr != nil { + return blockedWorkspaceSync(result, "Boatstack could not verify managed delivery ownership: "+ownerErr.Error()), nil + } else if owner != "" { + return blockedWorkspaceSync(result, fmt.Sprintf("Branch %q belongs to active managed delivery %q; use repair instead.", branch, owner)), nil + } + + localRef := "refs/heads/" + branch + oldCommit, err := workspaceGit(repo, "rev-parse", "--verify", localRef+"^{commit}") + if err != nil { + return blockedWorkspaceSync(result, fmt.Sprintf("Local branch %q does not exist.", branch)), nil + } + result.OldCommit = strings.TrimSpace(oldCommit) + + if _, err := workspaceGit(repo, "fetch", remote, "+refs/heads/"+remoteBranch+":"+sourceRef); err != nil { + return blockedWorkspaceSync(result, "Could not fetch the requested source: "+err.Error()), nil + } + newCommit, err := workspaceGit(repo, "rev-parse", "--verify", sourceRef+"^{commit}") + if err != nil { + return blockedWorkspaceSync(result, fmt.Sprintf("Fetched source %q does not resolve to a commit.", result.Source)), nil + } + result.NewCommit = strings.TrimSpace(newCommit) + + worktreePath := worktreePathForBranch(repo, branch) + if worktreePath != "" { + absolute, absErr := filepath.Abs(worktreePath) + if absErr != nil { + return blockedWorkspaceSync(result, "Could not resolve the branch's owning worktree."), nil + } + result.WorktreePath = filepath.Clean(absolute) + } + dirty := "" + if result.WorktreePath != "" { + dirty, _ = workspaceGit(result.WorktreePath, "status", "--porcelain=v1", "--untracked-files=all") + } + if result.OldCommit == result.NewCommit && strings.TrimSpace(dirty) == "" { + result.Status = "NO_CHANGE" + result.Reason = fmt.Sprintf("Branch %q already matches %s and its worktree is clean.", branch, result.Source) + return result, nil + } + + recoveryRef, checkpointRef := syncRecoveryRefs(branch, result.OldCommit) + if _, err := workspaceGit(repo, "update-ref", recoveryRef, result.OldCommit); err != nil { + return blockedWorkspaceSync(result, "Could not create the branch recovery reference: "+err.Error()), nil + } + result.RecoveryRef = recoveryRef + if verified, verifyErr := workspaceGit(repo, "rev-parse", "--verify", recoveryRef+"^{commit}"); verifyErr != nil || strings.TrimSpace(verified) != result.OldCommit { + _, _ = workspaceGit(repo, "update-ref", "-d", recoveryRef) + result.RecoveryRef = "" + return blockedWorkspaceSync(result, "Could not verify the branch recovery reference."), nil + } + + checkpointCreated := false + if strings.TrimSpace(dirty) != "" { + beforeStash, _ := workspaceGit(result.WorktreePath, "rev-parse", "--verify", "refs/stash^{commit}") + label := fmt.Sprintf("Boatstack workspace-sync %s from %s", branch, result.OldCommit) + if _, err := workspaceGit(result.WorktreePath, "stash", "push", "--include-untracked", "--message", label); err != nil { + rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, false) + result.RecoveryRef = "" + return blockedWorkspaceSync(result, "Could not checkpoint the dirty worktree: "+err.Error()), nil + } + stashCommit, stashErr := workspaceGit(result.WorktreePath, "rev-parse", "--verify", "refs/stash^{commit}") + checkpointCreated = stashErr == nil && strings.TrimSpace(stashCommit) != "" && strings.TrimSpace(stashCommit) != strings.TrimSpace(beforeStash) + if !checkpointCreated { + rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, false) + result.RecoveryRef = "" + return blockedWorkspaceSync(result, "Could not verify the dirty-worktree checkpoint."), nil + } + if _, err := workspaceGit(repo, "update-ref", checkpointRef, strings.TrimSpace(stashCommit)); err != nil { + rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, true) + result.RecoveryRef = "" + return blockedWorkspaceSync(result, "Could not preserve the dirty-worktree checkpoint: "+err.Error()), nil + } + result.CheckpointRef = checkpointRef + if verified, verifyErr := workspaceGit(repo, "rev-parse", "--verify", checkpointRef+"^{commit}"); verifyErr != nil || strings.TrimSpace(verified) != strings.TrimSpace(stashCommit) { + _, _ = workspaceGit(repo, "update-ref", "-d", checkpointRef) + result.CheckpointRef = "" + rollbackWorkspaceCheckpoint(result.WorktreePath, recoveryRef, true) + result.RecoveryRef = "" + return blockedWorkspaceSync(result, "Could not verify the dirty-worktree recovery reference."), nil + } + } + + if result.WorktreePath != "" { + if _, err := workspaceGit(result.WorktreePath, "reset", "--hard", result.NewCommit); err != nil { + return blockedWorkspaceSync(result, "The recovery checkpoint was preserved, but branch alignment failed: "+err.Error()), nil + } + } else if _, err := workspaceGit(repo, "update-ref", localRef, result.NewCommit, result.OldCommit); err != nil { + return blockedWorkspaceSync(result, "The recovery reference was preserved, but branch alignment failed: "+err.Error()), nil + } + + actual, actualErr := workspaceGit(repo, "rev-parse", "--verify", localRef+"^{commit}") + if actualErr != nil || strings.TrimSpace(actual) != result.NewCommit { + return blockedWorkspaceSync(result, "Branch alignment could not be verified; recovery references were preserved."), nil + } + if result.WorktreePath != "" { + status, statusErr := workspaceGit(result.WorktreePath, "status", "--porcelain=v1", "--untracked-files=all") + if statusErr != nil || strings.TrimSpace(status) != "" { + return blockedWorkspaceSync(result, "The target worktree is not clean after alignment; recovery references were preserved."), nil + } + } + if checkpointCreated { + if _, err := workspaceGit(repo, "rev-parse", "--verify", result.CheckpointRef+"^{commit}"); err != nil { + return blockedWorkspaceSync(result, "The worktree checkpoint became unreadable after alignment."), nil + } + } + + result.Status = "SYNCED" + result.Reason = fmt.Sprintf("Branch %q now matches %s; prior state is retained under %s.", branch, result.Source, result.RecoveryRef) + return result, nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go new file mode 100644 index 000000000..24dd35f20 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go @@ -0,0 +1,265 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func workspaceSyncRepo(t *testing.T) (string, string) { + t.Helper() + repo := workspaceRepo(t, defaultWorkspace()) + workspaceGitDo(t, repo, "add", ".product-loop/project.json") + workspaceGitDo(t, repo, "commit", "-m", "configure workspace") + remote := filepath.Join(t.TempDir(), "remote.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("init remote: %v: %s", err, output) + } + workspaceGitDo(t, repo, "remote", "add", "origin", remote) + workspaceGitDo(t, repo, "push", "-u", "origin", "main") + return repo, remote +} + +func advanceWorkspaceRemote(t *testing.T, repo string) (string, string) { + t.Helper() + oldCommit := runGit(t, repo, "rev-parse", "HEAD") + if err := os.WriteFile(filepath.Join(repo, "remote.txt"), []byte("remote\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", "remote.txt") + workspaceGitDo(t, repo, "commit", "-m", "advance remote") + newCommit := runGit(t, repo, "rev-parse", "HEAD") + workspaceGitDo(t, repo, "push", "origin", "main") + workspaceGitDo(t, repo, "reset", "--hard", oldCommit) + return oldCommit, newCommit +} + +func TestSyncWorkspaceNoChangeCreatesNoRecoveryState(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "NO_CHANGE" || result.RecoveryRef != "" || result.CheckpointRef != "" { + t.Fatalf("unexpected no-change result: %+v", result) + } + if refs := runGit(t, repo, "for-each-ref", "--format=%(refname)", "refs/boatstack/recovery/workspace-sync"); refs != "" { + t.Fatalf("no-change sync created recovery refs: %s", refs) + } +} + +func TestSyncWorkspaceCheckpointsDirtyWorktreeAndAlignsBranch(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + _, newCommit := advanceWorkspaceRemote(t, repo) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty tracked\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("dirty untracked\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", "README.md") + + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "SYNCED" || result.RecoveryRef == "" || result.CheckpointRef == "" { + t.Fatalf("unexpected sync result: %+v", result) + } + if got := runGit(t, repo, "rev-parse", "main"); got != newCommit { + t.Fatalf("main=%s, want %s", got, newCommit) + } + if status := runGit(t, repo, "status", "--porcelain=v1", "--untracked-files=all"); status != "" { + t.Fatalf("target worktree is dirty after sync: %s", status) + } + workspaceGitDo(t, repo, "stash", "apply", "--index", result.CheckpointRef) + if value, err := os.ReadFile(filepath.Join(repo, "README.md")); err != nil || string(value) != "dirty tracked\n" { + t.Fatalf("tracked checkpoint was not restorable: %q %v", value, err) + } + if value, err := os.ReadFile(filepath.Join(repo, "untracked.txt")); err != nil || string(value) != "dirty untracked\n" { + t.Fatalf("untracked checkpoint was not restorable: %q %v", value, err) + } +} + +func TestSyncWorkspaceReplacesDivergedBranchAndRetainsOldHead(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + _, newCommit := advanceWorkspaceRemote(t, repo) + if err := os.WriteFile(filepath.Join(repo, "local.txt"), []byte("local\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "add", "local.txt") + workspaceGitDo(t, repo, "commit", "-m", "local divergence") + localCommit := runGit(t, repo, "rev-parse", "HEAD") + + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "SYNCED" || runGit(t, repo, "rev-parse", "main") != newCommit { + t.Fatalf("diverged branch was not synchronized: %+v", result) + } + if got := runGit(t, repo, "rev-parse", result.RecoveryRef); got != localCommit { + t.Fatalf("recovery ref=%s, want old head %s", got, localCommit) + } +} + +func TestSyncWorkspaceUsesNamedBranchOwningWorktree(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + caller := filepath.Join(t.TempDir(), "caller") + workspaceGitDo(t, repo, "worktree", "add", "-b", "caller", caller, "main") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(caller, "caller.txt"), []byte("caller remains dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: caller, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + ownerInfo, ownerErr := os.Stat(result.WorktreePath) + repoInfo, repoErr := os.Stat(repo) + if result.Status != "SYNCED" || ownerErr != nil || repoErr != nil || !os.SameFile(ownerInfo, repoInfo) { + t.Fatalf("sync did not target main's owning worktree: %+v", result) + } + if _, err := os.Stat(filepath.Join(caller, "caller.txt")); err != nil { + t.Fatalf("caller worktree was modified: %v", err) + } + if status := runGit(t, repo, "status", "--porcelain=v1", "--untracked-files=all"); status != "" { + t.Fatalf("main worktree is dirty after sync: %s", status) + } +} + +func TestSyncWorkspaceBlocksActiveManagedDelivery(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + state := DeliveryState{ + SchemaVersion: deliveryStateSchemaVersion, + Feature: "active-main", + PlanLockHash: strings.Repeat("a", 64), + ActiveIndex: 0, + Mode: "NORMAL", + Slices: []DeliverySlice{{ + ID: "delivery", Title: "Delivery", Status: "BUILD", HeadBranch: "main", + }}, + } + if err := saveDeliveryState(repo, state); err != nil { + t.Fatal(err) + } + before := runGit(t, repo, "rev-parse", "main") + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "BLOCKED" || !strings.Contains(result.Reason, "use repair instead") { + t.Fatalf("active delivery was not blocked: %+v", result) + } + if after := runGit(t, repo, "rev-parse", "main"); after != before { + t.Fatalf("blocked sync changed main from %s to %s", before, after) + } +} + +func TestSyncWorkspaceFailuresDoNotChangeTargetState(t *testing.T) { + t.Run("fetch", func(t *testing.T) { + repo, remote := workspaceSyncRepo(t) + before := runGit(t, repo, "rev-parse", "main") + if err := os.Rename(remote, remote+".missing"); err != nil { + t.Fatal(err) + } + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "main") != before { + t.Fatalf("fetch failure changed target state: %+v", result) + } + }) + + t.Run("checkpoint", func(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + beforeHead := runGit(t, repo, "rev-parse", "main") + oldGit := workspaceGit + workspaceGit = func(path string, arguments ...string) (string, error) { + if len(arguments) >= 2 && arguments[0] == "stash" && arguments[1] == "push" { + return "", fmt.Errorf("injected checkpoint failure") + } + return gitCommand(path, arguments...) + } + t.Cleanup(func() { workspaceGit = oldGit }) + + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "main") != beforeHead { + t.Fatalf("checkpoint failure changed target ref: %+v", result) + } + if value, _ := os.ReadFile(filepath.Join(repo, "README.md")); string(value) != "dirty\n" { + t.Fatalf("checkpoint failure changed worktree: %q", value) + } + if refs := runGit(t, repo, "for-each-ref", "--format=%(refname)", "refs/boatstack/recovery/workspace-sync"); refs != "" { + t.Fatalf("checkpoint failure retained recovery refs: %s", refs) + } + }) + + t.Run("checkpoint verification preserves prior stash", func(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + if err := os.WriteFile(filepath.Join(repo, "prior.txt"), []byte("prior\n"), 0o644); err != nil { + t.Fatal(err) + } + workspaceGitDo(t, repo, "stash", "push", "--include-untracked", "--message", "prior user stash") + priorStash := runGit(t, repo, "rev-parse", "refs/stash") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("current dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + oldGit := workspaceGit + workspaceGit = func(path string, arguments ...string) (string, error) { + if len(arguments) >= 2 && arguments[0] == "stash" && arguments[1] == "push" { + return "injected success without checkpoint", nil + } + return gitCommand(path, arguments...) + } + t.Cleanup(func() { workspaceGit = oldGit }) + + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Branch: "main", Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "refs/stash") != priorStash { + t.Fatalf("checkpoint verification disturbed the prior stash: %+v", result) + } + if value, _ := os.ReadFile(filepath.Join(repo, "README.md")); string(value) != "current dirty\n" { + t.Fatalf("checkpoint verification changed current worktree: %q", value) + } + }) + + t.Run("detached", func(t *testing.T) { + repo, _ := workspaceSyncRepo(t) + workspaceGitDo(t, repo, "checkout", "--detach") + before := runGit(t, repo, "rev-parse", "HEAD") + result, err := SyncWorkspace(WorkspaceSyncOptions{Repo: repo, Source: "origin/main"}) + if err != nil { + t.Fatal(err) + } + if result.Status != "BLOCKED" || runGit(t, repo, "rev-parse", "HEAD") != before { + t.Fatalf("detached target was not safely blocked: %+v", result) + } + }) +} + +func TestSyncRecoveryRefsAreUnique(t *testing.T) { + oldNow := workspaceSyncNow + workspaceSyncNow = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 1, time.UTC) } + t.Cleanup(func() { workspaceSyncNow = oldNow }) + head, worktree := syncRecoveryRefs("main", strings.Repeat("a", 40)) + if head == worktree || !strings.HasSuffix(head, "/head") || !strings.HasSuffix(worktree, "/worktree") { + t.Fatalf("unexpected recovery refs: %s %s", head, worktree) + } +} From d589911ccb9138013c8693d85b96be0e7c903be3 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Thu, 23 Jul 2026 08:53:13 +0100 Subject: [PATCH 2/2] test: normalize restored line endings --- .../product-engineering-loop/workspace_sync_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go index 24dd35f20..09567bfe6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/workspace_sync_test.go @@ -77,10 +77,10 @@ func TestSyncWorkspaceCheckpointsDirtyWorktreeAndAlignsBranch(t *testing.T) { t.Fatalf("target worktree is dirty after sync: %s", status) } workspaceGitDo(t, repo, "stash", "apply", "--index", result.CheckpointRef) - if value, err := os.ReadFile(filepath.Join(repo, "README.md")); err != nil || string(value) != "dirty tracked\n" { + if value, err := os.ReadFile(filepath.Join(repo, "README.md")); err != nil || strings.ReplaceAll(string(value), "\r\n", "\n") != "dirty tracked\n" { t.Fatalf("tracked checkpoint was not restorable: %q %v", value, err) } - if value, err := os.ReadFile(filepath.Join(repo, "untracked.txt")); err != nil || string(value) != "dirty untracked\n" { + if value, err := os.ReadFile(filepath.Join(repo, "untracked.txt")); err != nil || strings.ReplaceAll(string(value), "\r\n", "\n") != "dirty untracked\n" { t.Fatalf("untracked checkpoint was not restorable: %q %v", value, err) } }