diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9eb46d7..cb35cbb 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/46be4fd2d8ebbc00e28c10e78685b721b2c62fe8/examples/12-product-engineering-loop). +Boatstack is a generated content distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/4fee357eb535287be4b172b2af4c2e44939ce196/examples/12-product-engineering-loop). The Boatstack repository receives product/runtime changes through a generated pull request. Review the PR's `UPSTREAM.json`, tests, adapter diff, and context-size change; do not hand-edit generated output on `main`. `.github/workflows` is the exception: it is Boatstack's executable control plane, excluded from scheduled projection and changed only through a separate manually reviewed Boatstack PR. diff --git a/README.md b/README.md index b096e4e..20d781e 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

Build freely. Prove it. Ship.

-Boatstack is **evidence-engineered coding**: a model-neutral coding node that turns product intent and repository context into an explicitly approved, tested, reviewable change. It does not prescribe the model, implementation technique, tools, or document structure. It governs what may be claimed, approved, or shipped. Its behavior is generated from [Intelligence Flow at `46be4fd2d8ebbc00e28c10e78685b721b2c62fe8`](https://github.com/operatorstack/intelligence-flow/tree/46be4fd2d8ebbc00e28c10e78685b721b2c62fe8/examples/12-product-engineering-loop). +Boatstack is **evidence-engineered coding**: a model-neutral coding node that turns product intent and repository context into an explicitly approved, tested, reviewable change. It does not prescribe the model, implementation technique, tools, or document structure. It governs what may be claimed, approved, or shipped. Its behavior is generated from [Intelligence Flow at `4fee357eb535287be4b172b2af4c2e44939ce196`](https://github.com/operatorstack/intelligence-flow/tree/4fee357eb535287be4b172b2af4c2e44939ce196/examples/12-product-engineering-loop). > **You are free in how you build. Only claims of completion require evidence.** @@ -53,9 +53,11 @@ The installer previews the generated paths, verifies the platform helper, asks a ```text idea -> Plan mode -> /auto-plan -> questions -> /plan-gate -> approve -> Build -> /build -> /test-gate - -> /review-gate -> /ship-gate -> PR + -> /review-gate -> /ship-gate -> preview -> confirm -> PR ``` +At ship, Boatstack compiles the approved intent, actual committed diff, evidence, decisions, gaps, rollout, and rollback into a reviewer-ready title and body. It shows the exact preview first; GitHub changes only after `open PR` or `update PR`. For an existing branch, simply ask **“Use Boatstack to improve this PR.”** There is no extra `/pr-brief` command, and missing workflow evidence is labeled `NOT_VERIFIED` rather than invented. + ## Plan first, then auto-plan Start with ordinary product intent **inside Cursor, Codex, or Claude Plan mode**: @@ -313,7 +315,7 @@ Read the [research and design record](docs/research-and-design.md) and [corpus a ## Context has a budget -The three canonical runtime references currently total approximately **5250 estimated tokens** using `ceil(characters / 4)`. That is a stable compactness signal, not provider billing. Host adapters stay thin and load the operation-specific slice on demand. +The three canonical runtime references currently total approximately **6039 estimated tokens** using `ceil(characters / 4)`. That is a stable compactness signal, not provider billing. Host adapters stay thin and load the operation-specific slice on demand. ## Status diff --git a/UPSTREAM.json b/UPSTREAM.json index ca17445..8de04b1 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 21000, - "estimated_tokens": 5250, + "characters": 24156, + "estimated_tokens": 6039, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -11,10 +11,10 @@ }, "files": { ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", - "CONTRIBUTING.md": "d6611d54de720531ad55cbb4a711fedfc8cb4a545c580077785a871db7edfcd1", - "README.md": "e3bd6393e0e1d01e57275085598ddb89e7a317ef41e1b56cf0e3ab0783172e00", + "CONTRIBUTING.md": "89ac3ca1d81d4a10e433fcf3246413ae4856f47ae9850471717d75623f82914f", + "README.md": "ad6943d05ba387efcfefc99a2f847a2fd45b4d035766e56b69ad85ac70bff866", "assets/boatstack-mark.svg": "c46e935f06fcfde3b37abfd579c1963b765b2337a0fa993f9538c9b652297e39", - "boatstack/SKILL.md": "7452435698e962a50162aa22563465c6f8bdf0857646ea0a2aa508f6e7a68fa3", + "boatstack/SKILL.md": "ca9f6119c85dc178f7a98bc4dc8fa0b66f1f6d00465381f5489272d7413ea6bc", "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", "boatstack/assets/templates/approval.md": "74b0b816703a6dce3c96c8f95f981af910b020b6908e7f76cf5630778637e9f5", @@ -28,9 +28,9 @@ "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", "boatstack/atomic_unix.go": "89f2723361591de2bb8bd22ce7e34ec529d3278509f0df78fd5c4a7d4140fbe9", "boatstack/atomic_windows.go": "cefd775cbe7e7c3bd8a3f5673b11cdd784c6d3ebd6de7dcb8f39406b0bee511f", - "boatstack/cmd/boatstack-helper/main.go": "e21101b8df6170c01a98bff71f8a2e9d7ac9ef92b883ec50020eb3a011a7737d", - "boatstack/export.go": "16b3d7a88668f374d73db58d87e48207151d39578309ca44efc46ed4b1605c69", - "boatstack/export_test.go": "28cac84532a7ee4dbe9a5f58ea9560db6f978fd3deded4eea76327121a8a0084", + "boatstack/cmd/boatstack-helper/main.go": "8f63ed355fd9e28d57b04d744ce1369865d875047db0bdfc2add5ea60987a500", + "boatstack/export.go": "f09b39643eba5b96b5f53b5f838a07cd78186524db667319a0f55d9d98b0b6dd", + "boatstack/export_test.go": "2298a48972b30c072005722572d4b45822cad389839834f14629fff3ec43b6f8", "boatstack/go.mod": "daf262a00abfe961d8ca266d4b26eea09a6aee73e4c53baaa537a809eaef59f6", "boatstack/init.go": "40e62f3502fe704e98dfbb0017e8869fd6aed3011d508a8448d882fe9ed6b52c", "boatstack/init_test.go": "282451f7abd03c32c536512568f0cfcb858754835523d16706ac1de4f5fc6419", @@ -39,26 +39,29 @@ "boatstack/plan_test.go": "f95ce7a38276f957064ee83901566c84ebf3eec0683e81794f823dc446b2ac54", "boatstack/planning.go": "d8b0b9842beb37392f0993f273849d45edd9564c1b17afe78d418a8c9c0d8f06", "boatstack/planning_test.go": "4662908c1ec063aa8ef6f91db52247864303d9b91ef2363a8f68b41082fe383f", - "boatstack/references/artifacts.md": "22b6cc596b65c8c2a22b0f3a692ca16a808fa495dad0b4453d23ea734978a2b7", + "boatstack/pr.go": "3b9c924b5a149230b28d98a849c85788d4ee20c6a8a4af8f83c6aa74b01f73cf", + "boatstack/pr_test.go": "f03d8d4cbb879f6c1cfe4b9daa240bda75b737490540930085cdf719a063701f", + "boatstack/references/artifacts.md": "fc6438b43a6de998fa20da91f1703248ca0b2707b83e4d65898eab9a80cb9aeb", "boatstack/references/failure-moves.md": "2d7d3988c70718e9cc02104f9899a00208173e2f654d1046edd22079f4d46f41", "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", - "boatstack/references/workflow.md": "8f8624c88a7f61fdb28baa5f1c24e285c14a0e25a122c6fda3572e7d0d6a56cf", + "boatstack/references/workflow.md": "84c9a244d8d8564d4dee1a8c4b38d7bc41685b395dcdebab26233c591b2a2dd2", "boatstack/runtime.go": "b988d57ec14e15fc6a57949a995879fc0e0d6bfa9a7b62935e7754df0b85d87a", + "boatstack/testdata/reviewer-pr-body.md": "7cf83e5deb07bc1d145266820afd7f58e3d3ec6b8d3ca1eb2f9b353b36925c2e", "docs/account-recovery-walkthrough.md": "912edec85d930750c044bcd6117df9d03491a8c91f139af3ee82ab853452f1b7", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/evidence-engineered-coding.md": "fb63e822926fb07cec95b836a2cb7ac2ad6c17f95f54d7e5cdd4cf782ba0fd2a", - "docs/generated-files.md": "0ca73d52bb286b86324c6bcf0ef1c5aee5dc831e35b826b8a9e8cce65c311505", - "docs/getting-started.md": "4e32193555d10c070edbbb58fc90cbec8a4f141ae73ba45809cb74424b794b7c", - "docs/research-and-design.md": "67dc454f0d13e0e2809f49e910f7847457fa78f03c4cef6fb0138f6039a57c4f", - "docs/troubleshooting.md": "6a2f8483d6f1fb7e0ae6265f3a07af05583e8c5585b8b887d307702bd9b14705", + "docs/evidence-engineered-coding.md": "2249eab16ea28543f077935ef45b9ef336aeeedfbd9fbca89bc537808a67cc40", + "docs/generated-files.md": "6670e6f607ac8a7a4a7201429a944e4fccdbc40c27f0909430aea93c12f5eaa1", + "docs/getting-started.md": "bd943f6e965e2f4fc9be5348cc76574d5d0926f2b18d9d93697155d5f5f9c690", + "docs/research-and-design.md": "84e0eac2b59843c1e9b7a9d8c60ec12cca563e501c9e7306e283cef683795cc1", + "docs/troubleshooting.md": "d961f6f209fb291bf0aec6be3ac41cc0a1eeec4526d0668d67ae2fe71c59c41d", "docs/validation-and-evidence.md": "3b5ed588bd44c5568f0c313be0dfaa411e959dc184fe886dfd0a81aee9fd25cc", "examples/diagram-json/README.md": "061b583180e43bbd26618bbd9d3d79af4b75d7c8f37c66475640745a97328fbc", "examples/diagram-json/approval.md": "bc421a825349923512d5cb0ce489310d3a4d7cbac35e661a693b4a32eec263d1", "examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", "examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896", "examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf", - "examples/diagram-json/plan.lock.json": "b477e561d295bf762daf6b631bab01896420eb25aa51b49ca0efdc369bd1611b", + "examples/diagram-json/plan.lock.json": "7d5274e024ea27231b1f57dcff36465a58b01fc4862bca3d7efad91ebb327041", "examples/diagram-json/plan.md": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76", "examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", @@ -71,7 +74,7 @@ "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "46be4fd2d8ebbc00e28c10e78685b721b2c62fe8", + "commit": "4fee357eb535287be4b172b2af4c2e44939ce196", "path": "examples/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index f0246c0..4771833 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -17,7 +17,7 @@ Map the request to one operation: - `build`: activate the approved Markdown plan, then implement its tasks in bounded, reversible slices. - `test-gate`: test requirements and relevant regressions using independent evidence. - `review-gate`: review the diff against the spec, project invariants, risks, and known gaps. -- `ship-gate`: prepare a reviewable PR with evidence, rollback notes, and explicit gaps. +- `ship-gate`: preview, then explicitly open or update, a reviewer-ready PR grounded in the approved diff and evidence. - `retro`: classify failures, propose a harness move, and gate it before promotion. - `export`: generate thin Cursor, Claude Code, Codex, and GitHub adapters. @@ -140,12 +140,34 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. ### Ship gate - Require a clean, intentional diff; passing required checks; a filled evidence ledger; explicit known gaps; and rollout/rollback notes. -- Create a PR, but keep merge and deploy as separate authorized actions. +- Project only review-relevant context into `.product-loop/features//pr.md`: why, changed behavior, review order, decisions, acceptance evidence, gaps, risks, rollout, rollback, and collapsed provenance. +- Treat the actual committed diff as what changed, approved artifacts as why it changed, and evidence as the only support for completion claims. +- In the visible Evidence table, link each managed claim to the current repository-relative evidence ledger using a readable link label; do not expose hashes or absolute paths. +- 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. +- Internally generate the normalized context and preview skeleton with `pr-context --repo . --feature `, write `pr.md`, and validate it with `check-pr --repo . --preview `. Keep these helper names and their fingerprints out of the primary response. +- Inspect the projected changed files, diff stat, high-risk matches, and actual diff before composing the brief. Commit messages are navigation aids, not proof of what changed. +- Show the exact title and rendered body before any GitHub mutation. If no PR exists, make `Reply open PR` the one next action; if one exists, use `Reply update PR`. +- After that exact confirmation, commit only the reviewed `pr.md`, rerun the preview check, require the same preview fingerprint, then invoke the internal publisher with the selected open/update action. It rechecks the current committed diff, approval, lock, and evidence and performs only a normal push. Any intervening change invalidates the preview and requires regeneration; never force-push. +- Keep model attribution inside collapsed provenance. Create or update the PR, but keep merge and deploy as separate authorized actions. - Never hide failed experiments, skipped checks, or `PASS_WITH_GAPS` behind a green summary. - If a required check also fails on the base branch, record that comparison and recommend a separate repair PR. Do not edit unrelated code in the approved feature branch. A bypass is valid only when repository policy permits it and the human explicitly authorizes it; otherwise return to planning for any scope expansion. Gate statuses are `PASS`, `PASS_WITH_GAPS`, and `BLOCKED`. Critical safety, correctness, or product-acceptance gaps always produce `BLOCKED`. +## Improve an existing PR without a public command + +When the user naturally asks Boatstack to prepare, improve, summarize, or update a PR and no managed feature package is available: + +1. Do not invent a `/pr-brief` command or require the user to learn another operation. +2. Project the current committed branch diff, commits, observed checks, and relevant repository context into `.product-loop/pr-briefs//pr.md`. +3. Use the same reviewer-first title/body contract as `ship-gate`, but label missing approval or gate evidence `NOT_VERIFIED`. Never imply Boatstack approved the plan or passed a gate that did not run. +4. Add conditional security/privacy, migration, UI evidence, or operations sections only when the diff makes them relevant. +5. Preview the exact title and rendered body. Ask for only `Reply open PR` or `Reply update PR`, as appropriate. +6. Internally run `pr-context --repo .` without a feature, validate with `check-pr`, and keep those mechanics out of the primary response. +7. After confirmation, commit only `pr.md`, recheck the exact preview fingerprint and committed diff, then publish with the selected open/update action. If anything changed, regenerate instead of publishing stale text. + +This is a two-slice ZCA projection: the reviewer brief minimizes review effort, while collapsed provenance preserves the evidence boundary. The projection must not become a dump of every generated artifact. + ## Learn without overfitting Read [failure-moves.md](references/failure-moves.md) before proposing a loop change. diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 4299860..107ab80 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -199,9 +199,89 @@ func doctorCommand(arguments []string) int { return 0 } +func prContextCommand(arguments []string) int { + flags := flag.NewFlagSet("pr-context", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose branch should be projected") + feature := flags.String("feature", "", "managed Boatstack feature slug; omit for evidence-limited ad-hoc mode") + base := flags.String("base", "", "base branch; defaults to the Boatstack project configuration") + format := flags.String("format", "json", "json or template") + if err := flags.Parse(arguments); err != nil { + return 2 + } + context, err := boatstack.PreparePRContext(boatstack.PRContextOptions{Repo: *repo, Feature: *feature, Base: *base}) + if err != nil { + return fail(err) + } + switch *format { + case "json": + value, err := boatstack.PRContextJSON(context) + if err != nil { + return fail(err) + } + fmt.Print(string(value)) + case "template": + fmt.Print(boatstack.PRPreviewTemplate(context)) + default: + return fail(fmt.Errorf("pr-context format must be json or template")) + } + return 0 +} + +func checkPRCommand(arguments []string) int { + flags := flag.NewFlagSet("check-pr", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the PR preview") + previewPath := flags.String("preview", "", "reviewed pr.md preview") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *previewPath == "" { + return fail(fmt.Errorf("check-pr requires --preview")) + } + preview, context, err := boatstack.CheckPRPreview(*repo, *previewPath) + if err != nil { + return fail(err) + } + action, url, actionErr := boatstack.RecommendedPRAction(*repo) + fmt.Printf("PASS: exact PR preview matches the current branch and evidence\nPR_ACTION=%s\nPR_TITLE=%s\nPREVIEW_FINGERPRINT=%s\nCONTEXT_FINGERPRINT=%s\n", action, preview.Title, preview.Fingerprint, context.ContextFingerprint) + if url != "" { + fmt.Printf("PR_URL=%s\n", url) + } + if actionErr != nil { + fmt.Printf("PUBLICATION_NOTE=%s\n", actionErr) + } + fmt.Printf("--- PR BODY ---\n%s\n--- END PR BODY ---\n", string(boatstack.PRBody(preview))) + return 0 +} + +func publishPRCommand(arguments []string) int { + flags := flag.NewFlagSet("publish-pr", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the PR preview") + previewPath := flags.String("preview", "", "reviewed pr.md preview") + fingerprint := flags.String("preview-fingerprint", "", "exact preview fingerprint confirmed by the human") + action := flags.String("action", "", "open or update") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *previewPath == "" || *fingerprint == "" || *action == "" { + return fail(fmt.Errorf("publish-pr requires --preview, --preview-fingerprint, and --action")) + } + url, err := boatstack.PublishPR(boatstack.PRPublishOptions{ + Repo: *repo, PreviewPath: *previewPath, ExpectedFingerprint: *fingerprint, Action: *action, + }) + if err != nil { + return fail(err) + } + verb := "opened" + if *action == "update" { + verb = "updated" + } + fmt.Printf("PASS: PR %s without merge authorization\nPR_URL=%s\n", verb, url) + 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] { @@ -219,6 +299,12 @@ func run() int { return recordApprovalCommand(os.Args[2:]) case "activate-plan": return activatePlanCommand(os.Args[2:]) + case "pr-context": + return prContextCommand(os.Args[2:]) + case "check-pr": + return checkPRCommand(os.Args[2:]) + case "publish-pr": + return publishPRCommand(os.Args[2:]) case "doctor": return doctorCommand(os.Args[2:]) case "version": diff --git a/boatstack/export.go b/boatstack/export.go index 5baf367..ed84937 100644 --- a/boatstack/export.go +++ b/boatstack/export.go @@ -155,9 +155,9 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Implementation tactics remain open inside the approved boundary. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "test-gate": "Build a requirement-to-evidence matrix and treat self-authored tests as evidence rather than the sole oracle. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required repair or authorization the one next action.", "review-gate": "Review the actual diff against approved intent, invariants, risks, gaps, and test evidence. On pass respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", - "ship-gate": "Prepare a PR only; do not merge or deploy without separate authorization. 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. On success respond PR ready or PR opened and make Review the PR the one next action; never imply merge authorization.", + "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, and review evidence, 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, and rollback into its required pr.md path, then run check-pr --repo . --preview . Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance; add UI evidence, security/privacy, migration, or operations sections only when the diff makes them relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready with Reply open PR as the one next action; if update, use Reply update PR; if manual, preserve the preview and give one manual publication action. Only after that exact reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization.", "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 a PR only; do not merge or deploy without separate authorization. Keep pre-existing unrelated failures out of the approved feature branch. Use PR ready or PR opened and make Review the PR the one next action.", + "ship": "Alias of ship-gate: prepare and preview the exact reviewer-ready title and body before any GitHub mutation. Require Reply open PR or Reply update 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.", } @@ -173,6 +173,7 @@ Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. Do not start build work until the explicit plan gate has produced approval.md and build activation has produced a valid plan lock. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. +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. Do not branch behavior on model name, provider, or price; branch on observed work state and evidence. ` files[fmt.Sprintf(".cursor/rules/%s.mdc", adapterName)], err = GeneratedFrontmatter(rule) @@ -203,6 +204,8 @@ Use .product-loop/artifacts.md for document boundaries and .product-loop/failure At ship, prove whether a failing check is pre-existing by checking the base branch. Keep unrelated repairs in a separate PR; do not modify unrelated code under the approved feature lock. A repository-policy bypass requires explicit human authorization and recorded evidence. +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 exactly Reply open PR or Reply update PR. Only after that 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) if contains(adapters, "claude") { @@ -218,43 +221,46 @@ If gstack is enabled, use only its namespaced /gstack-* specialist lenses inside } } if contains(adapters, "github") { - files[fmt.Sprintf(".github/PULL_REQUEST_TEMPLATE/%s.md", adapterName)] = GeneratedMarkdown(`# Evidence-engineered change + files[fmt.Sprintf(".github/PULL_REQUEST_TEMPLATE/%s.md", adapterName)] = GeneratedMarkdown(`# Reviewer-ready change + +## Why this change -## Approved intent +Explain the user or engineering outcome, not merely the files edited. -- Feature spec: -- Approved plan hash: -- Human approver: -- Linked ADRs/questions: +## What changed -## Outcome +| Area | Before | After | Reviewer focus | +|---|---|---|---| +| | | | | -- User-visible change: -- Non-goals preserved: +## Review order -## Gate evidence +1. Start with the contract, trust boundary, or user-visible behavior. -- Test gate: BLOCKED -- Review gate: BLOCKED -- Ship gate: BLOCKED -- Evidence ledger: +## Evidence -## Known gaps +| Claim | Evidence | Result | Source | +|---|---|---|---| +| | | NOT_VERIFIED | | -- Gap ledger: -- PASS_WITH_GAPS rationale, owner, and revisit trigger: +## Known gaps and risks + +List explicit gaps with impact and revisit trigger, or state that no material gaps are known. ## Rollout and rollback - Rollout: - Observability: -- Rollback: +- Smallest safe rollback: + +
+Boatstack provenance -## Generated adapter update +- Mode: managed or evidence-limited ad-hoc +- Approval and gate evidence: +- Coding-host attribution: -- Boatstack version: -- Config hash: -- Export check: +
`) } diff --git a/boatstack/export_test.go b/boatstack/export_test.go index b85e5c8..b0782f0 100644 --- a/boatstack/export_test.go +++ b/boatstack/export_test.go @@ -100,6 +100,26 @@ func TestExportAndDriftCheck(t *testing.T) { t.Fatal("build adapter must activate the Markdown plan exactly once") } ship := string(bundle.Files[".cursor/commands/ship-gate.md"]) + for _, expected := range []string{"separate repair PR", "Never edit unrelated code", "exact title", "Reply open PR", "Reply update PR", "preview fingerprint"} { + if !strings.Contains(ship, expected) { + t.Fatalf("ship adapter is missing reviewer-ready PR rule %q", expected) + } + } + if _, exists := bundle.Files[".cursor/commands/pr-brief.md"]; exists { + t.Fatal("PR brief must remain natural-language behavior, not a public command") + } + cursorRule := string(bundle.Files[".cursor/rules/boatstack.mdc"]) + for _, expected := range []string{"naturally asks Boatstack", "evidence-limited ad-hoc PR brief", "not a /pr-brief command", "NOT_VERIFIED"} { + if !strings.Contains(cursorRule, expected) { + t.Fatalf("Cursor rule is missing ad-hoc PR behavior %q", expected) + } + } + prTemplate := string(bundle.Files[".github/PULL_REQUEST_TEMPLATE/boatstack.md"]) + for _, expected := range []string{"## Why this change", "## What changed", "## Review order", "## Evidence", "## Known gaps and risks", "## Rollout and rollback", "Boatstack provenance"} { + if !strings.Contains(prTemplate, expected) { + t.Fatalf("generated PR template is missing %q", expected) + } + } if !strings.Contains(ship, "separate repair PR") || !strings.Contains(ship, "Never edit unrelated code") { t.Fatal("ship adapter permits unrelated scope expansion") } @@ -115,7 +135,7 @@ func TestExportAndDriftCheck(t *testing.T) { } for _, path := range []string{".agents/skills/boatstack/SKILL.md", ".claude/skills/boatstack/SKILL.md"} { adapter := string(bundle.Files[path]) - for _, expected := range []string{"User-facing response contract", "exactly one Next step", "Normal approval is simply approve", "filesystem username"} { + for _, expected := range []string{"User-facing response contract", "exactly one Next step", "Normal approval is simply approve", "filesystem username", "Never create or advertise a /pr-brief command", "Reply open PR", "Reply update PR"} { if !strings.Contains(adapter, expected) { t.Fatalf("%s is missing response-DX rule %q", path, expected) } diff --git a/boatstack/pr.go b/boatstack/pr.go new file mode 100644 index 0000000..bb7caf9 --- /dev/null +++ b/boatstack/pr.go @@ -0,0 +1,816 @@ +package boatstack + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +const prPreviewSchemaVersion = 1 + +var prStatusPattern = regexp.MustCompile(`(?i)^(PASS|PASS_WITH_GAPS|NOT_VERIFIED|BLOCKED)$`) + +type PRContextOptions struct { + Repo string + Feature string + Base string +} + +type PRSource struct { + Kind string `json:"kind"` + Path string `json:"path"` + SHA256 string `json:"sha256"` +} + +type PRContext struct { + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Feature string `json:"feature,omitempty"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + BaseCommit string `json:"base_commit"` + MergeBaseCommit string `json:"merge_base_commit"` + HeadCommit string `json:"head_commit"` + ProductDiffSHA256 string `json:"product_diff_sha256"` + ContextFingerprint string `json:"context_fingerprint"` + ChangedFiles []string `json:"changed_files"` + Commits []string `json:"commits"` + DiffStat string `json:"diff_stat"` + ContextPaths []string `json:"context_paths,omitempty"` + ProjectCommands map[string]string `json:"project_commands,omitempty"` + HighRiskFiles []string `json:"high_risk_files,omitempty"` + GateStatus map[string]string `json:"gate_status,omitempty"` + Sources []PRSource `json:"sources,omitempty"` + PreviewPath string `json:"preview_path"` +} + +type PRPreview struct { + SchemaVersion int + Title string + Mode string + Feature string + BaseBranch string + HeadBranch string + ContextFingerprint string + Body string + Path string + Fingerprint string +} + +type PRPublishOptions struct { + Repo string + PreviewPath string + ExpectedFingerprint string + Action string +} + +func commandOutput(repo string, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + command.Dir = repo + value, err := command.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(value)) + if message == "" { + message = err.Error() + } + return "", fmt.Errorf("%s", message) + } + return strings.TrimSpace(string(value)), nil +} + +func gitCommand(repo string, arguments ...string) (string, error) { + return commandOutput(repo, "git", append([]string{"-C", repo}, arguments...)...) +} + +func defaultPRBase(repo string) string { + configPath := filepath.Join(repo, ".product-loop", "project.json") + if config, _, err := LoadConfig(configPath); err == nil && strings.TrimSpace(config.Project.DefaultBranch) != "" { + return strings.TrimSpace(config.Project.DefaultBranch) + } + if branch := strings.TrimPrefix(gitOutput(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"), "origin/"); branch != "" { + return branch + } + return "main" +} + +func resolveBaseCommit(repo, base string) (string, error) { + for _, candidate := range []string{"refs/remotes/origin/" + base, "refs/heads/" + base, base} { + if commit, err := gitCommand(repo, "rev-parse", "--verify", candidate+"^{commit}"); err == nil { + return commit, nil + } + } + return "", fmt.Errorf("base branch %q is not available locally; fetch it and try again", base) +} + +func previewSlug(branch string) string { + value := strings.ToLower(branch) + var result strings.Builder + lastDash := false + for _, character := range value { + if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + result.WriteRune(character) + lastDash = false + } else if !lastDash && result.Len() > 0 { + result.WriteByte('-') + lastDash = true + } + } + return strings.Trim(result.String(), "-") +} + +func expectedPRPreviewPath(mode, feature, head string) (string, error) { + switch mode { + case "managed": + if !featureSlugPattern.MatchString(feature) { + return "", fmt.Errorf("managed PR context requires a lowercase kebab-case feature") + } + return filepath.ToSlash(filepath.Join(".product-loop", "features", feature, "pr.md")), nil + case "ad-hoc": + slug := previewSlug(head) + if slug == "" { + return "", fmt.Errorf("current branch cannot be converted into a PR brief slug") + } + return filepath.ToSlash(filepath.Join(".product-loop", "pr-briefs", slug, "pr.md")), nil + default: + return "", fmt.Errorf("unsupported PR context mode: %s", mode) + } +} + +func dirtyPaths(repo string) ([]string, error) { + command := exec.Command("git", "-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all") + value, err := command.Output() + if err != nil { + return nil, err + } + if len(value) == 0 { + return nil, nil + } + paths := []string{} + records := bytes.Split(value, []byte{0}) + for index := 0; index < len(records); index++ { + record := records[index] + if len(record) < 4 { + continue + } + status := string(record[:2]) + paths = append(paths, string(record[3:])) + if (strings.Contains(status, "R") || strings.Contains(status, "C")) && index+1 < len(records) && len(records[index+1]) > 0 { + paths = append(paths, string(records[index+1])) + index++ + } + } + return paths, nil +} + +func productDiff(repo, baseCommit, previewPath string) ([]byte, []string, error) { + pathspec := []string{"--", ".", ":(exclude).product-loop/features/*/pr.md", ":(exclude).product-loop/pr-briefs/*/pr.md"} + arguments := append([]string{"diff", "--binary", "--no-ext-diff", baseCommit, "HEAD"}, pathspec...) + diff, err := exec.Command("git", append([]string{"-C", repo}, arguments...)...).Output() + if err != nil { + return nil, nil, fmt.Errorf("cannot read product diff: %w", err) + } + nameArguments := append([]string{"diff", "--name-only", baseCommit, "HEAD"}, pathspec...) + names, err := gitCommand(repo, nameArguments...) + if err != nil { + return nil, nil, fmt.Errorf("cannot list changed files: %w", err) + } + changed := []string{} + if names != "" { + changed = strings.Split(names, "\n") + } + dirty, err := dirtyPaths(repo) + if err != nil { + return nil, nil, err + } + unexpected := []string{} + for _, path := range dirty { + if filepath.ToSlash(path) != filepath.ToSlash(previewPath) { + unexpected = append(unexpected, path) + } + } + if len(unexpected) > 0 { + sort.Strings(unexpected) + return nil, nil, fmt.Errorf("commit or remove non-preview working-tree changes before preparing the PR: %s", strings.Join(unexpected, ", ")) + } + return diff, changed, nil +} + +func productDiffStat(repo, baseCommit string) (string, error) { + pathspec := []string{"--", ".", ":(exclude).product-loop/features/*/pr.md", ":(exclude).product-loop/pr-briefs/*/pr.md"} + arguments := append([]string{"diff", "--stat", baseCommit, "HEAD"}, pathspec...) + return gitCommand(repo, arguments...) +} + +func highRiskChangedFiles(changed, patterns []string) []string { + result := []string{} + for _, path := range changed { + matched := false + for _, pattern := range patterns { + pattern = strings.TrimSpace(filepath.ToSlash(pattern)) + if pattern == "" { + continue + } + prefix := strings.TrimSuffix(pattern, "/") + globMatch, _ := filepath.Match(filepath.FromSlash(pattern), filepath.FromSlash(path)) + if globMatch || path == prefix || strings.HasPrefix(path, prefix+"/") { + matched = true + break + } + } + if matched { + result = append(result, path) + } + } + sort.Strings(result) + return result +} + +func evidenceGateStatus(value, gate string) string { + quoted := regexp.QuoteMeta(gate) + patterns := []*regexp.Regexp{ + regexp.MustCompile(`(?mi)^\s*-\s*` + quoted + `\s+gate\s*:\s*` + "`?" + `([A-Z_]+)` + "`?" + `\s*$`), + regexp.MustCompile(`(?mi)^\s*\|\s*` + quoted + `\s+gate\s*\|\s*` + "`?" + `([A-Z_]+)` + "`?"), + } + for _, pattern := range patterns { + if match := pattern.FindStringSubmatch(value); len(match) == 2 { + return strings.ToUpper(match[1]) + } + } + return "" +} + +func relativeSource(repo, path, kind string) (PRSource, error) { + hash, err := SHA256File(path) + if err != nil { + return PRSource{}, err + } + relative, err := repositoryRelativePath(repo, path) + if err != nil { + return PRSource{}, err + } + return PRSource{Kind: kind, Path: relative, SHA256: hash}, nil +} + +func managedPRSources(repo, feature string) ([]PRSource, map[string]string, error) { + directory := filepath.Join(repo, ".product-loop", "features", feature) + planPath := filepath.Join(directory, "plan.md") + approvalPath := filepath.Join(directory, "approval.md") + lockPath := filepath.Join(directory, "plan.lock.json") + check, err := CheckPlan(planPath) + if err != nil { + return nil, nil, fmt.Errorf("managed PR requires a current plan: %w", err) + } + if _, err := CheckApprovalReceipt(approvalPath, check); err != nil { + return nil, nil, fmt.Errorf("managed PR requires current approval: %w", err) + } + tasksPath := filepath.Join(directory, "compiled", "tasks.json") + if err := CheckApprovalLock(ApprovalOptions{ + SourcePlanPath: check.SourcePlanPath, + SpecPath: check.SpecPath, + PlanPath: planPath, + TasksPath: tasksPath, + OutputPath: lockPath, + }); err != nil { + return nil, nil, fmt.Errorf("managed PR requires a current build lock: %w", err) + } + evidencePath := filepath.Join(directory, "evidence.md") + if !fileExists(evidencePath) { + evidencePath = filepath.Join(directory, "compiled", "evidence.md") + } + if err := checkNonEmptyFile(evidencePath, "feature evidence"); err != nil { + return nil, nil, err + } + evidence, err := os.ReadFile(evidencePath) + if err != nil { + return nil, nil, err + } + gateStatus := map[string]string{ + "test": evidenceGateStatus(string(evidence), "Test"), + "review": evidenceGateStatus(string(evidence), "Review"), + } + for _, gate := range []string{"test", "review"} { + status := gateStatus[gate] + if status != "PASS" && status != "PASS_WITH_GAPS" { + return nil, nil, fmt.Errorf("managed PR requires %s-gate evidence marked PASS or PASS_WITH_GAPS; found %q", gate, status) + } + } + paths := []struct{ kind, path string }{ + {"source_plan", check.SourcePlanPath}, + {"feature_spec", check.SpecPath}, + {"plan", planPath}, + {"approval", approvalPath}, + {"plan_lock", lockPath}, + {"evidence", evidencePath}, + } + for _, optional := range []struct{ kind, name string }{ + {"questions", "questions.md"}, {"gaps", "gaps.md"}, {"test_plan", "test-plan.md"}, + } { + path := filepath.Join(directory, optional.name) + if fileExists(path) { + paths = append(paths, struct{ kind, path string }{optional.kind, path}) + } + } + sources := make([]PRSource, 0, len(paths)) + for _, item := range paths { + source, err := relativeSource(repo, item.path, item.kind) + if err != nil { + return nil, nil, err + } + sources = append(sources, source) + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) + return sources, gateStatus, nil +} + +func PreparePRContext(options PRContextOptions) (PRContext, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return PRContext{}, err + } + head, err := gitCommand(repo, "branch", "--show-current") + if err != nil || head == "" { + return PRContext{}, fmt.Errorf("PR preparation requires a named branch") + } + configPath := filepath.Join(repo, ".product-loop", "project.json") + config, _, err := LoadConfig(configPath) + if err != nil { + return PRContext{}, fmt.Errorf("PR preparation requires a valid Boatstack project configuration: %w", err) + } + base := strings.TrimSpace(options.Base) + if base == "" { + base = strings.TrimSpace(config.Project.DefaultBranch) + if base == "" { + base = defaultPRBase(repo) + } + } + if head == base { + return PRContext{}, fmt.Errorf("current branch %q is the configured base branch", head) + } + baseCommit, err := resolveBaseCommit(repo, base) + if err != nil { + return PRContext{}, err + } + mergeBaseCommit, err := gitCommand(repo, "merge-base", baseCommit, "HEAD") + if err != nil || mergeBaseCommit == "" { + return PRContext{}, fmt.Errorf("cannot determine the merge base between %s and %s", base, head) + } + headCommit, err := gitCommand(repo, "rev-parse", "HEAD") + if err != nil { + return PRContext{}, err + } + mode := "ad-hoc" + if strings.TrimSpace(options.Feature) != "" { + mode = "managed" + } + previewPath, err := expectedPRPreviewPath(mode, options.Feature, head) + if err != nil { + return PRContext{}, err + } + diff, changed, err := productDiff(repo, mergeBaseCommit, previewPath) + if err != nil { + return PRContext{}, err + } + if len(changed) == 0 { + return PRContext{}, fmt.Errorf("branch has no committed product changes relative to %s", base) + } + diffStat, err := productDiffStat(repo, mergeBaseCommit) + if err != nil { + return PRContext{}, err + } + log, err := gitCommand(repo, "log", "--format=%h %s", mergeBaseCommit+"..HEAD") + if err != nil { + return PRContext{}, err + } + commits := []string{} + if log != "" { + commits = strings.Split(log, "\n") + } + configSource, err := relativeSource(repo, configPath, "project_config") + if err != nil { + return PRContext{}, err + } + sources := []PRSource{configSource} + gateStatus := map[string]string{} + if mode == "managed" { + managedSources, statuses, sourceErr := managedPRSources(repo, options.Feature) + if sourceErr != nil { + return PRContext{}, sourceErr + } + sources = append(sources, managedSources...) + gateStatus = statuses + } + sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path }) + fingerprintPayload, err := MarshalJSON(map[string]any{ + "schema_version": prPreviewSchemaVersion, + "mode": mode, + "feature": options.Feature, + "base_branch": base, + "head_branch": head, + "base_commit": baseCommit, + "merge_base_commit": mergeBaseCommit, + "product_diff_sha256": SHA256Bytes(diff), + "gate_status": gateStatus, + "sources": sources, + }) + if err != nil { + return PRContext{}, err + } + return PRContext{ + SchemaVersion: prPreviewSchemaVersion, Mode: mode, Feature: options.Feature, + BaseBranch: base, HeadBranch: head, BaseCommit: baseCommit, MergeBaseCommit: mergeBaseCommit, HeadCommit: headCommit, + ProductDiffSHA256: SHA256Bytes(diff), ContextFingerprint: SHA256Bytes(fingerprintPayload), + ChangedFiles: changed, Commits: commits, DiffStat: diffStat, + ContextPaths: config.Project.Context, ProjectCommands: config.Project.Commands, + HighRiskFiles: highRiskChangedFiles(changed, config.Project.HighRiskPaths), + GateStatus: gateStatus, Sources: sources, + PreviewPath: previewPath, + }, nil +} + +func parsePRFrontmatter(value string) (map[string]string, string, error) { + if !strings.HasPrefix(value, "---\n") { + return nil, "", fmt.Errorf("PR preview must start with YAML frontmatter") + } + end := strings.Index(value[4:], "\n---\n") + if end < 0 { + return nil, "", fmt.Errorf("PR preview frontmatter is missing its closing delimiter") + } + frontmatter := value[4 : 4+end] + body := strings.TrimSpace(value[4+end+len("\n---\n"):]) + fields := map[string]string{} + allowed := map[string]bool{ + "boatstack_pr_version": true, "title": true, "mode": true, "feature": true, + "base": true, "head": true, "context_fingerprint": true, + } + for _, line := range strings.Split(frontmatter, "\n") { + key, raw, found := strings.Cut(line, ":") + if !found { + return nil, "", fmt.Errorf("invalid PR frontmatter line: %s", line) + } + key = strings.TrimSpace(key) + raw = strings.TrimSpace(raw) + if !allowed[key] { + return nil, "", fmt.Errorf("unsupported PR frontmatter field: %s", key) + } + if _, exists := fields[key]; exists { + return nil, "", fmt.Errorf("duplicate PR frontmatter field: %s", key) + } + if key == "boatstack_pr_version" { + fields[key] = raw + continue + } + var decoded string + if err := json.Unmarshal([]byte(raw), &decoded); err != nil { + return nil, "", fmt.Errorf("PR frontmatter field %s must be a JSON-quoted string", key) + } + fields[key] = decoded + } + for key := range allowed { + if _, exists := fields[key]; !exists { + return nil, "", fmt.Errorf("PR frontmatter is missing %s", key) + } + } + return fields, body, nil +} + +func section(value, heading string) string { + start := strings.Index(value, heading) + if start < 0 { + return "" + } + remainder := value[start+len(heading):] + if next := strings.Index(remainder, "\n## "); next >= 0 { + remainder = remainder[:next] + } + return strings.TrimSpace(remainder) +} + +func validateEvidenceTable(body string, mode string) error { + evidence := section(body, "## Evidence") + if evidence == "" { + return fmt.Errorf("PR body requires a non-empty Evidence section") + } + lines := strings.Split(evidence, "\n") + rows := 0 + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") || strings.Contains(strings.ToLower(trimmed), "| claim ") || strings.Contains(trimmed, "---") { + continue + } + cells := strings.Split(strings.Trim(trimmed, "|"), "|") + if len(cells) != 4 { + return fmt.Errorf("Evidence rows require Claim, Evidence, Result, and Source columns") + } + status := strings.ToUpper(strings.Trim(strings.TrimSpace(cells[2]), "`")) + if !prStatusPattern.MatchString(status) { + return fmt.Errorf("unsupported evidence result %q", status) + } + if strings.TrimSpace(cells[0]) == "" || strings.TrimSpace(cells[1]) == "" || strings.TrimSpace(cells[3]) == "" { + return fmt.Errorf("Evidence rows must include claim, evidence, and source") + } + if mode == "managed" && (status == "NOT_VERIFIED" || status == "BLOCKED") { + return fmt.Errorf("managed PR evidence cannot contain %s results", status) + } + rows++ + } + if rows == 0 { + return fmt.Errorf("PR body requires at least one structured evidence row") + } + return nil +} + +func validateManagedEvidenceSources(body string, sources []PRSource) error { + evidencePaths := []string{} + for _, source := range sources { + if source.Kind == "evidence" { + evidencePaths = append(evidencePaths, source.Path) + } + } + if len(evidencePaths) == 0 { + return fmt.Errorf("managed PR context has no current evidence source") + } + evidence := section(body, "## Evidence") + for _, line := range strings.Split(evidence, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "|") || strings.Contains(strings.ToLower(trimmed), "| claim ") || strings.Contains(trimmed, "---") { + continue + } + cells := strings.Split(strings.Trim(trimmed, "|"), "|") + if len(cells) != 4 { + continue + } + sourceCell := strings.TrimSpace(cells[3]) + matched := false + for _, path := range evidencePaths { + if strings.Contains(sourceCell, path) { + matched = true + break + } + } + if !matched { + return fmt.Errorf("managed PR evidence rows must link the current evidence ledger: %s", strings.Join(evidencePaths, " or ")) + } + } + return nil +} + +func ParsePRPreview(path string) (PRPreview, error) { + value, err := os.ReadFile(path) + if err != nil { + return PRPreview{}, err + } + fields, body, err := parsePRFrontmatter(string(value)) + if err != nil { + return PRPreview{}, err + } + version, err := strconv.Atoi(fields["boatstack_pr_version"]) + if err != nil || version != prPreviewSchemaVersion { + return PRPreview{}, fmt.Errorf("boatstack_pr_version must be %d", prPreviewSchemaVersion) + } + preview := PRPreview{ + SchemaVersion: version, Title: strings.TrimSpace(fields["title"]), Mode: fields["mode"], + Feature: fields["feature"], BaseBranch: fields["base"], HeadBranch: fields["head"], + ContextFingerprint: fields["context_fingerprint"], Body: body, Path: path, + Fingerprint: SHA256Bytes(value), + } + if preview.Title == "" || strings.Contains(preview.Title, "\n") || len([]rune(preview.Title)) > 120 { + return PRPreview{}, fmt.Errorf("PR title must be one non-empty line of at most 120 characters") + } + if preview.Mode != "managed" && preview.Mode != "ad-hoc" { + return PRPreview{}, fmt.Errorf("PR mode must be managed or ad-hoc") + } + if preview.Mode == "managed" && !featureSlugPattern.MatchString(preview.Feature) { + return PRPreview{}, fmt.Errorf("managed PR preview requires a lowercase kebab-case feature") + } + if preview.Mode == "ad-hoc" && preview.Feature != "" { + return PRPreview{}, fmt.Errorf("ad-hoc PR preview must not claim a managed feature") + } + if strings.TrimSpace(preview.BaseBranch) == "" || strings.TrimSpace(preview.HeadBranch) == "" { + return PRPreview{}, fmt.Errorf("PR preview requires base and head branches") + } + if len(preview.ContextFingerprint) != 64 { + return PRPreview{}, fmt.Errorf("PR preview requires a valid context fingerprint") + } + for _, heading := range []string{ + "## Why this change", "## What changed", "## Review order", "## Evidence", + "## Known gaps and risks", "## Rollout and rollback", + } { + if section(body, heading) == "" { + return PRPreview{}, fmt.Errorf("PR body requires a non-empty %s section", strings.TrimPrefix(heading, "## ")) + } + } + if !strings.Contains(body, "
") || !strings.Contains(body, "Boatstack provenance") || !strings.Contains(body, "
") { + return PRPreview{}, fmt.Errorf("PR body requires collapsed Boatstack provenance") + } + if err := validateEvidenceTable(body, preview.Mode); err != nil { + return PRPreview{}, err + } + return preview, nil +} + +func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) { + repo, err := ResolveRepository(repoPath) + if err != nil { + return PRPreview{}, PRContext{}, err + } + if !filepath.IsAbs(previewPath) { + previewPath = filepath.Join(repo, filepath.FromSlash(previewPath)) + } + if resolved, resolveErr := filepath.EvalSymlinks(repo); resolveErr == nil { + repo = resolved + } + if resolved, resolveErr := filepath.EvalSymlinks(previewPath); resolveErr == nil { + previewPath = resolved + } + if err := rejectSymlinkComponents(repo, previewPath); err != nil { + return PRPreview{}, PRContext{}, err + } + preview, err := ParsePRPreview(previewPath) + if err != nil { + return PRPreview{}, PRContext{}, err + } + context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: preview.Feature, Base: preview.BaseBranch}) + if err != nil { + return PRPreview{}, PRContext{}, err + } + expectedPath, err := resolveRepositoryRelativePath(repo, context.PreviewPath) + if err != nil { + return PRPreview{}, PRContext{}, err + } + actualPath, err := filepath.Abs(previewPath) + if err != nil { + return PRPreview{}, PRContext{}, err + } + if resolved, resolveErr := filepath.EvalSymlinks(expectedPath); resolveErr == nil { + expectedPath = resolved + } + if resolved, resolveErr := filepath.EvalSymlinks(actualPath); resolveErr == nil { + actualPath = resolved + } + if filepath.Clean(expectedPath) != filepath.Clean(actualPath) { + return PRPreview{}, PRContext{}, fmt.Errorf("PR preview must be stored at %s", context.PreviewPath) + } + if preview.Mode != context.Mode || preview.BaseBranch != context.BaseBranch || preview.HeadBranch != context.HeadBranch || preview.ContextFingerprint != context.ContextFingerprint { + return PRPreview{}, PRContext{}, fmt.Errorf("PR preview is stale or does not match the current branch context; regenerate it") + } + if context.Mode == "managed" { + if err := validateManagedEvidenceSources(preview.Body, context.Sources); err != nil { + return PRPreview{}, PRContext{}, err + } + } + return preview, context, nil +} + +func ghAvailable(repo string) error { + if _, err := exec.LookPath("gh"); err != nil { + return fmt.Errorf("GitHub CLI is unavailable; the validated preview remains available for manual publication") + } + if _, err := commandOutput(repo, "gh", "auth", "status", "-h", "github.com"); err != nil { + return fmt.Errorf("GitHub CLI is not authenticated; the validated preview remains available for manual publication") + } + return nil +} + +func existingPRURL(repo string) (string, bool, error) { + if err := ghAvailable(repo); err != nil { + return "", false, err + } + value, err := commandOutput(repo, "gh", "pr", "view", "--json", "url", "--jq", ".url") + if err != nil { + message := strings.ToLower(err.Error()) + for _, expected := range []string{ + "no pull requests found", "no open pull requests", "could not resolve to a pullrequest", + } { + if strings.Contains(message, expected) { + return "", false, nil + } + } + return "", false, fmt.Errorf("cannot determine whether this branch already has a PR: %w", err) + } + if strings.TrimSpace(value) == "" { + return "", false, nil + } + return strings.TrimSpace(value), true, nil +} + +func RecommendedPRAction(repo string) (string, string, error) { + repository, err := ResolveRepository(repo) + if err != nil { + return "", "", err + } + url, exists, err := existingPRURL(repository) + if err != nil { + return "manual", "", err + } + if exists { + return "update", url, nil + } + return "open", "", nil +} + +func PublishPR(options PRPublishOptions) (string, error) { + repo, err := ResolveRepository(options.Repo) + if err != nil { + return "", err + } + preview, context, err := CheckPRPreview(repo, options.PreviewPath) + if err != nil { + return "", err + } + if strings.TrimSpace(options.ExpectedFingerprint) == "" || options.ExpectedFingerprint != preview.Fingerprint { + return "", fmt.Errorf("publication fingerprint does not match the exact preview confirmed by the human") + } + if options.Action != "open" && options.Action != "update" { + return "", fmt.Errorf("publication action must be open or update") + } + dirty, err := dirtyPaths(repo) + if err != nil { + return "", err + } + if len(dirty) > 0 { + return "", fmt.Errorf("commit the exact reviewed pr.md before publication; working tree is not clean") + } + if err := ghAvailable(repo); err != nil { + return "", err + } + if _, err := gitCommand(repo, "remote", "get-url", "origin"); err != nil { + return "", fmt.Errorf("GitHub publication requires an origin remote") + } + existingURL, exists, err := existingPRURL(repo) + if err != nil { + return "", err + } + if options.Action == "open" && exists { + return "", fmt.Errorf("a PR already exists for %s; regenerate the preview for update", context.HeadBranch) + } + if options.Action == "update" && !exists { + return "", fmt.Errorf("no PR exists for %s; regenerate the preview for opening", context.HeadBranch) + } + if _, err := gitCommand(repo, "push", "--set-upstream", "origin", context.HeadBranch); err != nil { + return "", fmt.Errorf("cannot push %s without rewriting history: %w", context.HeadBranch, err) + } + temporary, err := os.CreateTemp("", "boatstack-pr-body-*.md") + if err != nil { + return "", err + } + temporaryPath := temporary.Name() + defer os.Remove(temporaryPath) + if _, err := temporary.WriteString(preview.Body + "\n"); err != nil { + temporary.Close() + return "", err + } + if err := temporary.Close(); err != nil { + return "", err + } + if options.Action == "open" { + url, err := commandOutput(repo, "gh", "pr", "create", "--base", context.BaseBranch, "--head", context.HeadBranch, "--title", preview.Title, "--body-file", temporaryPath) + if err != nil { + return "", err + } + return strings.TrimSpace(url), nil + } + if _, err := commandOutput(repo, "gh", "pr", "edit", existingURL, "--title", preview.Title, "--body-file", temporaryPath); err != nil { + return "", err + } + return existingURL, nil +} + +func PRPreviewTemplate(context PRContext) string { + quote := func(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) + } + return strings.Join([]string{ + "---", + "boatstack_pr_version: 1", + "title: " + quote("Describe the reviewer-visible outcome"), + "mode: " + quote(context.Mode), + "feature: " + quote(context.Feature), + "base: " + quote(context.BaseBranch), + "head: " + quote(context.HeadBranch), + "context_fingerprint: " + quote(context.ContextFingerprint), + "---", + "## Why this change", "", "Explain the user or engineering outcome.", "", + "## What changed", "", "| Area | Before | After | Reviewer focus |", "|---|---|---|---|", "| | | | |", "", + "## Review order", "", "1. Start with the contract or boundary that defines the behavior.", "", + "## Evidence", "", "| Claim | Evidence | Result | Source |", "|---|---|---|---|", "| | | `NOT_VERIFIED` | |", "", + "## Known gaps and risks", "", "List explicit gaps or say that no material gaps are known.", "", + "## Rollout and rollback", "", "Describe deployment impact and the smallest safe rollback.", "", + "
", "Boatstack provenance", "", "Summarize mode, approval/evidence availability, and coding-host attribution here.", "", "
", "", + }, "\n") +} + +func PRContextJSON(context PRContext) ([]byte, error) { + return MarshalJSON(context) +} + +func PRBody(preview PRPreview) []byte { + return bytes.TrimSpace([]byte(preview.Body)) +} diff --git a/boatstack/pr_test.go b/boatstack/pr_test.go new file mode 100644 index 0000000..f651e8c --- /dev/null +++ b/boatstack/pr_test.go @@ -0,0 +1,436 @@ +package boatstack + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func runGit(t *testing.T, repo string, arguments ...string) string { + t.Helper() + command := exec.Command("git", append([]string{"-C", repo}, arguments...)...) + value, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v: %s", strings.Join(arguments, " "), err, value) + } + return strings.TrimSpace(string(value)) +} + +func prTestRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + runGit(t, repo, "init", "-b", "main") + runGit(t, repo, "config", "user.name", "Boatstack Test") + runGit(t, repo, "config", "user.email", "boatstack@example.invalid") + config := testConfig() + config.Project.DefaultBranch = "main" + config.Project.Context = []string{"README.md"} + config.Project.HighRiskPaths = []string{"feature.go"} + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, ".product-loop"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# Fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "base") + remote := filepath.Join(t.TempDir(), "origin.git") + if output, err := exec.Command("git", "init", "--bare", remote).CombinedOutput(); err != nil { + t.Fatalf("git init --bare: %v: %s", err, output) + } + runGit(t, repo, "remote", "add", "origin", remote) + runGit(t, repo, "push", "--set-upstream", "origin", "main") + runGit(t, repo, "switch", "-c", "feat/reviewer-ready") + if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", "feature.go") + runGit(t, repo, "commit", "-m", "add reviewer-visible behavior") + return repo +} + +func quoted(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func previewDocument(context PRContext, title, body string) string { + return strings.Join([]string{ + "---", + "boatstack_pr_version: 1", + "title: " + quoted(title), + "mode: " + quoted(context.Mode), + "feature: " + quoted(context.Feature), + "base: " + quoted(context.BaseBranch), + "head: " + quoted(context.HeadBranch), + "context_fingerprint: " + quoted(context.ContextFingerprint), + "---", + strings.TrimSpace(body), + "", + }, "\n") +} + +func fixturePRBody(t *testing.T) string { + t.Helper() + value, err := os.ReadFile(filepath.Join("testdata", "reviewer-pr-body.md")) + if err != nil { + t.Fatal(err) + } + return string(value) +} + +func writePreview(t *testing.T, repo string, context PRContext, title, body string) string { + t.Helper() + path := filepath.Join(repo, filepath.FromSlash(context.PreviewPath)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(previewDocument(context, title, body)), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestAdHocPRContextAndPreviewAreEvidenceLimited(t *testing.T) { + repo := prTestRepo(t) + context, err := PreparePRContext(PRContextOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + if context.Mode != "ad-hoc" || context.Feature != "" { + t.Fatalf("unexpected ad-hoc context: %#v", context) + } + if context.PreviewPath != ".product-loop/pr-briefs/feat-reviewer-ready/pr.md" { + t.Fatalf("unexpected preview path: %s", context.PreviewPath) + } + if len(context.Sources) != 1 || context.Sources[0].Kind != "project_config" || len(context.GateStatus) != 0 { + t.Fatal("ad-hoc context must not manufacture managed provenance") + } + if context.DiffStat == "" || len(context.HighRiskFiles) != 1 || context.HighRiskFiles[0] != "feature.go" || len(context.ContextPaths) != 1 { + t.Fatalf("ad-hoc context did not project review boundaries: %#v", context) + } + previewPath := writePreview(t, repo, context, "Make hooks and privacy fallback predictable", fixturePRBody(t)) + preview, checked, err := CheckPRPreview(repo, previewPath) + if err != nil { + t.Fatal(err) + } + if checked.ContextFingerprint != context.ContextFingerprint { + t.Fatal("checked context fingerprint changed") + } + if strings.Contains(string(PRBody(preview)), "boatstack_pr_version") || !strings.Contains(string(PRBody(preview)), "## Security and privacy") { + t.Fatal("rendered PR body must exclude frontmatter and preserve adaptive sections") + } + if !strings.Contains(preview.Body, "NOT_VERIFIED") { + t.Fatal("ad-hoc fixture must expose unavailable evidence") + } + runGit(t, repo, "add", context.PreviewPath) + runGit(t, repo, "commit", "-m", "record reviewer-ready PR preview") + if _, _, err := CheckPRPreview(repo, previewPath); err != nil { + t.Fatalf("committing only pr.md must not invalidate its own product-diff fingerprint: %v", err) + } +} + +func activateManagedFeature(t *testing.T, repo, feature string) string { + t.Helper() + directory := filepath.Join(repo, ".product-loop", "features", feature) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + plan := validPlan() + plan["feature_id"] = feature + plan["spec_path"] = "feature-spec.md" + if err := os.WriteFile(filepath.Join(directory, "source-plan.md"), []byte("# Host plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "feature-spec.md"), []byte("# Feature spec\n\nDeliver reviewer-ready output.\n"), 0o644); err != nil { + t.Fatal(err) + } + writeMarkdownPlan(t, filepath.Join(directory, "plan.md"), plan, true) + check, err := CheckPlan(filepath.Join(directory, "plan.md")) + if err != nil { + t.Fatal(err) + } + writeApprovalReceipt(t, filepath.Join(directory, "approval.md"), check.Fingerprint) + if err := ActivatePlan(ActivationOptions{ + PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"), + OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), + SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), + }); err != nil { + t.Fatal(err) + } + for _, artifact := range []struct{ name, body string }{ + {"questions.md", "# Questions\n\nAll material decisions answered.\n"}, + {"gaps.md", "# Gaps\n\nNo material ship-blocking gaps.\n"}, + {"test-plan.md", "# Test plan\n\nRun the approved contract check.\n"}, + } { + if err := os.WriteFile(filepath.Join(directory, artifact.name), []byte(artifact.body), 0o644); err != nil { + t.Fatal(err) + } + } + evidence := `# Evidence ledger + +- Test gate: ` + "`PASS`" + ` +- Review gate: ` + "`PASS_WITH_GAPS`" + ` +- Ship gate: ` + "`BLOCKED`" + ` + +## Acceptance evidence + +| Criterion | Tasks | Result | Evidence | +|---|---|---|---| +| AC-1 | T-1 | ` + "`PASS`" + ` | Contract assertions passed | + +## Commands and checks + +The project checks passed. + +## Review findings + +No blocking findings. + +## Known gaps + +One non-critical portability gap is owned. + +## Rollout and rollback + +No migration; revert the feature commit. +` + if err := os.WriteFile(filepath.Join(directory, "evidence.md"), []byte(evidence), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".product-loop/features/"+feature) + runGit(t, repo, "commit", "-m", "record approved feature evidence") + return directory +} + +func managedPRBody() string { + return `## Why this change + +Reviewers need a concise, evidence-backed view of the approved outcome. + +## What changed + +| Area | Before | After | Reviewer focus | +|---|---|---|---| +| PR preparation | Generic placeholder | Reviewer-ready evidence projection | Evidence remains traceable | + +## Review order + +1. Review the evidence boundary, then the rendered output. + +## Evidence + +| Claim | Evidence | Result | Source | +|---|---|---|---| +| Approved behavior is implemented | Contract assertions passed | ` + "`PASS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) | +| Review found no blocking issue | Independent diff review | ` + "`PASS_WITH_GAPS`" + ` | [Evidence ledger](.product-loop/features/reviewer-ready/evidence.md) | + +## Known gaps and risks + +One non-critical portability gap remains recorded with an owner. + +## Rollout and rollback + +No migration is required; revert the feature commit to roll back. + +
+Boatstack provenance + +- Mode: managed +- Approval and gates: current +- Coding-host attribution: recorded when known + +
+` +} + +func TestManagedPRRequiresCurrentApprovalLockAndGateEvidence(t *testing.T) { + repo := prTestRepo(t) + directory := activateManagedFeature(t, repo, "reviewer-ready") + context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "reviewer-ready"}) + if err != nil { + t.Fatal(err) + } + if context.Mode != "managed" || context.GateStatus["test"] != "PASS" || context.GateStatus["review"] != "PASS_WITH_GAPS" { + t.Fatalf("unexpected managed context: %#v", context) + } + previewPath := writePreview(t, repo, context, "Generate evidence-backed PR reviews", managedPRBody()) + preview, _, err := CheckPRPreview(repo, previewPath) + if err != nil { + t.Fatal(err) + } + if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "commit the exact reviewed pr.md") { + t.Fatalf("expected uncommitted preview to block publication, got %v", err) + } + runGit(t, repo, "add", context.PreviewPath) + runGit(t, repo, "commit", "-m", "record PR preview") + preview, _, err = CheckPRPreview(repo, previewPath) + if err != nil { + t.Fatal(err) + } + unsupportedSource := strings.Replace(managedPRBody(), "[Evidence ledger](.product-loop/features/reviewer-ready/evidence.md)", "Unlinked summary", 1) + if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, unsupportedSource)), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "link the current evidence ledger") { + t.Fatalf("expected untraceable managed evidence to block, got %v", err) + } + + unsafeBody := strings.Replace(managedPRBody(), "`PASS_WITH_GAPS`", "`NOT_VERIFIED`", 1) + if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, unsafeBody)), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "managed PR evidence") { + t.Fatalf("expected unsupported managed claim to block, got %v", err) + } + if err := os.WriteFile(previewPath, []byte(previewDocument(context, preview.Title, managedPRBody())), 0o644); err != nil { + t.Fatal(err) + } + + evidencePath := filepath.Join(directory, "evidence.md") + evidence, _ := os.ReadFile(evidencePath) + if err := os.WriteFile(evidencePath, append(evidence, []byte("\nAdditional runtime evidence.\n")...), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", filepath.ToSlash(filepath.Join(".product-loop", "features", "reviewer-ready", "evidence.md"))) + runGit(t, repo, "commit", "-m", "add runtime evidence") + if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("expected evidence drift to invalidate preview, got %v", err) + } +} + +func TestPRPreviewRejectsMissingSectionsMalformedRowsAndStaleDiff(t *testing.T) { + repo := prTestRepo(t) + context, err := PreparePRContext(PRContextOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + body := fixturePRBody(t) + previewPath := writePreview(t, repo, context, "Reviewer-ready change", body) + + missing := strings.Replace(body, "## Review order", "## Reading notes", 1) + if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", missing)), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "Review order") { + t.Fatalf("expected missing section failure, got %v", err) + } + + malformed := strings.Replace(body, "| Hook runs without an activated environment | Repository hook smoke procedure | `NOT_VERIFIED` | Current branch test notes |", "| incomplete | row |", 1) + if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", malformed)), 0o644); err != nil { + t.Fatal(err) + } + if _, err := ParsePRPreview(previewPath); err == nil || !strings.Contains(err.Error(), "Evidence rows") { + t.Fatalf("expected malformed evidence row failure, got %v", err) + } + + if err := os.WriteFile(previewPath, []byte(previewDocument(context, "Reviewer-ready change", body)), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "feature.go"), []byte("package fixture\n\nconst Changed = true\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := CheckPRPreview(repo, previewPath); err == nil || !strings.Contains(err.Error(), "working-tree changes") { + t.Fatalf("expected uncommitted product drift to block, got %v", err) + } +} + +func TestPublishPRRequiresExactConfirmationAndUsesBodyWithoutFrontmatter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake gh fixture uses a POSIX shell; publication behavior is covered by cross-platform pure-Go checks") + } + repo := prTestRepo(t) + context, err := PreparePRContext(PRContextOptions{Repo: repo}) + if err != nil { + t.Fatal(err) + } + previewPath := writePreview(t, repo, context, "Make hooks and privacy fallback predictable", fixturePRBody(t)) + preview, _, err := CheckPRPreview(repo, previewPath) + if err != nil { + t.Fatal(err) + } + if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "commit the exact reviewed pr.md") { + t.Fatalf("expected uncommitted preview to block publication, got %v", err) + } + runGit(t, repo, "add", context.PreviewPath) + runGit(t, repo, "commit", "-m", "record exact PR preview") + preview, _, err = CheckPRPreview(repo, previewPath) + if err != nil { + t.Fatal(err) + } + fakeDir := t.TempDir() + capture := filepath.Join(fakeDir, "body.md") + script := filepath.Join(fakeDir, "gh") + scriptBody := `#!/bin/sh +if [ "$1" = "auth" ]; then exit 0; fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + if [ "$BOATSTACK_GH_ERROR" = "1" ]; then echo "network timeout" >&2; exit 1; fi + if [ "$BOATSTACK_EXISTING_PR" = "1" ]; then echo "https://github.com/example/repo/pull/7"; exit 0; fi + echo "no pull requests found for branch" >&2 + exit 1 +fi +if [ "$1" = "pr" ] && [ "$2" = "create" ]; then + while [ "$#" -gt 0 ]; do + if [ "$1" = "--body-file" ]; then shift; cp "$1" "$BOATSTACK_BODY_CAPTURE"; fi + shift + done + echo "https://github.com/example/repo/pull/8" + exit 0 +fi +if [ "$1" = "pr" ] && [ "$2" = "edit" ]; then + while [ "$#" -gt 0 ]; do + if [ "$1" = "--body-file" ]; then shift; cp "$1" "$BOATSTACK_BODY_CAPTURE"; fi + shift + done + exit 0 +fi +exit 1 +` + if err := os.WriteFile(script, []byte(scriptBody), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BOATSTACK_BODY_CAPTURE", capture) + t.Setenv("BOATSTACK_GH_ERROR", "1") + if action, _, err := RecommendedPRAction(repo); action != "manual" || err == nil || !strings.Contains(err.Error(), "cannot determine") { + t.Fatalf("expected GitHub lookup failure to remain distinct from no PR, got action=%s err=%v", action, err) + } + t.Setenv("BOATSTACK_GH_ERROR", "") + if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: "wrong", Action: "open"}); err == nil || !strings.Contains(err.Error(), "confirmed") { + t.Fatalf("expected exact confirmation fingerprint, got %v", err) + } + url, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/example/repo/pull/8" { + t.Fatalf("unexpected created PR URL: %s", url) + } + publishedBody, _ := os.ReadFile(capture) + if strings.Contains(string(publishedBody), "boatstack_pr_version") || !strings.Contains(string(publishedBody), "## Why this change") { + t.Fatal("publisher did not strip preview frontmatter") + } + + t.Setenv("BOATSTACK_EXISTING_PR", "1") + if _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "open"}); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("expected open/update mismatch to block, got %v", err) + } + url, err = PublishPR(PRPublishOptions{Repo: repo, PreviewPath: previewPath, ExpectedFingerprint: preview.Fingerprint, Action: "update"}) + if err != nil { + t.Fatal(err) + } + if url != "https://github.com/example/repo/pull/7" { + t.Fatalf("unexpected updated PR URL: %s", url) + } +} diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 9259806..2f6277f 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -18,6 +18,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Risk/threat note | Assets, actors, trust boundaries, abuse/failure paths | Security, data, tenancy, billing, auth, or destructive paths change | | Runbook | Deploy, observe, recover, and roll back | Operational behavior changes | | Evidence ledger | Commands, results, review evidence, screenshots, CI and runtime links | Every gate | +| PR preview | Exact reviewer-ready title/body plus a hidden fingerprint of the committed diff and evidence | Ship gate, before opening or updating GitHub | | Move ledger | Failure class, intervention, prediction, paired result, decision | Improving the loop itself | ## ADR boundary @@ -61,6 +62,12 @@ Every material statement should indicate whether it came from: Generated artifacts include the canonical loop version and config hash. Human edits to generated adapters are drift and should be moved into project-owned context or canonical source. +## PR projection boundary + +`pr.md` is a lossy review projection, not a replacement for the feature package. Its visible body contains only why, changed behavior, review order, evidence, gaps/risks, rollout, and rollback. Approval hashes, source paths, and host attribution remain in non-rendered metadata or collapsed provenance. + +For managed work it lives under `.product-loop/features//pr.md` and may claim only evidence present in the current approved package. For an existing or ad-hoc branch it lives under `.product-loop/pr-briefs//pr.md`, uses observed branch facts, and labels missing approval or gate evidence `NOT_VERIFIED`. Both are committed with the branch. The preview file itself is excluded from the product-diff fingerprint. + ## Planning boundary `auto-plan` and `plan-gate` create or update Markdown only. `plan.md` is the canonical structured input and `approval.md` is the human-approval receipt. Compiled JSON and `plan.lock.json` begin only at `build` activation, after the receipt is verified. This keeps planning compatible with hosts that intentionally restrict Plan mode to documents. diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index 2862656..7c0f502 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -57,7 +57,7 @@ Lead with a plain outcome, never a machine code such as `PASS`, `PLAN_APPROVED`, | `build` success / paused | **Build complete** -> run `/test-gate`; **Build needs a decision** -> answer the blocking question | | `test-gate` pass / blocked | **Tests passed** -> run `/review-gate`; **Testing found a problem** -> perform or authorize the repair | | `review-gate` pass / blocked | **Review passed** -> run `/ship-gate`; **Changes required** -> address the blocking finding | -| `ship-gate` success | **PR ready** or **PR opened** -> review the PR; never imply merge authorization | +| `ship-gate` preview / published | **PR ready** -> reply `open PR` or `update PR`; **PR opened** -> review the PR; never imply merge authorization | | `retro` | **Improvement proposed** -> review or authorize the experiment | Normal approval is `approve`. Resolve `approved_by` from (1) an identity supplied with approval, (2) the authenticated GitHub login from `gh api user --jq .login` when available, or (3) one short identity follow-up. Never infer the approver from a filesystem username, commit history, or the coding agent. If identity is missing after approval, preserve the current fingerprint and approval intent, create no receipt, and ask only for identity; once resolved against the unchanged plan, do not require another `approve`. Keep identity and receipt data inside **Technical details**. @@ -217,7 +217,33 @@ Require: ### `SHIP_GATE -> PR_OPEN` -Create a PR with the feature spec, decision links, test evidence, review findings, gaps, rollout, and rollback. Opening a PR does not authorize merge or deployment. +Project the approved feature and actual committed diff into a reviewer-ready title and body: + +- why the change exists; +- what changed, grouped by reviewer concern; +- the shortest useful review order; +- decisions that materially shaped the diff; +- acceptance and check evidence with source references; +- known gaps, risks, rollout, and rollback; +- collapsed approval, evidence, and coding-host provenance. + +Store the exact preview at `.product-loop/features//pr.md`. Its non-rendered frontmatter records the title, base/head branches, managed feature, and context fingerprint; the remaining Markdown is the exact GitHub body. The preview artifact itself is excluded from the product-diff fingerprint so committing it does not create a self-referential hash. + +Before publication, show the exact title and rendered body. Use **PR ready** and exactly one action: `Reply open PR` when no PR exists, or `Reply update PR` when one exists. Only that explicit reply authorizes opening or updating the PR. After confirmation, commit only the reviewed `pr.md`, recheck the same preview fingerprint, committed product diff, plan approval, build lock, test evidence, and review evidence, then perform a normal push and the selected GitHub action. Any drift blocks publication and requires a new preview; never force-push. + +Opening or updating a PR does not authorize merge or deployment. + +## Existing and ad-hoc PRs + +There is no public `/pr-brief` operation. When the user asks in natural language for Boatstack to prepare, improve, summarize, or update an existing PR without a managed feature package: + +1. project the committed branch diff, commits, observed checks, and minimal relevant repository context; +2. store the exact preview at `.product-loop/pr-briefs//pr.md`; +3. use the same reviewer-first format, but mark unavailable approval and gate evidence `NOT_VERIFIED`; +4. never claim that Boatstack approved the work or that an unrun gate passed; +5. preview first, then require `open PR` or `update PR` and recheck the diff before publication. + +Adaptive sections for security/privacy, migrations, UI evidence, or operations appear only when relevant. Model attribution belongs inside collapsed provenance. If GitHub CLI authentication is unavailable, keep the validated preview and provide one manual publication action instead of losing the work. ### `PR_OPEN -> RETRO` diff --git a/boatstack/testdata/reviewer-pr-body.md b/boatstack/testdata/reviewer-pr-body.md new file mode 100644 index 0000000..425988d --- /dev/null +++ b/boatstack/testdata/reviewer-pr-body.md @@ -0,0 +1,46 @@ +## Why this change + +Repository hooks could not reliably find the project toolchain when an IDE pushed without an activated environment, while the privacy detector could attempt an unexpected large model download during tests. + +## What changed + +| Area | Before | After | Reviewer focus | +|---|---|---|---| +| Hook tooling | Depended on the caller's active shell | Resolves the repository's configured environment first | Resolution order and cross-platform fallback | +| Privacy detection | Missing optional models could trigger a runtime download | Fails fast, warns, and uses the installed compact model | Detection remains active without hidden network work | +| Typing | Third-party registry shape was implicit | Registry access has an explicit checked type | No runtime behavior change | + +## Review order + +1. Review the hook's environment resolution and failure behavior. +2. Review the privacy detector's missing-model boundary and warning. +3. Confirm the typing-only change does not alter runtime registration. + +## Evidence + +| Claim | Evidence | Result | Source | +|---|---|---|---| +| Hook runs without an activated environment | Repository hook smoke procedure | `NOT_VERIFIED` | Current branch test notes | +| Privacy detection remains active with the compact model | Targeted detector scenario | `NOT_VERIFIED` | Current branch test notes | +| Static typing remains clean | Project type-check command | `NOT_VERIFIED` | Current branch test notes | + +## Security and privacy + +The fallback keeps privacy detection enabled and removes an unexpected network/download side effect from the test path. + +## Known gaps and risks + +Native Windows environment layout remains unverified. This brief does not claim approval or completed Boatstack gates. + +## Rollout and rollback + +No data migration is required. Roll back the hook resolution and detector fallback commits independently if either environment path regresses. + +
+Boatstack provenance + +- Mode: evidence-limited ad-hoc +- Approval and gate evidence: unavailable +- Coding-host attribution: record here when known + +
diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md index f4b4ffd..0910b91 100644 --- a/docs/evidence-engineered-coding.md +++ b/docs/evidence-engineered-coding.md @@ -90,7 +90,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 **5250 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 **6039 estimated tokens**, while host adapters point to one operation at a time. ## Control appears at transitions @@ -140,6 +140,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 [`46be4fd2d8ebbc00e28c10e78685b721b2c62fe8`](https://github.com/operatorstack/intelligence-flow/tree/46be4fd2d8ebbc00e28c10e78685b721b2c62fe8/examples/12-product-engineering-loop). +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`4fee357eb535287be4b172b2af4c2e44939ce196`](https://github.com/operatorstack/intelligence-flow/tree/4fee357eb535287be4b172b2af4c2e44939ce196/examples/12-product-engineering-loop). The evidence supports specific failure mechanisms and guardrails. It does not establish that Boatstack is optimal, that control-theory notation proves software quality, or that one workflow dominates every team. Those are evaluation questions, so the distribution preserves measurements, provenance, gaps, and negative results. diff --git a/docs/generated-files.md b/docs/generated-files.md index c95bb2d..bd31c1a 100644 --- a/docs/generated-files.md +++ b/docs/generated-files.md @@ -32,9 +32,14 @@ The installation manifest `.product-loop/generated.lock.json` describes generate | `compiled/` | Build-time task graph, test matrix, and evidence skeleton. | | `plan.lock.json` | Content-addressed build activation record. | | `evidence.md` | Commands, results, findings, runtime checks, and gate status. | +| `pr.md` | Exact reviewer-ready title/body preview, bound to the committed product diff and current evidence. | These files travel with the product diff because they explain what was approved and why completion is defensible. Changes to the source plan, spec, or `plan.md` invalidate approval until the plan gate runs again. +For an existing or ad-hoc branch, Boatstack stores the same exact preview under `.product-loop/pr-briefs//pr.md`. It is committed with that branch but does not create approval, lock, or gate provenance. Missing evidence stays visibly `NOT_VERIFIED`. + +The `pr.md` frontmatter is non-rendered publication metadata; the remaining Markdown is the exact GitHub body. Edit the reviewer narrative through Boatstack, preview it, then explicitly reply `open PR` or `update PR`. Any product diff or evidence change makes the preview stale. The preview artifact itself is excluded from the product-diff fingerprint so committing it does not invalidate itself. + ## Fresh clones and updates Committed adapters remain available after cloning. Restore only the ignored helper by rerunning the installer from the repository root. For an update, create a new `chore/update-boatstack` branch, rerun the installer, inspect the generated diff and version provenance, and merge it as a separate infrastructure PR. diff --git a/docs/getting-started.md b/docs/getting-started.md index 05cfa3c..68f1931 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -152,7 +152,17 @@ Run the gates in order: - `test-gate` maps every acceptance criterion to current evidence. - `review-gate` reviews the actual diff and may send the feature back for a local repair. -- `ship-gate` prepares and opens the PR; merge and deploy remain separate decisions. +- `ship-gate` generates a reviewer-ready title and body from the approved intent, actual committed diff, evidence, decisions, and gaps. + +Boatstack shows the exact title and rendered body before changing GitHub. Reply `open PR` when the branch has no PR. Reply `update PR` when one already exists. It then rechecks the diff and evidence before publication. If anything changed after the preview, Boatstack regenerates it instead of publishing stale claims. Merge and deploy remain separate decisions. + +You do not need another slash command for existing work. Ask naturally: + +```text +Use Boatstack to improve this PR. +``` + +Without a managed feature package, Boatstack uses an evidence-limited brief: it summarizes the committed branch and observed checks, while marking unavailable approval or gate evidence `NOT_VERIFIED`. It never pretends the ad-hoc branch passed the full Boatstack workflow. `PASS_WITH_GAPS` is honest success with explicitly owned, non-critical gaps. `BLOCKED` means the claim cannot progress. After fixing a review finding, rerun the affected gates. A check that already fails on the base branch belongs in a separate repair PR or an explicitly authorized repository-policy bypass—not an unrelated edit hidden in the feature branch. diff --git a/docs/research-and-design.md b/docs/research-and-design.md index fd22b3c..d119d83 100644 --- a/docs/research-and-design.md +++ b/docs/research-and-design.md @@ -137,7 +137,7 @@ The node does not adopt a universal “boil the ocean” policy. Completeness is - [Cursor project rules](https://docs.cursor.com/context/rules) live in `.cursor/rules`; project commands live in [`.cursor/commands`](https://docs.cursor.com/en/agent/chat/commands). Cursor is a first-class exported surface. - Claude Code receives a project skill while `CLAUDE.md` stays repository-owned. - Codex receives a repo skill under `.agents/skills`; [OpenAI recommends](https://learn.chatgpt.com/docs/customization/overview) keeping durable `AGENTS.md` guidance small and workflows in reusable skills. -- GitHub receives a PR template that exposes approved-plan hashes, gate status, gaps, evidence, rollout, and rollback. +- GitHub receives a reviewer-first fallback template. The active adapter generates an exact `pr.md` preview from the committed diff and available evidence, then requires a separate open/update confirmation. Managed work may cite current approval and gates; an ad-hoc branch must label missing evidence `NOT_VERIFIED`. The exporter refuses to overwrite any non-generated file. Its lock records canonical version, config hash, adapters, and output hashes so a PR can show exactly what changed. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 949f862..4261f0e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -44,3 +44,11 @@ Boatstack recognizes common package-manager tests, `scripts/check.sh`, Go, Rust, ## A fresh clone has adapters but no helper This is expected: `.product-loop/bin/` is machine-local and ignored. Rerun the installer from the repository root; the generated diff should remain clean when the committed configuration and installed Boatstack version match. + +## The PR preview became stale + +Boatstack binds `pr.md` to the current committed product diff and evidence. A new commit, amended evidence, changed approval artifact, or base-branch change invalidates that preview. Ask Boatstack to regenerate the PR; do not copy the old body forward. + +## GitHub CLI is missing or signed out + +Boatstack keeps the validated `pr.md` instead of discarding the work. Install or authenticate GitHub CLI and rerun the open/update confirmation, or copy the title and rendered body from the preview into GitHub manually. The manual path still does not authorize merge. diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json index 82cb50c..2efa0ab 100644 --- a/examples/diagram-json/plan.lock.json +++ b/examples/diagram-json/plan.lock.json @@ -6,7 +6,7 @@ "plan_path": "examples/diagram-json/plan.md", "plan_sha256": "3ad35cc3cbe48306e7ee401bd9e9047d25e46c8a6fe9679aa1b3f5e96ceea292", "schema_version": 1, - "source_commit": "46be4fd2d8ebbc00e28c10e78685b721b2c62fe8", + "source_commit": "4fee357eb535287be4b172b2af4c2e44939ce196", "source_plan_path": "examples/diagram-json/source-plan.md", "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "examples/diagram-json/spec.md",