diff --git a/.gitignore b/.gitignore index 734c757..8e893ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ -__pycache__/ -*.py[cod] +boatstack/boatstack-helper +boatstack/boatstack-helper.exe +dist/ .DS_Store .venv/ venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b649bba..35bfb61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,8 @@ # Contributing -Boatstack is a generated distribution. Propose changes to workflow semantics, templates, evidence rules, or generated presentation in [Intelligence Flow](https://github.com/operatorstack/intelligence-flow/tree/fcc2faa55ac3c2332ce4a19293d89023f578784d/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/40ebae5dfb3d090812301a438aaac079426edcfc/examples/12-product-engineering-loop). -The Boatstack repository receives those 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`. +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. Repository-specific examples and outcome reports can be proposed upstream as new evidence. A failure becomes a durable move only after its mechanism and non-regression gate are documented. diff --git a/README.md b/README.md index 2815e43..4ff5575 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,29 @@ # Boatstack -**Plan the route. Prove the work. Ship.** +**Build freely. Prove it. Ship.** -Boatstack is loop engineering for coding agents: a model-neutral path from a product request to an explicitly approved, tested, reviewed pull request. Its behavior is generated from [Intelligence Flow at `fcc2faa55ac3c2332ce4a19293d89023f578784d`](https://github.com/operatorstack/intelligence-flow/tree/fcc2faa55ac3c2332ce4a19293d89023f578784d/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 `40ebae5dfb3d090812301a438aaac079426edcfc`](https://github.com/operatorstack/intelligence-flow/tree/40ebae5dfb3d090812301a438aaac079426edcfc/examples/12-product-engineering-loop). -It is not a claim that a longer prompt writes better code. Here is what the loop actually does. +> **You are free in how you build. Only claims of completion require evidence.** -## One request, as executable state +It is not a claim that a longer prompt writes better code. Here is what the node actually makes observable. -Start with ordinary product intent: +## Plan first, then auto-plan + +Start with ordinary product intent **inside Cursor, Codex, or Claude Plan mode**: ```text Add machine-readable JSON output to the diagram printer while preserving the current text output. ``` -`/auto-plan` inspects the smallest relevant code boundary and makes contract choices visible: +Save the host's plan. When the host exposes the active plan path, Boatstack reads it from conversation context; otherwise save it under `.product-loop/intake/`. Then run: + +```text +/auto-plan +``` + +`/auto-plan` must validate a real, non-empty source plan before it reads repository context. Its fallback searches only bounded Plan-mode locations and succeeds for exactly one file. When discovery finds none or several, it returns `BLOCKED` and asks for the intended path; it never guesses from recency or creates the missing plan itself. It then inspects the smallest relevant code boundary and makes contract choices visible: ```text Q1 Public API? sibling serializeFlowGraph() | change printFlowGraph() @@ -28,6 +36,7 @@ The accepted answers become observable criteria and tasks—not hidden assumptio ```json { + "source_plan_path": "source-plan.md", "acceptance_criteria": [ {"id": "AC-1", "text": "Return parseable schema-versioned graph JSON."}, {"id": "AC-4", "text": "Keep existing ASCII output byte-compatible."} @@ -36,21 +45,60 @@ The accepted answers become observable criteria and tasks—not hidden assumptio "id": "T-3", "acceptance_criteria": ["AC-1", "AC-4"], "validation": [ - "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "diff -u expected-output.txt actual-output.txt" + { + "criteria": ["AC-1"], + "run": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "origin": "AC-1 and the approved v1 JSON contract", + "oracle": "parser and schema assertions against the approved contract", + "independence": "contract-derived" + }, + { + "criteria": ["AC-4"], + "run": "diff -u expected-output.txt actual-output.txt", + "origin": "AC-4 and the repository's existing ASCII behavior", + "oracle": "pre-feature golden fixture", + "independence": "pre-existing" + } ] }] } ``` +## Where validation comes from + +Boatstack does not choose a command after seeing the implementation and call that proof. Validation is derived before approval: + +```text +product intent or invariant + -> observable acceptance claim + -> oracle that could falsify the claim + -> executable or human procedure + -> recorded evidence +``` + +`criteria` limits which claims the check can support. The compiler rejects a criterion with no mapped validation and rejects a validation attached to a criterion its task does not serve. The `origin` identifies why the check is required: an acceptance criterion, existing repository invariant, explicit human decision, risk analysis, or external contract. The `oracle` identifies what makes the result meaningful: a pre-existing fixture, approved schema, independent system, measurable threshold, review rubric, or named human judgment. `independence` makes circular evidence visible; an implementation-authored test is useful evidence, but is not automatically an independent oracle. + +Ambiguous claims cannot pass unchanged: + +| Ambiguous claim | Required resolution before approval | +|---|---| +| “It should be fast” | Named workload, environment, metric, and threshold such as p95 under 200 ms | +| “The design should look good” | Approved reference states, review rubric, named reviewer, and captured evidence | +| “The migration should be safe” | Enumerated invariants, rehearsal/rollback procedure, and observable failure conditions | + +`/auto-plan` asks only the questions needed to establish that resolution. If no defensible oracle or authorized human judgment exists, the criterion remains `BLOCKED`; the model cannot validate its own interpretation by restating it. gstack and Spec Kit may help propose criteria and checks, but Boatstack still requires their provenance and evidence contract. + +See [Validation and evidence](docs/validation-and-evidence.md) for validation forms, ambiguity handling, independence levels, gate outcomes, and the benchmark observations behind this contract. + The compiler refuses a criterion with no task or verification. Then `/plan-gate` requires a named human and binds approval to content hashes: ```bash -python3 boatstack/scripts/compile_plan.py \ +.product-loop/bin/boatstack-helper compile-plan \ --plan .product-loop/features/diagram-json/plan.json \ --out-dir .product-loop/features/diagram-json/compiled -python3 boatstack/scripts/approve_plan.py \ +.product-loop/bin/boatstack-helper approve-plan \ + --source-plan .product-loop/features/diagram-json/source-plan.md \ --spec .product-loop/features/diagram-json/spec.md \ --plan .product-loop/features/diagram-json/plan.json \ --tasks .product-loop/features/diagram-json/compiled/tasks.json \ @@ -61,11 +109,11 @@ python3 boatstack/scripts/approve_plan.py \ Build work checks that lock first: ```console -$ python3 boatstack/scripts/approve_plan.py ... --check +$ .product-loop/bin/boatstack-helper approve-plan ... --check PASS: approved plan lock matches the current artifacts # after plan.json changes -$ python3 boatstack/scripts/approve_plan.py ... --check +$ .product-loop/bin/boatstack-helper approve-plan ... --check BLOCKED: stale or invalid plan lock: plan ``` @@ -75,45 +123,97 @@ See the complete, linked [worked example](examples/diagram-json/README.md). ## Bring your own product context -Boatstack does not impose a documentation structure or maintain a second product memory. Keep feature briefs, vision, roadmaps, ADRs, gaps, and engineering rules wherever they already live in the repository. Cursor, Codex, or Claude discovers the relevant surrounding code and documents; Boatstack controls how that context becomes an approved change. +**Bring your context as it is.** Boatstack does not impose a documentation structure or maintain a second product memory. Keep feature briefs, vision, roadmaps, ADRs, gaps, and engineering rules wherever they already live in the repository. Cursor, Codex, or Claude discovers the relevant surrounding code and documents; Boatstack controls how that context becomes an approved change. + +Boatstack treats the repository as canonical and creates only temporary, reviewable, provenance-linked task projections. This matters because a deterministic translation `T` cannot add information about the desired outcome `Y` that was not present in the source context `C`: + +```text +I(Y; T(C)) <= I(Y; C) +``` + +This data-processing bound motivates source preservation; it does **not** prove that every transformation is harmful. A well-chosen projection can improve a finite-context model's effective performance by removing irrelevant material. The rule is therefore: **preserve the source; project only the relevant slice.** Point the host at an existing product document: ```text -/auto-plan Build team notification preferences. +/auto-plan Product brief: docs/features/team-notifications.md Relevant decisions: docs/architecture/notifications.md ``` -Or begin with only the request and let the host inspect the smallest relevant repository slice. Boatstack separates discoverable facts from product questions, then produces the consistent handoff: +If no product document exists, the host Plan-mode file can begin with only the ordinary request. Boatstack then inspects the smallest relevant repository slice, separates discoverable facts from product questions, and produces the consistent handoff: ```text existing product docs + code -> questions -> feature spec -> approval -> engineering plan ``` -Product documents define what and why. ADRs record durable technical decisions. Gaps record known incomplete work. Boatstack references these sources without rewriting them. No context map or documentation migration is required in V1; the project config may list useful starting paths when a repository wants stable defaults. +Product documents define what and why. ADRs record durable technical decisions. Gaps record known incomplete work. Boatstack references these sources without replacing them. Any generated spec or plan must remain traceable to its sources and reviewable as a lossy task projection. No context map or documentation migration is required in V1; the project config may list useful starting paths when a repository wants stable defaults. ## Install into a repository +macOS or Linux: + ```bash -git clone https://github.com/operatorstack/boatstack.git && cd boatstack -cp project.example.json /path/to/product/.boatstack-project.json -# Replace the example paths and commands with facts from the product repository. +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)" +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/operatorstack/boatstack/main/install.ps1 | iex +``` + +Run the command from the product repository. The installer detects the repository and available coding hosts, previews every generated path, verifies the downloaded helper, and asks whether to add gstack, Spec Kit, both, or core only. Boatstack core requires no Python, Node, Go, or package manager. + +After installation, open the chosen host's Plan mode, describe the change, save its plan, and run `/auto-plan`. Boatstack uses the host-exposed active path or discovers exactly one file under `.product-loop/intake/`; an explicit path is only needed to resolve ambiguity. The source plan remains required and hash-checked through `/build`; after build, test, review, and ship gates use the approved lock, actual diff, and evidence instead. + +## Use Boatstack with gstack and GitHub Spec Kit + +Boatstack is primarily a **control and evidence layer** over your coding host and optional planning/review tools. It does not need to reproduce everything those projects already do well: + +```text +product intent + repository context + | + [ BOATSTACK CONTRACT ] + / \ + gstack review lenses Spec Kit artifacts + \ / + normalized spec + plan + decisions + | + approval lock -> open build -> evidence gates -> PR +``` -python3 boatstack/scripts/export_repo.py \ - --repo /path/to/product \ - --config /path/to/product/.boatstack-project.json \ - --adapter-name boatstack +| Layer | What it contributes | What remains Boatstack-owned | +|---|---|---| +| Coding host: Cursor, Codex, or Claude | Plan mode, repository exploration, implementation, tool execution | Cross-host artifact meanings and transition rules | +| [gstack](https://github.com/garrytan/gstack) | Product/CEO, design, engineering, and developer-experience review lenses; adversarial plan critique | Which findings change the approved plan, validation provenance, approval hashing, and gate outcomes | +| [GitHub Spec Kit](https://github.com/github/spec-kit) | Constitution, specify, clarify, plan, tasks, analyze, checklist, and related spec-driven artifacts | Normalization into Boatstack's criterion/validation contract and explicit human plan gate | +| Boatstack core | Source-plan discovery, provenance, question/gap boundaries, deterministic compilation, approval/drift locks, evidence mapping, review/ship gates | The completion and shipping claim itself | -# Review the dry run, then materialize it on a branch. -python3 boatstack/scripts/export_repo.py \ - --repo /path/to/product \ - --config /path/to/product/.boatstack-project.json \ - --adapter-name boatstack \ - --write +### With gstack + +When installed, Boatstack invokes gstack through namespaced `/gstack-*` review skills inside `auto-plan`, review, or retrospective work. gstack can challenge product premises, design states, architecture, failure modes, and developer experience. Its findings are proposals: Boatstack records accepted decisions, maps resulting claims to validation, and re-runs approval when semantics change. gstack never becomes an implicit approval signal. + +### With GitHub Spec Kit + +Spec Kit can generate or cross-check the constitution, specification, clarification answers, implementation plan, tasks, analysis, and checklists. Boatstack sits above those artifacts as the authority/evidence layer: + +```text +speckit.specify / clarify / plan / tasks / analyze / checklist + | + v + Boatstack criteria + oracle + validation normalization + | + explicit /plan-gate ``` -The exporter creates one canonical `.product-loop/` runtime and thin adapters for: +`speckit.implement` does not bypass Boatstack's plan lock or `/build` boundary. If Spec Kit changes accepted semantics, Boatstack invalidates the old lock and returns to approval. This preserves Spec Kit's artifact-generation value without allowing a generator to approve or validate its own output. + +### Core only + +Both integrations are optional. Boatstack core still performs Plan-mode source discovery, question-led specification, structured planning, deterministic approval locking, validation/evidence mapping, test/review gates, and PR preparation. Integration failure is recorded as partial installation and does not roll back the working core. + +The installer creates one canonical `.product-loop/` runtime and thin adapters for: ```text .cursor/commands/{auto-plan,plan-gate,build,test-gate,review,ship,retro}.md @@ -123,24 +223,35 @@ The exporter creates one canonical `.product-loop/` runtime and thin adapters fo .github/PULL_REQUEST_TEMPLATE/boatstack.md ``` -It refuses to overwrite user-owned host files. Run the same export with `--check` in CI to detect drift. +It refuses to overwrite user-owned host files. The small platform helper lives under ignored `.product-loop/bin/`; users continue to operate Boatstack through their coding host. Re-run the installer to restore the helper on a fresh clone. -## Why “loop engineering” +## Freedom inside, evidence at the edges -A coding model is one operator inside a controlled path: +Boatstack is a mathematically modeled composite node inside an Intelligence Flow graph: ```text -intent -> questions -> spec -> plan -> human approval -> build - -> test evidence -> review evidence -> PR -> failure analysis - ^ | - +--- promoted moves ---+ +product intent + repository state + | + v + [ BOATSTACK ] + | + v +diff + evidence + decisions + known gaps ``` -- **Optimization:** select the smallest context and ceremony that preserve the required quality and evidence constraints. -- **Control:** represent state explicitly, gate transitions, verify outputs, preserve known-good progress, and feed observed failures into separately tested improvements. +Inside the node, a model, developer, team, or tool may use any suitable implementation method. At the edges, Boatstack makes authority, acceptance, evidence, and known gaps explicit. + +- A first implementation that passes is a **linear path**. +- Evidence that causes revision creates a **feedback path**. +- Multiple agents or approaches form a **branch/merge path**. + +Boatstack can participate in a loop, but it is not constrained to one. The graph topology follows the work. The invariant is evidence at transitions, not repeated ceremony. + +- **Optimization:** select the smallest context and ceremony that preserve required quality and evidence. +- **Control:** represent state explicitly and verify approval, acceptance, and shipping transitions. - **Model neutrality:** route on ambiguity, risk, convergence, tool results, and evidence—not model brand, price, or a guessed capability tier. -The full mapping from equations to files and checks is in [Loop engineering](docs/loop-engineering.md). +The full mapping from equations to files and checks is in [Evidence-engineered coding](docs/evidence-engineered-coding.md). ## Evidence, with boundaries @@ -158,7 +269,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 **3371 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 **4044 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 967a805..e150ba6 100644 --- a/UPSTREAM.json +++ b/UPSTREAM.json @@ -1,7 +1,7 @@ { "canonical_context": { - "characters": 13481, - "estimated_tokens": 3371, + "characters": 16173, + "estimated_tokens": 4044, "estimator": "ceil(total characters / 4); compactness signal, not provider billing", "files": [ "product-engineering-loop/references/workflow.md", @@ -10,49 +10,57 @@ ] }, "files": { - ".github/workflows/ci.yml": "9480a65a3a4d24b42f7854566ad4a55100b7f2c2b25bffc6bb6b368ba0848104", - ".github/workflows/sync-upstream.yml": "f8c84e316e296ac5928bc0222cef64848ea0fda6846a42f540b0747e8c4eb5f7", - ".gitignore": "94fa252979321511b0ce5fa598f71905f6dc29c5bef6660ff4498a5a39c167ba", - "CONTRIBUTING.md": "125729f68cbf129ce27a72973d7c633ae04cf8159a39ebeaaed98a2d3d13a40c", - "README.md": "8262db68e8217f4c367fbc4b2575e235ee0e57651d4e169265b27fa855bc4c7e", - "boatstack/SKILL.md": "335731973cea2c5d0eb67b9d3870cc332490d1188e3943bb185ab78ab4b4b886", - "boatstack/agents/openai.yaml": "8429c65868025e798d345cc2a9bd78f2bc3280ee982f8f7874504395d6d68368", + ".gitignore": "a7079e923a776f14f1bb3a6aa0a11a133a8e1dfb35af020f327623357b7e3957", + "CONTRIBUTING.md": "cf876c19c52a1758c7347c0a27f63c12ef3e49d67066988df638cd09889b88ca", + "README.md": "0aada7f928bd0f1d2dcfd703fe822759a178e0e6a49779370f0c85f3a9d098f7", + "boatstack/SKILL.md": "1c71535962a56c9f9c7841e55e0e44c2988af427929f95e12cd7fcf08ef26686", + "boatstack/agents/openai.yaml": "68a30a60859556c5a26e16d184594ca243a6043d99c8cf7d66b5dd6d50a93cd1", "boatstack/assets/templates/adr.md": "c577a3c1c1319061f61deb053597e6e853657022185fe28b8f733327e2a78565", "boatstack/assets/templates/evidence.md": "12dac552bc5373ab443367d5797f41988f14284bcf46d16dfd72015cfddf9ad1", "boatstack/assets/templates/feature-spec.md": "c7e007cc4295ed4c599642c0587021ef978e729cf0946f6bf3a6c4f01d366ad4", "boatstack/assets/templates/gaps.md": "911cc2f086104d35071b952950c2ec44258641419f10b2355c594f33eb492cbe", "boatstack/assets/templates/move.md": "91bfd9a9b9426ac023eb88fd19f4f638190481c1855f1239acc73830528e50f0", "boatstack/assets/templates/plan-lock.json": "3e44dea05419cf198ee8112e9b9fdff92287edc2480a03fca026560fe929d468", - "boatstack/assets/templates/plan.json": "803907480dd150da36337f3ecf46e3617f3e26ace9be282e540032983cb77e86", + "boatstack/assets/templates/plan.json": "ff530e27959495aa800a494ebb745cd3703c8bd1c2b6c800c373f5210e89c9af", "boatstack/assets/templates/questions.md": "86c9bcf51172fe222b7b28bffccaf3da3b1ea0633c7a2348272fdbbd8eea6740", "boatstack/assets/templates/test-plan.md": "6db8a9f27dd171fb80222a501cae50eb051e7278c04703fa43b5ff86dd4d2df4", - "boatstack/references/artifacts.md": "caaa7337674bf707a53f0854c7d95e58333ac566f2bc67f0d77533230796221a", + "boatstack/cmd/boatstack-helper/main.go": "c9238e078fd85540073a284772f7360597e68aa0af86b7d3fc0dbc2c73a198ac", + "boatstack/export.go": "e1dc2d79126e98a8202d93a5fc13049b7186e105724daa93814c7133336e810a", + "boatstack/export_test.go": "f880bc99e78bbb3d8533153abaa63eee39c3d067df0eef2451e622083cce5676", + "boatstack/go.mod": "daf262a00abfe961d8ca266d4b26eea09a6aee73e4c53baaa537a809eaef59f6", + "boatstack/init.go": "6a04d5482748bd3b6a9febdf1723b89f8460144bcd5315d5feda0f82de477f07", + "boatstack/init_test.go": "7ca705f014f6bb22f7ea61a1eb370b98d1bc722f77a73711749375fb7a08ea14", + "boatstack/integrations.go": "75b39ce2e662fccd66bf4b9bff0e097a4db558f23b3aa1d9bc83a5fc6373444c", + "boatstack/plan.go": "8787fb1204fc7a81a2c81e39e4469faab637fd180297dce220eab1e48bf7787d", + "boatstack/plan_test.go": "5afab86a9b7f749652cfd31675f5a9bd1ec3e03086300923dd7d47765cd80963", + "boatstack/references/artifacts.md": "3aa4b2abc4195656b011cbfdd61b8e05759a0b67b37590be3a028c21b8a453e6", "boatstack/references/failure-moves.md": "2d7d3988c70718e9cc02104f9899a00208173e2f654d1046edd22079f4d46f41", - "boatstack/references/portability.md": "5490a045526c4cd6fd52bcddeb0039119208478fb17656cd2f6b3d5f71698ce6", - "boatstack/references/workflow.md": "2c2343b3ef3dd7684dc6a027da4c8e5b1ce927cf3979aca32abea0d0d2028ee7", - "boatstack/scripts/approve_plan.py": "92cb14cf0703bd25d053f8939575ab651a0274418d9ec85f3827f4c23c30001c", - "boatstack/scripts/compile_plan.py": "523befa52993f5606a5ed7a91678459254ba6cfa074e9aafa9f4010876332968", - "boatstack/scripts/export_repo.py": "42444369b2b4d8430b5347761626ac7725aae25aa4726b5bdca5325a4fc80ad0", + "boatstack/references/portability.md": "fb683095991bb0cb06ec56fb8884c49038b283172a7d2f8b203483b7cacb4bae", + "boatstack/references/workflow.md": "b8262570751ea73cf40cf9de76fc2e6acf1303657d1956b6957ca9af7034fbb0", + "boatstack/runtime.go": "66c02aa0b6e9c031c26799b86dfbcb26df74487cc26b32f7fb85e38707eeec31", "docs/benchmark-corpus-audit.md": "f2d206fe8579a514f9da82b2c96c19b343ac004be67617e1bd34f0f8e0e5e6c6", "docs/benchmark-submission-audit.md": "9518abdd17690729c6423f87cab20418ed47b0915b5faa44b9ef975e9e9c3b79", - "docs/loop-engineering.md": "63b114e57c129379757a57938e33abb2da21eed3fef19c1731dba22ea58b5baf", - "docs/research-and-design.md": "543836387090f8dc01381b1e46d1c6760bcbf8119b4004ece8d6f6f68d08db4f", - "examples/diagram-json/README.md": "51871b16438cbef2bbdf5077dda0b5b06e77cbe76882d34e4a05c17c8f13a2b3", + "docs/evidence-engineered-coding.md": "2c2b9cb428d3e75b8463d17099ec254afd204367cdc6f8a2214c9def97d8c2a9", + "docs/research-and-design.md": "fc9c517f2783489cbbc4de5ad020a4e4e5bb542c7092b13b317a1893a3415cb4", + "docs/validation-and-evidence.md": "3b5ed588bd44c5568f0c313be0dfaa411e959dc184fe886dfd0a81aee9fd25cc", + "examples/diagram-json/README.md": "fbb4721434e6110bbef84813c244132bbe7ae0e9e0cd535b6871e3359bcd4559", "examples/diagram-json/compiled/evidence.md": "1ba1c989ade070a8ef9a508fbd788d100d7292f2dbacbb2bce895468019f619d", - "examples/diagram-json/compiled/tasks.json": "d66d693df1ba7dd34f65ce93afea54006563c14d642a1bf0d1d9311b3fcfb37b", - "examples/diagram-json/compiled/test-matrix.json": "0497cf73f84515cfc493e4904eda4c2be6c0621fc0a11a3b1349803b5acf91cb", - "examples/diagram-json/plan.json": "d1208003042a9d10f5efb010fc32fc7ac7bdefa427938260586e90daa0cb4414", - "examples/diagram-json/plan.lock.json": "3e0bb70807c5a06dd7ac7a87c691a19abfc2e27131754830fe1d32b365a20bba", + "examples/diagram-json/compiled/tasks.json": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896", + "examples/diagram-json/compiled/test-matrix.json": "6c6895c509271e4337f3c91d9f62ee3a2b34e768e78513784cb012506a328ecf", + "examples/diagram-json/plan.json": "df1b205517cf7dbdf5c5db65a342622922bd959a5ba885326888f6dd2b9c50d3", + "examples/diagram-json/plan.lock.json": "c904d4873bf678198dc2d7aba4e90e278385d0266e8bf8bc54334d3f764f1a1a", "examples/diagram-json/questions.md": "1a0050041cac0a8d53e6ebfe04cbec4a298cdc8c50efeeb6fa15aeb663c5ec76", "examples/diagram-json/request.md": "0808fc41c36779c404f4a3a121167da6e76cac56df526e70f9ed6d3e0d4c02ed", + "examples/diagram-json/source-plan.md": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "examples/diagram-json/spec.md": "a943c81cf2a88d23d5b300e6b9dc1dafc80923a9b6b9ab5297a67b4e2054b9d5", - "project.example.json": "2054228f4c824d43385b7732e9f38f17739900d3cec6567bc23c5fe6c890d1be", - "tests/test_boatstack.py": "9d61e552a196b9c9fba8bd175b7c3ae1fcfead1f61eb6396df065477086369e1" + "install.ps1": "c81f2f8eb6032ea82c36ebe98bd015ba4dabb8f4825b3c1bb89baea2a808690c", + "install.sh": "c9caf1eb0554715d189e4183478dfb3ae7e5c0a8187ea44229cbf268276652f8", + "project.example.json": "d1f7aa3cff0b55ede79500bd2ca710bb99cb2ae579f0a058dc00934accf03d33" }, "generator": "operatorstack/intelligence-flow:boatstack-distribution", "schema_version": 1, "source": { - "commit": "fcc2faa55ac3c2332ce4a19293d89023f578784d", + "commit": "40ebae5dfb3d090812301a438aaac079426edcfc", "path": "examples/12-product-engineering-loop", "repository": "operatorstack/intelligence-flow" } diff --git a/boatstack/SKILL.md b/boatstack/SKILL.md index 12b8d23..4eb4fc8 100644 --- a/boatstack/SKILL.md +++ b/boatstack/SKILL.md @@ -5,14 +5,14 @@ description: Turn a product request into a question-led, specification-first imp # Boatstack -Build the smallest complete product slice that can be independently verified. Keep the workflow model-neutral: project facts and gate evidence are canonical; host-specific prompts are adapters. +Build the smallest complete product slice that can be independently verified. Implementation methods remain open: project facts, approval, and gate evidence are canonical; host-specific prompts are adapters. You are free in how you build. Only claims of completion require evidence. ## Start by selecting the operation Map the request to one operation: - `init`: inspect a repository and create or update `.product-loop/project.json`. -- `auto-plan`: turn product intent into a reviewable draft feature package. +- `auto-plan`: refine a saved host Plan-mode file into a reviewable draft feature package; refuse when that file is absent. - `plan-gate`: present the draft for explicit human acceptance, then freeze its approved contents and generate the executable package. - `build`: implement approved tasks in bounded, reversible slices. - `test-gate`: test requirements and relevant regressions using independent evidence. @@ -35,7 +35,7 @@ For ordinary feature work, define one bounded outcome: Because this workflow is also a reusable product, maintain delivery and improvement as separate paths: -- **Delivery path:** intent -> questions -> spec -> plan -> code -> gates -> PR. +- **Delivery path:** intent -> host Plan mode -> saved source plan -> questions -> spec -> approved plan -> code -> gates -> PR. - **Improvement path:** traces -> failure classification -> proposed move -> paired evaluation -> promote/reject. Never mix benchmark observations or speculative harness changes into the delivery path during an active feature. The improvement path may propose an experiment; only a passed promotion gate changes the canonical loop. @@ -53,37 +53,43 @@ Do not scan the entire repository by default. Record discovered paths and comman ## Run `auto-plan` -1. Write the bounded outcome definition before proposing architecture. -2. Separate facts, decisions, unknowns, and safely deferrable gaps. -3. Answer discoverable code questions by inspection. -4. Ask the developer only questions whose answers materially change behavior, contracts, risk, or acceptance. Ask 1-3 concise questions at a time, give 2-3 mutually exclusive choices, recommend one, and explain the impact. -5. Record answers and provenance in the question ledger. -6. Create the feature spec: problem, users, outcomes, non-goals, acceptance criteria, invariants, interfaces, failure behavior, observability, rollout, and rollback. -7. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it. -8. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative. -9. End with a **draft**, never an implied approval. Do not generate executable task state or start implementation from `auto-plan` alone. +0. Require exactly one saved plan file created in the active host's Plan mode. First use the active plan path exposed in host/system conversation context, when available, and validate it with `.product-loop/bin/boatstack-helper check-source-plan --repo . --plan `. Otherwise run `check-source-plan --repo .` to search only `.product-loop/intake/` and bounded repo-local host plan directories. If the result is missing or ambiguous, return `BLOCKED`; never choose by recency alone. An explicit `/auto-plan ` is only the ambiguity fallback. Do not write the missing source plan inside `auto-plan`. +1. Treat the supplied plan as an initial proposal, not approved truth. Record its path as `source_plan_path` in the structured plan. +2. Write the bounded outcome definition before proposing architecture. +3. Separate facts, decisions, unknowns, and safely deferrable gaps. +4. Answer discoverable code questions by inspection. +5. Ask the developer only questions whose answers materially change behavior, contracts, risk, or acceptance. Ask 1-3 concise questions at a time, give 2-3 mutually exclusive choices, recommend one, and explain the impact. +6. Record answers and provenance in the question ledger. +7. Create the feature spec: problem, users, outcomes, non-goals, acceptance criteria, invariants, interfaces, failure behavior, observability, rollout, and rollback. Translate every accepted claim into an observable condition with a defensible oracle. +8. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it. +9. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative. +10. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`. +11. End with a **draft**, never an implied approval. Do not generate executable task state or start implementation from `auto-plan` alone. Do not treat an ADR as general project context. ADRs record accepted durable decisions. Use a question ledger for unknowns and a gap ledger for known divergence. +Treat repository-owned product context as canonical. Do not require it to be migrated or rewritten into a Boatstack memory. Specs, plans, summaries, and selected context are temporary task projections: keep them reviewable, link material claims back to their source paths, and never silently replace the source. Preserve the source; project only the relevant slice. + ## Run `plan-gate` -1. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, and proposed verification in a reviewable form. +1. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, and proposed verification—including the origin, oracle, and independence of every validation—in a reviewable form. 2. Ask the developer to approve it or request changes. Silence and continued conversation are not approval. 3. On changes, return to `auto-plan`, preserve the feedback in the question/decision ledger, and issue a new draft. 4. On explicit approval, deterministically compile the already-approved structured plan into the task graph, requirement-test traceability rows, evidence skeleton, and expected gate commands. Do not add semantics during compilation. -5. Calculate content hashes and write `plan.lock.json` with the approver, timestamp, source commit, spec hash, plan hash, and task-graph hash. -6. If the spec or plan changes later, invalidate the lock and return to this gate. +5. Calculate content hashes and write `plan.lock.json` with the approver, timestamp, source commit, source-plan hash, spec hash, structured-plan hash, and task-graph hash. +6. If the source plan, spec, or structured plan changes later, invalidate the lock and return to this gate. -`build` must refuse to run when the plan lock is absent, stale, or does not match the approved artifacts. +`build` must refuse to run when the source Plan-mode file is absent or when the plan lock is absent, stale, or does not match the source plan and approved artifacts. The reference implementation performs the post-approval materialization and lock in this order: ```bash -python3 .product-loop/tools/compile_plan.py \ +.product-loop/bin/boatstack-helper compile-plan \ --plan .product-loop/features//plan.json \ --out-dir .product-loop/features//compiled -python3 .product-loop/tools/approve_plan.py \ +.product-loop/bin/boatstack-helper approve-plan \ + --source-plan \ --spec .product-loop/features//spec.md \ --plan .product-loop/features//plan.json \ --tasks .product-loop/features//compiled/tasks.json \ @@ -95,6 +101,8 @@ The first command validates and compiles already-approved semantics; it must not ## Build without erasing evidence +- Before the first edit, pass the source plan along with the approved artifacts to `approve-plan --check`. It remains a required, hash-checked input through completion of `build`. +- Choose any suitable model, tool, or implementation tactic inside the approved boundary. Boatstack controls transitions and claims, not local creativity. - Work from approved tasks and acceptance criteria. - Preserve the last known-good state; repair locally instead of restarting a near-correct implementation. - Re-scope context at task boundaries. Include relevant source, interfaces, invariants, and tests—not arbitrary history. @@ -108,6 +116,7 @@ Do not branch the workflow on model brand, price, or a guessed capability tier. ### Test gate +- After build completes, the source Plan-mode file is no longer a runtime prerequisite. Test, review, and ship use the approved lock, actual diff, and accumulated evidence; provenance remains recorded in the lock. - Derive tests from acceptance criteria and affected contracts, not only from the implementation. - Run existing relevant tests plus targeted new tests, linters, type checks, builds, and runtime checks. - Treat model-authored tests and same-model self-review as evidence, not ground truth. @@ -147,7 +156,7 @@ More steps, more context, stronger wording, more tests, or more retries are not Read [portability.md](references/portability.md), then use: ```bash -python3 boatstack/scripts/export_repo.py --adapter-name boatstack --repo /path/to/repo --config /path/to/project.json --write +.product-loop/bin/boatstack-helper export --repo /path/to/repo --config /path/to/project.json --write ``` Run with `--check` in CI to detect drift. The exporter writes generated files only and refuses to overwrite user-owned files. Review the generated diff in a branch and ship it through a PR. diff --git a/boatstack/agents/openai.yaml b/boatstack/agents/openai.yaml index c14214d..4ff6ffb 100644 --- a/boatstack/agents/openai.yaml +++ b/boatstack/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Boatstack" - short_description: "Plan, build, verify, review, and ship with evidence." - default_prompt: "Use $boatstack to turn this product request into a question-led, evidence-gated implementation loop." + short_description: "Build freely; approve, verify, review, and ship with evidence." + default_prompt: "Use $boatstack as an evidence-engineered coding node: keep implementation tactics open and require evidence for approval, completion, review, and shipping." diff --git a/boatstack/assets/templates/plan.json b/boatstack/assets/templates/plan.json index 652463d..3396b3b 100644 --- a/boatstack/assets/templates/plan.json +++ b/boatstack/assets/templates/plan.json @@ -1,6 +1,7 @@ { "schema_version": 1, "feature_id": "", + "source_plan_path": "", "spec_path": "", "acceptance_criteria": [ { @@ -14,7 +15,15 @@ "title": "", "depends_on": [], "acceptance_criteria": ["AC-1"], - "validation": [""], + "validation": [ + { + "criteria": ["AC-1"], + "run": "", + "origin": "", + "oracle": "", + "independence": "" + } + ], "rollback_boundary": "" } ] diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go new file mode 100644 index 0000000..1c89599 --- /dev/null +++ b/boatstack/cmd/boatstack-helper/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "flag" + "fmt" + "os" + "sort" + "strings" + + boatstack "github.com/operatorstack/boatstack/boatstack" +) + +func fail(err error) int { + fmt.Fprintln(os.Stderr, "BLOCKED:", err) + return 1 +} + +func initCommand(arguments []string) int { + flags := flag.NewFlagSet("init", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository to initialize") + binary := flags.String("binary", "", "verified helper binary to install project-locally") + integrations := flags.String("integrations", "", "core, gstack, spec-kit, or both") + yes := flags.Bool("yes", false, "accept the generated-file preview; optional integrations still default to core") + if err := flags.Parse(arguments); err != nil { + return 2 + } + err := boatstack.RunInit(boatstack.InitOptions{Repo: *repo, BinaryPath: *binary, IntegrationChoice: *integrations, Yes: *yes}) + if err != nil { + return fail(err) + } + return 0 +} + +func exportCommand(arguments []string) int { + flags := flag.NewFlagSet("export", flag.ContinueOnError) + repo := flags.String("repo", "", "repository to export into") + configPath := flags.String("config", "", "Boatstack project config") + adapterName := flags.String("adapter-name", "boatstack", "generated adapter slug") + adapters := flags.String("adapters", "", "comma-separated adapter override") + write := flags.Bool("write", false, "write generated files") + check := flags.Bool("check", false, "check generated files for drift") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *repo == "" || *configPath == "" || (*write && *check) { + return fail(fmt.Errorf("export requires --repo and --config; --write and --check are mutually exclusive")) + } + config, raw, err := boatstack.LoadConfig(*configPath) + if err != nil { + return fail(err) + } + if *adapters != "" { + config.Adapters = strings.Split(*adapters, ",") + } + bundle, err := boatstack.BuildExportBundle(*configPath, config, raw, *adapterName) + if err != nil { + return fail(err) + } + if *check { + if err := boatstack.CheckExport(*repo, bundle.Files); err != nil { + return fail(err) + } + fmt.Printf("PASS: %d generated files match Boatstack %s\n", len(bundle.Files), boatstack.Version) + return 0 + } + if *write { + if err := boatstack.WriteExport(*repo, bundle.Files); err != nil { + return fail(err) + } + fmt.Printf("PASS: wrote %d generated files to %s\n", len(bundle.Files), *repo) + return 0 + } + fmt.Printf("dry run: would generate %d files in %s\n", len(bundle.Files), *repo) + for _, path := range func() []string { + paths := make([]string, 0, len(bundle.Files)) + for path := range bundle.Files { + paths = append(paths, path) + } + sort.Strings(paths) + return paths + }() { + fmt.Println(" " + path) + } + return 0 +} + +func compileCommand(arguments []string) int { + flags := flag.NewFlagSet("compile-plan", flag.ContinueOnError) + plan := flags.String("plan", "", "approved structured plan") + outDir := flags.String("out-dir", "", "compiled artifact directory") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *plan == "" || *outDir == "" { + return fail(fmt.Errorf("compile-plan requires --plan and --out-dir")) + } + if err := boatstack.CompilePlanFiles(*plan, *outDir); err != nil { + return fail(fmt.Errorf("invalid approved plan: %w", err)) + } + fmt.Printf("PASS: compiled approved plan into %s\n", *outDir) + return 0 +} + +func checkSourcePlanCommand(arguments []string) int { + flags := flag.NewFlagSet("check-source-plan", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository whose bounded plan locations should be searched") + plan := flags.String("plan", "", "optional explicit plan file created by the host Plan mode") + if err := flags.Parse(arguments); err != nil { + return 2 + } + discovered, err := boatstack.DiscoverSourcePlan(*repo, *plan) + if err != nil { + return fail(err) + } + fmt.Printf("PASS: source plan is present\nSOURCE_PLAN=%s\n", discovered) + return 0 +} + +func approveCommand(arguments []string) int { + flags := flag.NewFlagSet("approve-plan", flag.ContinueOnError) + options := boatstack.ApprovalOptions{} + flags.StringVar(&options.SourcePlanPath, "source-plan", "", "plan file created by the host Plan mode") + flags.StringVar(&options.SpecPath, "spec", "", "approved spec") + flags.StringVar(&options.PlanPath, "plan", "", "approved structured plan") + flags.StringVar(&options.TasksPath, "tasks", "", "compiled task graph") + flags.StringVar(&options.ApprovedBy, "approved-by", "", "human approver") + flags.StringVar(&options.ApprovedAt, "approved-at", "", "approval timestamp") + flags.StringVar(&options.SourceCommit, "source-commit", "", "source Git commit") + flags.StringVar(&options.OutputPath, "output", "", "plan lock path") + check := flags.Bool("check", false, "verify an existing plan lock") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if options.SourcePlanPath == "" || options.SpecPath == "" || options.PlanPath == "" || options.TasksPath == "" || options.OutputPath == "" { + return fail(fmt.Errorf("approve-plan requires --source-plan, --spec, --plan, --tasks, and --output")) + } + if *check { + if err := boatstack.CheckApprovalLock(options); err != nil { + return fail(err) + } + fmt.Println("PASS: approved plan lock matches the current artifacts") + return 0 + } + if err := boatstack.CreateApprovalLock(options); err != nil { + return fail(err) + } + fmt.Printf("PASS: wrote approved plan lock: %s\n", options.OutputPath) + return 0 +} + +func run() int { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + return 2 + } + switch os.Args[1] { + case "init": + return initCommand(os.Args[2:]) + case "export": + return exportCommand(os.Args[2:]) + case "check-source-plan": + return checkSourcePlanCommand(os.Args[2:]) + case "compile-plan": + return compileCommand(os.Args[2:]) + case "approve-plan": + return approveCommand(os.Args[2:]) + case "version": + fmt.Printf("Boatstack %s (%s)\n", boatstack.Version, boatstack.SourceCommit) + return 0 + default: + fmt.Fprintln(os.Stderr, "unknown command:", os.Args[1]) + return 2 + } +} + +func main() { os.Exit(run()) } diff --git a/boatstack/export.go b/boatstack/export.go new file mode 100644 index 0000000..c4f9c79 --- /dev/null +++ b/boatstack/export.go @@ -0,0 +1,382 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +var adapterNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +var allowedAdapters = map[string]bool{ + "cursor": true, + "claude": true, + "codex": true, + "github": true, +} + +type ExportBundle struct { + Files map[string][]byte + Config ProjectConfig +} + +func LoadConfig(path string) (ProjectConfig, []byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return ProjectConfig{}, nil, err + } + var config ProjectConfig + if err := json.Unmarshal(raw, &config); err != nil { + return ProjectConfig{}, nil, err + } + if err := ValidateConfig(config); err != nil { + return ProjectConfig{}, nil, err + } + return config, raw, nil +} + +func ValidateConfig(config ProjectConfig) error { + if config.SchemaVersion != 1 { + return fmt.Errorf("project config schema_version must be 1") + } + if strings.TrimSpace(config.Project.Name) == "" { + return fmt.Errorf("project.name is required") + } + if strings.TrimSpace(config.Project.Commands["test"]) == "" { + return fmt.Errorf("project.commands.test is required; Boatstack will not invent it") + } + for _, adapter := range normalizedAdapters(config.Adapters) { + if !allowedAdapters[adapter] { + return fmt.Errorf("unsupported adapter: %s", adapter) + } + } + return nil +} + +func normalizedAdapters(adapters []string) []string { + if len(adapters) == 0 { + return []string{"claude", "codex", "cursor", "github"} + } + seen := map[string]bool{} + for _, adapter := range adapters { + adapter = strings.TrimSpace(adapter) + if adapter != "" { + seen[adapter] = true + } + } + result := make([]string, 0, len(seen)) + for adapter := range seen { + result = append(result, adapter) + } + sort.Strings(result) + return result +} + +func commandBody(operation, extra string) string { + preflight := "" + if operation == "auto-plan" { + preflight = `Before reading repository context or drafting artifacts, inspect the active host/system conversation for its Plan-mode file path. If present, run the project-local helper with ` + "`check-source-plan --repo . --plan `" + `. Otherwise run ` + "`check-source-plan --repo .`" + `. Use its ` + "`SOURCE_PLAN`" + ` result. Fallback discovery searches only bounded Plan-mode locations and succeeds only for exactly one non-empty file. If discovery blocks, stop and show the candidates or ask the user to save the host plan under ` + "`.product-loop/intake/`" + `. Accept ` + "`/auto-plan `" + ` only as an ambiguity override. Do not create the missing source plan inside auto-plan.` + } + return fmt.Sprintf(`# %s + +Run the %s operation from @.product-loop/workflow.md. + +%s + +Read @.product-loop/project.json, @.product-loop/artifacts.md, and only the minimal repository context relevant to the current feature. %s + +Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Boatstack leaves implementation tactics open, but completion, approval, and shipping claims require current evidence. +`, operation, operation, preflight, extra) +} + +func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte, adapterName string) (ExportBundle, error) { + if !adapterNamePattern.MatchString(adapterName) { + return ExportBundle{}, fmt.Errorf("adapter name must be a lowercase kebab-case slug") + } + if err := ValidateConfig(config); err != nil { + return ExportBundle{}, err + } + adapters := normalizedAdapters(config.Adapters) + config.Adapters = adapters + files := map[string][]byte{} + + projectJSON, err := GeneratedJSON(config) + if err != nil { + return ExportBundle{}, err + } + files[".product-loop/project.json"] = projectJSON + files[".product-loop/.gitignore"] = []byte("bin/\n") + files[".product-loop/intake/.gitkeep"] = []byte{} + + for _, name := range []string{"workflow.md", "artifacts.md", "failure-moves.md"} { + value, err := ReadCanonical("references/" + name) + if err != nil { + return ExportBundle{}, err + } + files[".product-loop/"+name] = GeneratedMarkdown(string(value)) + } + + entries, err := ReadCanonicalDir("assets/templates") + if err != nil { + return ExportBundle{}, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + value, err := ReadCanonical("assets/templates/" + entry.Name()) + if err != nil { + return ExportBundle{}, err + } + path := ".product-loop/templates/" + entry.Name() + if strings.HasSuffix(entry.Name(), ".json") { + var decoded any + if err := json.Unmarshal(value, &decoded); err != nil { + return ExportBundle{}, err + } + files[path], err = GeneratedJSON(decoded) + if err != nil { + return ExportBundle{}, err + } + } else { + files[path] = GeneratedMarkdown(string(value)) + } + } + + operations := map[string]string{ + "auto-plan": "Discover exactly one saved Plan-mode file, refine it into a draft feature package, and record its path as source_plan_path. Do not implement and do not imply the user accepted it.", + "plan-gate": "Require the source Plan-mode file and explicit human approval. Only then run the project-local Boatstack helper to compile the executable task/evidence package and approval lock.", + "build": "Before editing, locate the source Plan-mode file, feature spec, structured plan, compiled tasks, and plan lock; pass all of them to the project-local Boatstack helper when checking the lock. Stop if it reports BLOCKED. Implementation tactics remain open inside the approved boundary.", + "test-gate": "Build a requirement-to-evidence matrix and treat self-authored tests as evidence rather than the sole oracle.", + "review-gate": "Review the actual diff against approved intent, invariants, risks, gaps, and test evidence.", + "ship-gate": "Prepare a PR only; do not merge or deploy without separate authorization.", + "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence.", + "ship": "Alias of ship-gate: prepare a PR only; do not merge or deploy without separate authorization.", + "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate.", + } + + if contains(adapters, "cursor") { + rule := `--- +description: Use Boatstack for evidence-engineered planning, explicit approval, open implementation, evidence gates, and PR preparation. +globs: +alwaysApply: false +--- + +The source of truth is @.product-loop/workflow.md and @.product-loop/project.json. +Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for improvement experiments. +Ordinary product intent starts in the host's Plan mode. Save the completed plan under .product-loop/intake/. Auto-plan discovers exactly one saved plan from bounded host locations, validates it, and must not invent a substitute. Keep the source plan present and current through build. +Do not start build work until the explicit plan gate has produced a valid plan lock. +Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. +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) + if err != nil { + return ExportBundle{}, err + } + for operation, extra := range operations { + files[fmt.Sprintf(".cursor/commands/%s.md", operation)] = GeneratedMarkdown(commandBody(operation, extra)) + } + } + + adapterSkill := fmt.Sprintf(`--- +name: %s +description: Run Boatstack's evidence-engineered coding node for question-led planning, explicit approval, open implementation, evidence gates, and PR preparation. +--- + +# Boatstack adapter + +Read .product-loop/project.json and .product-loop/workflow.md. The requested operation is supplied by the user; valid operations are auto-plan, plan-gate, build, test-gate, review-gate/review, ship-gate/ship, and retro. + +Ordinary product intent must first be explored in the host's Plan mode and saved as a file, preferably under .product-loop/intake/. Auto-plan runs bounded discovery before inspecting the repository and records the single result as source_plan_path. If no file exists or multiple candidates remain, auto-plan is BLOCKED; it must not guess or create a substitute. An explicit path is only an ambiguity override. The source plan remains required and hash-current through plan-gate and build. Test, review, and ship gates operate from the approved lock, diff, and evidence after build. + +Use .product-loop/artifacts.md for document boundaries and .product-loop/failure-moves.md for improvement experiments. Do not implement from an unapproved or stale plan. Implementation tactics are open; completion, approval, and shipping claims require current evidence. Do not branch on model identity; use observable state and gate evidence. + +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") { + files[fmt.Sprintf(".claude/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(adapterSkill) + if err != nil { + return ExportBundle{}, err + } + } + if contains(adapters, "codex") { + files[fmt.Sprintf(".agents/skills/%s/SKILL.md", adapterName)], err = GeneratedFrontmatter(adapterSkill) + if err != nil { + return ExportBundle{}, err + } + } + if contains(adapters, "github") { + files[fmt.Sprintf(".github/PULL_REQUEST_TEMPLATE/%s.md", adapterName)] = GeneratedMarkdown(`# Evidence-engineered change + +## Approved intent + +- Feature spec: +- Approved plan hash: +- Human approver: +- Linked ADRs/questions: + +## Outcome + +- User-visible change: +- Non-goals preserved: + +## Gate evidence + +- Test gate: BLOCKED +- Review gate: BLOCKED +- Ship gate: BLOCKED +- Evidence ledger: + +## Known gaps + +- Gap ledger: +- PASS_WITH_GAPS rationale, owner, and revisit trigger: + +## Rollout and rollback + +- Rollout: +- Observability: +- Rollback: + +## Generated adapter update + +- Boatstack version: +- Config hash: +- Export check: +`) + } + + hashes := map[string]string{} + for path, value := range files { + hashes[path] = SHA256Bytes(value) + } + lock := map[string]any{ + "schema_version": 1, + "generator": Generator, + "boatstack_version": Version, + "config_source": filepath.Base(configPath), + "config_sha256": SHA256Bytes(rawConfig), + "adapters": adapters, + "integrations": config.Integrations, + "runtime": map[string]any{ + "source_commit": SourceCommit, + "checksums_sha256": ChecksumsSHA256, + }, + "files": hashes, + } + files[".product-loop/generated.lock.json"], err = GeneratedJSON(lock) + if err != nil { + return ExportBundle{}, err + } + return ExportBundle{Files: files, Config: config}, nil +} + +func contains(values []string, expected string) bool { + for _, value := range values { + if value == expected { + return true + } + } + return false +} + +func owned(value []byte, path string) bool { + text := string(value) + if strings.Contains(text, Marker) || strings.Contains(text, "Generated by product-engineering-loop exporter.") { + return true + } + if strings.HasSuffix(path, ".json") { + decoded := map[string]any{} + if json.Unmarshal(value, &decoded) == nil { + generator := decoded["_generated_by"] + return generator == Generator || generator == "product-engineering-loop-exporter" + } + } + return false +} + +func previousFiles(repo string) map[string]string { + value, err := os.ReadFile(filepath.Join(repo, ".product-loop/generated.lock.json")) + if err != nil { + return map[string]string{} + } + var lock struct { + Files map[string]string `json:"files"` + } + if json.Unmarshal(value, &lock) != nil || lock.Files == nil { + return map[string]string{} + } + return lock.Files +} + +func ExportCollisions(repo string, files map[string][]byte) []string { + problems := []string{} + for _, relative := range sortedKeys(files) { + target := filepath.Join(repo, filepath.FromSlash(relative)) + current, err := os.ReadFile(target) + if os.IsNotExist(err) || (err == nil && string(current) == string(files[relative])) { + continue + } + if err != nil || !owned(current, relative) { + problems = append(problems, relative) + } + } + previous := previousFiles(repo) + for relative, expectedHash := range previous { + if _, stillGenerated := files[relative]; stillGenerated { + continue + } + current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) + if err == nil && SHA256Bytes(current) != expectedHash { + problems = append(problems, "stale generated path modified downstream: "+relative) + } + } + sort.Strings(problems) + return problems +} + +func WriteExport(repo string, files map[string][]byte) error { + if problems := ExportCollisions(repo, files); len(problems) > 0 { + return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) + } + for relative, expectedHash := range previousFiles(repo) { + if _, stillGenerated := files[relative]; stillGenerated { + continue + } + target := filepath.Join(repo, filepath.FromSlash(relative)) + current, err := os.ReadFile(target) + if err == nil && SHA256Bytes(current) == expectedHash { + if err := os.Remove(target); err != nil { + return err + } + } + } + for _, relative := range sortedKeys(files) { + if err := writeFile(filepath.Join(repo, filepath.FromSlash(relative)), files[relative], 0o644); err != nil { + return err + } + } + return nil +} + +func CheckExport(repo string, files map[string][]byte) error { + problems := []string{} + for _, relative := range sortedKeys(files) { + current, err := os.ReadFile(filepath.Join(repo, filepath.FromSlash(relative))) + if os.IsNotExist(err) { + problems = append(problems, "missing "+relative) + } else if err != nil { + problems = append(problems, fmt.Sprintf("unreadable %s: %v", relative, err)) + } else if string(current) != string(files[relative]) { + problems = append(problems, "drift "+relative) + } + } + if len(problems) > 0 { + return fmt.Errorf("generated output is stale: %s", strings.Join(problems, ", ")) + } + return nil +} diff --git a/boatstack/export_test.go b/boatstack/export_test.go new file mode 100644 index 0000000..36cbba4 --- /dev/null +++ b/boatstack/export_test.go @@ -0,0 +1,123 @@ +package boatstack + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func testConfig() ProjectConfig { + return ProjectConfig{ + SchemaVersion: 1, + Project: Project{ + Name: "fixture", Commands: map[string]string{"test": "go test ./..."}, + }, + Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, + Adapters: []string{"cursor", "claude", "codex", "github"}, + Integrations: map[string]IntegrationState{ + "gstack": {Requested: false, Version: GStackRef}, + "spec-kit": {Requested: false, Version: SpecKitVersion}, + }, + } +} + +func TestExportAndDriftCheck(t *testing.T) { + repo := t.TempDir() + config := testConfig() + raw, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + bundle, err := BuildExportBundle(".boatstack-project.json", config, raw, "boatstack") + if err != nil { + t.Fatal(err) + } + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + if err := CheckExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + for _, path := range []string{ + ".cursor/commands/plan-gate.md", + ".cursor/commands/review.md", + ".claude/skills/boatstack/SKILL.md", + ".agents/skills/boatstack/SKILL.md", + ".product-loop/.gitignore", + } { + if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) { + t.Fatalf("expected generated file %s", path) + } + } + if _, exists := bundle.Files[".product-loop/tools/approve_plan.py"]; exists { + t.Fatal("public export must not contain Python runtime tools") + } + lock := string(bundle.Files[".product-loop/generated.lock.json"]) + if !strings.Contains(lock, `"source_commit"`) || !strings.Contains(lock, `"integrations"`) { + t.Fatal("generated lock must record runtime provenance and integrations") + } +} + +func TestExportRefusesUserOwnedCollision(t *testing.T) { + repo := t.TempDir() + path := filepath.Join(repo, ".cursor", "rules", "boatstack.mdc") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("user owned\n"), 0o644); err != nil { + t.Fatal(err) + } + config := testConfig() + raw, _ := MarshalJSON(config) + bundle, err := BuildExportBundle("config.json", config, raw, "boatstack") + if err != nil { + t.Fatal(err) + } + if err := WriteExport(repo, bundle.Files); err == nil || !strings.Contains(err.Error(), "user-owned") { + t.Fatalf("expected user-owned collision, got %v", err) + } + value, _ := os.ReadFile(path) + if string(value) != "user owned\n" { + t.Fatal("collision handling modified the user-owned file") + } +} + +func TestExportAdoptsLegacyGeneratedFiles(t *testing.T) { + repo := t.TempDir() + path := filepath.Join(repo, ".cursor", "rules", "boatstack.mdc") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + legacy := "\n" + if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { + t.Fatal(err) + } + config := testConfig() + raw, _ := MarshalJSON(config) + bundle, err := BuildExportBundle("config.json", config, raw, "boatstack") + if err != nil { + t.Fatal(err) + } + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatalf("legacy generated file should be safely replaceable: %v", err) + } +} + +func TestExportRemovesOnlyUnmodifiedStaleGeneratedPath(t *testing.T) { + repo := t.TempDir() + config := testConfig() + raw, _ := MarshalJSON(config) + bundle, _ := BuildExportBundle("config.json", config, raw, "boatstack") + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + stale := ".cursor/commands/retro.md" + delete(bundle.Files, stale) + if err := WriteExport(repo, bundle.Files); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(stale))); !os.IsNotExist(err) { + t.Fatal("unmodified stale generated path was not removed") + } +} diff --git a/boatstack/go.mod b/boatstack/go.mod new file mode 100644 index 0000000..2924fd1 --- /dev/null +++ b/boatstack/go.mod @@ -0,0 +1,3 @@ +module github.com/operatorstack/boatstack/boatstack + +go 1.22 diff --git a/boatstack/init.go b/boatstack/init.go new file mode 100644 index 0000000..ef28ecc --- /dev/null +++ b/boatstack/init.go @@ -0,0 +1,324 @@ +package boatstack + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +type InitOptions struct { + Repo string + BinaryPath string + IntegrationChoice string + Yes bool + Input io.Reader + Output io.Writer +} + +func gitOutput(repo string, arguments ...string) string { + command := exec.Command("git", append([]string{"-C", repo}, arguments...)...) + value, err := command.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(value)) +} + +func ResolveRepository(path string) (string, error) { + if path == "" { + path = "." + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + root := gitOutput(absolute, "rev-parse", "--show-toplevel") + if root == "" { + return "", fmt.Errorf("Boatstack must be initialized inside a Git repository") + } + return root, nil +} + +func detectTestCommand(repo string) string { + packagePath := filepath.Join(repo, "package.json") + if value, err := os.ReadFile(packagePath); err == nil { + var packageJSON struct { + Scripts map[string]string `json:"scripts"` + } + if json.Unmarshal(value, &packageJSON) == nil && strings.TrimSpace(packageJSON.Scripts["test"]) != "" { + switch { + case fileExists(filepath.Join(repo, "pnpm-lock.yaml")): + return "pnpm test" + case fileExists(filepath.Join(repo, "yarn.lock")): + return "yarn test" + case fileExists(filepath.Join(repo, "bun.lock")), fileExists(filepath.Join(repo, "bun.lockb")): + return "bun test" + default: + return "npm test" + } + } + } + for _, candidate := range []struct{ path, command string }{ + {"go.mod", "go test ./..."}, {"Cargo.toml", "cargo test"}, {"Makefile", "make test"}, + } { + if fileExists(filepath.Join(repo, candidate.path)) { + return candidate.command + } + } + return "" +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func detectContext(repo string) []string { + paths := []string{} + for _, candidate := range []string{"README.md", "AGENTS.md", "CLAUDE.md", "docs/architecture/", "docs/decisions/"} { + if _, err := os.Stat(filepath.Join(repo, filepath.FromSlash(strings.TrimSuffix(candidate, "/")))); err == nil { + paths = append(paths, candidate) + } + } + return paths +} + +func DetectHosts(repo string) []string { + hosts := []string{} + checks := []struct { + name string + paths []string + commands []string + }{ + {"cursor", []string{".cursor"}, []string{"cursor", "cursor-agent"}}, + {"claude", []string{".claude", "CLAUDE.md"}, []string{"claude"}}, + {"codex", []string{".agents", "AGENTS.md"}, []string{"codex"}}, + } + for _, check := range checks { + detected := false + for _, path := range check.paths { + if _, err := os.Stat(filepath.Join(repo, path)); err == nil { + detected = true + } + } + for _, command := range check.commands { + if _, err := lookPath(command); err == nil { + detected = true + } + } + if detected { + hosts = append(hosts, check.name) + } + } + if strings.Contains(gitOutput(repo, "remote", "get-url", "origin"), "github.com") || fileExists(filepath.Join(repo, ".github")) { + hosts = append(hosts, "github") + } + return hosts +} + +func defaultConfig(repo, testCommand string) ProjectConfig { + branch := strings.TrimPrefix(gitOutput(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"), "origin/") + if branch == "" { + branch = gitOutput(repo, "branch", "--show-current") + } + if branch == "" { + branch = "main" + } + return ProjectConfig{ + SchemaVersion: 1, + Project: Project{ + Name: filepath.Base(repo), DefaultBranch: branch, Context: detectContext(repo), + Commands: map[string]string{"test": testCommand}, + }, + Workflow: Workflow{HumanPlanApproval: true, IndependentReviewForHighRisk: true, AllowPassWithGaps: true}, + Adapters: []string{"cursor", "claude", "codex", "github"}, + Integrations: map[string]IntegrationState{ + "gstack": {Requested: false, Version: GStackRef}, + "spec-kit": {Requested: false, Version: SpecKitVersion}, + }, + } +} + +func promptLine(reader *bufio.Reader, output io.Writer, prompt string) (string, error) { + fmt.Fprint(output, prompt) + value, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + return strings.TrimSpace(value), nil +} + +func copyHelper(source, repo string) (string, string, error) { + if source == "" { + var err error + source, err = os.Executable() + if err != nil { + return "", "", err + } + } + value, err := os.ReadFile(source) + if err != nil { + return "", "", err + } + name := "boatstack-helper" + if runtime.GOOS == "windows" { + name += ".exe" + } + destination := filepath.Join(repo, ".product-loop", "bin", name) + if err := writeFile(destination, value, 0o755); err != nil { + return "", "", err + } + return destination, SHA256Bytes(value), nil +} + +func writeInstallLock(repo, binaryPath, binaryHash string, integrations map[string]IntegrationState) error { + lock := map[string]any{ + "schema_version": 1, + "boatstack_version": Version, + "source_commit": SourceCommit, + "platform": runtime.GOOS + "/" + runtime.GOARCH, + "binary_path": filepath.ToSlash(strings.TrimPrefix(binaryPath, repo+string(filepath.Separator))), + "binary_sha256": binaryHash, + "release_checksums_sha256": ChecksumsSHA256, + "integrations": integrations, + } + value, err := MarshalJSON(lock) + if err != nil { + return err + } + return writeFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json"), value, 0o644) +} + +func RunInit(options InitOptions) error { + if options.Input == nil { + options.Input = os.Stdin + } + if options.Output == nil { + options.Output = os.Stdout + } + repo, err := ResolveRepository(options.Repo) + if err != nil { + return err + } + reader := bufio.NewReader(options.Input) + configPath := filepath.Join(repo, ".boatstack-project.json") + var config ProjectConfig + var rawConfig []byte + if fileExists(configPath) { + config, rawConfig, err = LoadConfig(configPath) + if err != nil { + return fmt.Errorf("existing Boatstack config is invalid: %w", err) + } + } else { + testCommand := detectTestCommand(repo) + if testCommand == "" { + if options.Yes { + return fmt.Errorf("no test command could be detected; rerun interactively or create .boatstack-project.json") + } + testCommand, err = promptLine(reader, options.Output, "No test command was detected. Enter the real project test command: ") + if err != nil || testCommand == "" { + return fmt.Errorf("a real project test command is required") + } + } + config = defaultConfig(repo, testCommand) + } + + detected := DetectHosts(repo) + if len(detected) == 0 { + fmt.Fprintln(options.Output, "Detected host signals: none; installing all thin adapters for portability.") + } else { + fmt.Fprintf(options.Output, "Detected host signals: %s. Installing portable Cursor, Claude, Codex, and GitHub adapters.\n", strings.Join(detected, ", ")) + } + + choice := options.IntegrationChoice + if choice == "" { + if options.Yes { + choice = "core" + } else { + fmt.Fprintln(options.Output, "\nOptional integrations:") + fmt.Fprintln(options.Output, " core Boatstack only; no external runtimes") + fmt.Fprintln(options.Output, " gstack product/design/engineering/DX review lenses; requires Git, Bun, and a supported host") + fmt.Fprintln(options.Output, " spec-kit specification/plan/task/checklist generation; requires uv and a managed Python environment") + fmt.Fprintln(options.Output, " both install both optional integrations") + choice, err = promptLine(reader, options.Output, "Choose [core]: ") + if err != nil { + return err + } + if choice == "" { + choice = "core" + } + } + } + wantGStack, wantSpecKit, err := RequestedIntegrations(choice) + if err != nil { + return err + } + config.Integrations = map[string]IntegrationState{ + "gstack": {Requested: wantGStack, Version: GStackRef}, + "spec-kit": {Requested: wantSpecKit, Version: SpecKitVersion}, + } + rawConfig, err = MarshalJSON(config) + if err != nil { + return err + } + bundle, err := BuildExportBundle(configPath, config, rawConfig, "boatstack") + if err != nil { + return err + } + if problems := ExportCollisions(repo, bundle.Files); len(problems) > 0 { + return fmt.Errorf("refusing to overwrite user-owned files: %s", strings.Join(problems, ", ")) + } + paths := sortedKeys(bundle.Files) + fmt.Fprintf(options.Output, "\nBoatstack will generate %d paths:\n", len(paths)) + for _, path := range paths { + fmt.Fprintln(options.Output, " "+path) + } + if !fileExists(configPath) { + fmt.Fprintln(options.Output, " .boatstack-project.json (editable repository facts)") + } + if !options.Yes { + answer, promptErr := promptLine(reader, options.Output, "Write these files? [y/N] ") + if promptErr != nil { + return promptErr + } + if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + return fmt.Errorf("installation cancelled before writing files") + } + } + if err := os.WriteFile(configPath, rawConfig, 0o644); err != nil { + return err + } + if err := WriteExport(repo, bundle.Files); err != nil { + return err + } + binaryPath, binaryHash, err := copyHelper(options.BinaryPath, repo) + if err != nil { + return err + } + states, err := InstallIntegrations(choice, repo, config.Adapters) + if err != nil { + return err + } + if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { + return err + } + if err := CheckExport(repo, bundle.Files); err != nil { + return err + } + fmt.Fprintln(options.Output, "\nPASS: Boatstack core installed without a language runtime.") + keys := sortedKeys(states) + for _, name := range keys { + state := states[name] + fmt.Fprintf(options.Output, " %s: %s — %s\n", name, state.Status, state.Detail) + } + fmt.Fprintln(options.Output, "\nStart in Cursor, Codex, or Claude Plan mode:") + fmt.Fprintln(options.Output, " 1. Describe the product change and save the host plan (use .product-loop/intake/ if the host exposes no path).") + fmt.Fprintln(options.Output, " 2. Run /auto-plan") + return nil +} diff --git a/boatstack/init_test.go b/boatstack/init_test.go new file mode 100644 index 0000000..fcbf680 --- /dev/null +++ b/boatstack/init_test.go @@ -0,0 +1,79 @@ +package boatstack + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestRuntimeFreeInit(t *testing.T) { + repo := t.TempDir() + if output, err := exec.Command("git", "-C", repo, "init").CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, output) + } + packageJSON := `{"scripts":{"test":"node --test"}}` + if err := os.WriteFile(filepath.Join(repo, "package.json"), []byte(packageJSON), 0o644); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := RunInit(InitOptions{Repo: repo, IntegrationChoice: "core", Yes: true, Output: &output}); err != nil { + t.Fatal(err) + } + for _, path := range []string{ + ".boatstack-project.json", ".product-loop/project.json", ".product-loop/generated.lock.json", + ".product-loop/bin/install.lock.json", ".cursor/commands/auto-plan.md", + } { + if !fileExists(filepath.Join(repo, filepath.FromSlash(path))) { + t.Fatalf("init did not create %s", path) + } + } + binaryName := "boatstack-helper" + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + if !fileExists(filepath.Join(repo, ".product-loop", "bin", binaryName)) { + t.Fatal("init did not install the project-local helper") + } + if !strings.Contains(output.String(), "PASS: Boatstack core installed without a language runtime") { + t.Fatalf("unexpected init output: %s", output.String()) + } + configValue, _ := os.ReadFile(filepath.Join(repo, ".boatstack-project.json")) + if strings.Contains(string(configValue), `"status"`) { + t.Fatal("machine-local integration status leaked into repository configuration") + } + installValue, _ := os.ReadFile(filepath.Join(repo, ".product-loop", "bin", "install.lock.json")) + if !strings.Contains(string(installValue), `"binary_sha256"`) || !strings.Contains(string(installValue), `"integrations"`) { + t.Fatal("local install lock did not record binary and integration state") + } +} + +func TestGStackMissingPrerequisiteIsPartialNotCoreFailure(t *testing.T) { + oldLookPath := lookPath + defer func() { lookPath = oldLookPath }() + lookPath = func(name string) (string, error) { + if name == "bun" { + return "", fmt.Errorf("missing") + } + return oldLookPath(name) + } + state := installGStack([]string{"codex"}) + if state.Status != "partial" || !strings.Contains(state.Detail, "bun") { + t.Fatalf("expected honest partial integration result, got %#v", state) + } +} + +func TestRequestedIntegrationChoices(t *testing.T) { + for choice, expected := range map[string][2]bool{ + "core": {false, false}, "gstack": {true, false}, "spec-kit": {false, true}, "both": {true, true}, + } { + gstack, specKit, err := RequestedIntegrations(choice) + if err != nil || [2]bool{gstack, specKit} != expected { + t.Fatalf("choice %s: %v %v %v", choice, gstack, specKit, err) + } + } +} diff --git a/boatstack/integrations.go b/boatstack/integrations.go new file mode 100644 index 0000000..98c34cb --- /dev/null +++ b/boatstack/integrations.go @@ -0,0 +1,224 @@ +package boatstack + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +var ( + lookPath = exec.LookPath + homeDir = os.UserHomeDir + runExternal = func(directory, name string, arguments ...string) error { + command := exec.Command(name, arguments...) + command.Dir = directory + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Stdin = os.Stdin + return command.Run() + } + externalOutput = func(directory, name string, arguments ...string) (string, error) { + command := exec.Command(name, arguments...) + command.Dir = directory + value, err := command.Output() + return strings.TrimSpace(string(value)), err + } +) + +func RequestedIntegrations(choice string) (bool, bool, error) { + switch strings.ToLower(strings.TrimSpace(choice)) { + case "", "core", "core-only", "none": + return false, false, nil + case "gstack": + return true, false, nil + case "spec-kit", "speckit": + return false, true, nil + case "both": + return true, true, nil + default: + return false, false, fmt.Errorf("integrations must be core, gstack, spec-kit, or both") + } +} + +func installGStack(adapters []string) IntegrationState { + state := IntegrationState{Requested: true, Status: "partial", Version: GStackRef} + for _, prerequisite := range []string{"git", "bun", "bash"} { + if _, err := lookPath(prerequisite); err != nil { + state.Detail = fmt.Sprintf("gstack skipped: %s is required by its official installer", prerequisite) + return state + } + } + if runtime.GOOS == "windows" { + if _, err := lookPath("node"); err != nil { + state.Detail = "gstack skipped: native Windows requires Node plus Bun and Git Bash or WSL" + return state + } + } + home, err := homeDir() + if err != nil { + state.Detail = "gstack skipped: cannot resolve the user home directory" + return state + } + installRoot := filepath.Join(home, ".claude", "skills", "gstack") + _, claudeDetected := lookPath("claude") + _, codexDetected := lookPath("codex") + if claudeDetected != nil && codexDetected == nil { + installRoot = filepath.Join(home, ".codex", "skills", "gstack") + } + if info, statErr := os.Stat(installRoot); statErr == nil && info.IsDir() { + if _, gitErr := os.Stat(filepath.Join(installRoot, ".git")); gitErr != nil { + state.Detail = "gstack skipped: its target directory exists but is not a Git checkout" + return state + } + if err := runExternal(installRoot, "git", "fetch", "--depth", "1", "origin", GStackRef); err != nil { + state.Detail = "gstack update failed while fetching the pinned revision" + return state + } + if err := runExternal(installRoot, "git", "checkout", "--detach", GStackRef); err != nil { + state.Detail = "gstack update failed while checking out the pinned revision" + return state + } + } else { + if err := os.MkdirAll(filepath.Dir(installRoot), 0o755); err != nil { + state.Detail = "gstack skipped: cannot create its skill directory" + return state + } + if err := runExternal("", "git", "clone", "--no-checkout", "https://github.com/garrytan/gstack.git", installRoot); err != nil { + state.Detail = "gstack clone failed" + return state + } + if err := runExternal(installRoot, "git", "fetch", "--depth", "1", "origin", GStackRef); err != nil { + state.Detail = "gstack clone could not fetch the pinned revision" + return state + } + if err := runExternal(installRoot, "git", "checkout", "--detach", GStackRef); err != nil { + state.Detail = "gstack clone could not check out the pinned revision" + return state + } + } + + hosts := []string{} + if contains(adapters, "claude") { + if _, err := lookPath("claude"); err == nil { + hosts = append(hosts, "claude") + } + } + if contains(adapters, "codex") { + if _, err := lookPath("codex"); err == nil { + hosts = append(hosts, "codex") + } + } + if len(hosts) == 0 { + state.Detail = "gstack source installed, but no officially supported Claude or Codex host was detected; Cursor remains available through Boatstack core" + return state + } + for _, host := range hosts { + if err := runExternal(installRoot, "bash", "setup", "--host", host, "--prefix"); err != nil { + state.Detail = fmt.Sprintf("gstack setup failed for %s; Boatstack core remains installed", host) + return state + } + } + state.Status = "installed" + state.Detail = "gstack installed with namespaced /gstack-* commands" + return state +} + +func specKitExecutable() (string, error) { + if path, err := lookPath("specify"); err == nil { + return path, nil + } + uv, err := lookPath("uv") + if err != nil { + return "", fmt.Errorf("uv is required to install the optional Spec Kit integration") + } + if err := runExternal("", uv, "tool", "install", "specify-cli", "--from", "git+https://github.com/github/spec-kit.git@"+SpecKitVersion); err != nil { + return "", fmt.Errorf("Spec Kit installation failed: %w", err) + } + if path, err := lookPath("specify"); err == nil { + return path, nil + } + binDirectory, err := externalOutput("", uv, "tool", "dir", "--bin") + if err != nil || binDirectory == "" { + return "", fmt.Errorf("Spec Kit installed but specify is not available on PATH") + } + name := "specify" + if runtime.GOOS == "windows" { + name += ".exe" + } + path := filepath.Join(binDirectory, name) + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("Spec Kit installed but specify is not available at %s", path) + } + return path, nil +} + +func specKitHosts(adapters []string) []string { + hosts := []string{} + for _, pair := range []struct{ adapter, integration string }{ + {"cursor", "cursor-agent"}, {"codex", "codex"}, {"claude", "claude"}, + } { + if contains(adapters, pair.adapter) { + hosts = append(hosts, pair.integration) + } + } + return hosts +} + +func installSpecKit(repo string, adapters []string) IntegrationState { + state := IntegrationState{Requested: true, Status: "partial", Version: SpecKitVersion} + specify, err := specKitExecutable() + if err != nil { + state.Detail = err.Error() + return state + } + hosts := specKitHosts(adapters) + if len(hosts) == 0 { + state.Detail = "Spec Kit installed, but no Cursor, Codex, or Claude adapter was selected" + return state + } + if _, err := os.Stat(filepath.Join(repo, ".specify")); os.IsNotExist(err) { + script := "sh" + if runtime.GOOS == "windows" { + script = "ps" + } + arguments := []string{"init", "--here", "--force", "--integration", hosts[0], "--ignore-agent-tools", "--script", script} + if hosts[0] == "codex" { + arguments = append(arguments, "--integration-options=--skills") + } + if err := runExternal(repo, specify, arguments...); err != nil { + state.Detail = "Spec Kit project initialization failed; Boatstack core remains installed" + return state + } + hosts = hosts[1:] + } + for _, host := range hosts { + if err := runExternal(repo, specify, "integration", "install", host); err != nil { + state.Detail = fmt.Sprintf("Spec Kit installed, but its %s integration failed", host) + return state + } + } + state.Status = "installed" + state.Detail = "Spec Kit installed as an artifact generator; Boatstack retains approval and build authority" + return state +} + +func InstallIntegrations(choice, repo string, adapters []string) (map[string]IntegrationState, error) { + wantGStack, wantSpecKit, err := RequestedIntegrations(choice) + if err != nil { + return nil, err + } + states := map[string]IntegrationState{ + "gstack": {Requested: false, Status: "not_selected", Version: GStackRef}, + "spec-kit": {Requested: false, Status: "not_selected", Version: SpecKitVersion}, + } + if wantGStack { + states["gstack"] = installGStack(adapters) + } + if wantSpecKit { + states["spec-kit"] = installSpecKit(repo, adapters) + } + return states, nil +} diff --git a/boatstack/plan.go b/boatstack/plan.go new file mode 100644 index 0000000..5c366f8 --- /dev/null +++ b/boatstack/plan.go @@ -0,0 +1,538 @@ +package boatstack + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +func stringValue(value any) string { + result, _ := value.(string) + return result +} + +func stringSlice(value any) ([]string, bool) { + items, ok := value.([]any) + if !ok { + return nil, false + } + result := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok { + return nil, false + } + result = append(result, text) + } + return result, true +} + +func objectSlice(value any) ([]map[string]any, bool) { + items, ok := value.([]any) + if !ok { + return nil, false + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + object, ok := item.(map[string]any) + if !ok { + return nil, false + } + result = append(result, object) + } + return result, true +} + +func validationSlice(value any) ([]map[string]any, bool) { + items, ok := value.([]any) + if !ok || len(items) == 0 { + return nil, false + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + validation, ok := item.(map[string]any) + if !ok { + return nil, false + } + for _, field := range []string{"run", "origin", "oracle", "independence"} { + if strings.TrimSpace(stringValue(validation[field])) == "" { + return nil, false + } + } + criteria, criteriaOK := stringSlice(validation["criteria"]) + if !criteriaOK || len(criteria) == 0 { + return nil, false + } + result = append(result, validation) + } + return result, true +} + +func LoadPlan(path string) (map[string]any, error) { + value, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var plan map[string]any + if err := json.Unmarshal(value, &plan); err != nil { + return nil, err + } + return plan, nil +} + +func CheckSourcePlan(path string) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("source plan path is required; start in the host Plan mode and save its plan before running auto-plan") + } + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("source plan does not exist as a regular file: %s", path) + } + value, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("source plan is unreadable: %w", err) + } + if strings.TrimSpace(string(value)) == "" { + return fmt.Errorf("source plan is empty: %s", path) + } + return nil +} + +func DiscoverSourcePlan(repo, explicit string) (string, error) { + repoAbsolute, err := filepath.Abs(repo) + if err != nil { + return "", err + } + if strings.TrimSpace(explicit) != "" { + candidate := explicit + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(repoAbsolute, candidate) + } + candidate = filepath.Clean(candidate) + if err := CheckSourcePlan(candidate); err != nil { + return "", err + } + relative, err := filepath.Rel(repoAbsolute, candidate) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return filepath.ToSlash(relative), nil + } + return candidate, nil + } + + roots := []string{ + ".product-loop/intake", + ".cursor/plans", + ".claude/plans", + ".codex/plans", + } + allowed := map[string]bool{".md": true, ".txt": true, ".json": true, ".yaml": true, ".yml": true} + candidates := []string{} + for _, root := range roots { + absoluteRoot := filepath.Join(repoAbsolute, filepath.FromSlash(root)) + if _, err := os.Stat(absoluteRoot); err != nil { + if os.IsNotExist(err) { + continue + } + return "", err + } + err := filepath.WalkDir(absoluteRoot, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if entry.Type()&os.ModeSymlink != 0 || !allowed[strings.ToLower(filepath.Ext(entry.Name()))] { + return nil + } + if strings.EqualFold(entry.Name(), "README.md") || CheckSourcePlan(path) != nil { + return nil + } + relative, relErr := filepath.Rel(repoAbsolute, path) + if relErr != nil { + return relErr + } + candidates = append(candidates, filepath.ToSlash(relative)) + return nil + }) + if err != nil { + return "", err + } + } + sort.Strings(candidates) + if len(candidates) == 0 { + return "", fmt.Errorf("no saved Plan-mode file found; save the current host plan under .product-loop/intake/ and run auto-plan again") + } + if len(candidates) > 1 { + return "", fmt.Errorf("multiple saved Plan-mode files found: %s; keep one active intake file or pass the intended path", strings.Join(candidates, ", ")) + } + return candidates[0], nil +} + +func SourcePlanForStructuredPlan(planPath string) (string, error) { + plan, err := LoadPlan(planPath) + if err != nil { + return "", err + } + sourcePlan := stringValue(plan["source_plan_path"]) + if strings.TrimSpace(sourcePlan) == "" { + return "", fmt.Errorf("source_plan_path is required") + } + if !filepath.IsAbs(sourcePlan) { + sourcePlan = filepath.Join(filepath.Dir(planPath), sourcePlan) + } + return filepath.Clean(sourcePlan), nil +} + +func checkApprovalSourcePlan(options ApprovalOptions) error { + expected, err := SourcePlanForStructuredPlan(options.PlanPath) + if err != nil { + return err + } + expectedAbsolute, err := filepath.Abs(expected) + if err != nil { + return err + } + suppliedAbsolute, err := filepath.Abs(options.SourcePlanPath) + if err != nil { + return err + } + if filepath.Clean(expectedAbsolute) != filepath.Clean(suppliedAbsolute) { + return fmt.Errorf("source-plan does not match structured plan source_plan_path: expected %s", expected) + } + return CheckSourcePlan(expected) +} + +func ValidatePlan(plan map[string]any) error { + if plan["schema_version"] != float64(1) { + return fmt.Errorf("schema_version must be 1") + } + if stringValue(plan["feature_id"]) == "" { + return fmt.Errorf("feature_id is required") + } + if stringValue(plan["source_plan_path"]) == "" { + return fmt.Errorf("source_plan_path is required") + } + criteria, ok := objectSlice(plan["acceptance_criteria"]) + if !ok || len(criteria) == 0 { + return fmt.Errorf("at least one acceptance criterion is required") + } + tasks, ok := objectSlice(plan["tasks"]) + if !ok || len(tasks) == 0 { + return fmt.Errorf("at least one task is required") + } + criterionIDs := map[string]bool{} + for _, criterion := range criteria { + id := stringValue(criterion["id"]) + if id == "" || criterionIDs[id] { + return fmt.Errorf("acceptance criterion ids must be present and unique") + } + criterionIDs[id] = true + } + taskIDs := map[string]bool{} + for _, task := range tasks { + id := stringValue(task["id"]) + if id == "" || taskIDs[id] { + return fmt.Errorf("task ids must be present and unique") + } + taskIDs[id] = true + } + covered := map[string]bool{} + validationCovered := map[string]bool{} + graph := map[string][]string{} + for _, task := range tasks { + id := stringValue(task["id"]) + dependencies, dependenciesOK := stringSlice(task["depends_on"]) + if task["depends_on"] == nil { + dependencies, dependenciesOK = []string{}, true + } + mapped, mappedOK := stringSlice(task["acceptance_criteria"]) + if task["acceptance_criteria"] == nil { + mapped, mappedOK = []string{}, true + } + validations, validationsOK := validationSlice(task["validation"]) + if !dependenciesOK || !mappedOK || !validationsOK { + return fmt.Errorf("task %s requires list dependencies, criteria, and at least one validation with criteria, run, origin, oracle, and independence", id) + } + for _, dependency := range dependencies { + if dependency == id { + return fmt.Errorf("task %s cannot depend on itself", id) + } + if !taskIDs[dependency] { + return fmt.Errorf("task %s has unknown dependency: %s", id, dependency) + } + } + for _, criterion := range mapped { + if !criterionIDs[criterion] { + return fmt.Errorf("task %s maps unknown criterion: %s", id, criterion) + } + covered[criterion] = true + } + for _, validation := range validations { + validationCriteria, _ := stringSlice(validation["criteria"]) + for _, criterion := range validationCriteria { + if !contains(mapped, criterion) { + return fmt.Errorf("task %s validation maps criterion %s not served by the task", id, criterion) + } + validationCovered[criterion] = true + } + } + if len(mapped) == 0 && stringValue(task["enabling_reason"]) == "" { + return fmt.Errorf("task %s must map acceptance criteria or state an enabling_reason", id) + } + graph[id] = dependencies + } + uncovered := []string{} + for criterion := range criterionIDs { + if !covered[criterion] { + uncovered = append(uncovered, criterion) + } + } + if len(uncovered) > 0 { + sort.Strings(uncovered) + return fmt.Errorf("uncovered acceptance criteria: %v", uncovered) + } + unvalidated := []string{} + for criterion := range criterionIDs { + if !validationCovered[criterion] { + unvalidated = append(unvalidated, criterion) + } + } + if len(unvalidated) > 0 { + sort.Strings(unvalidated) + return fmt.Errorf("acceptance criteria without validation procedures: %v", unvalidated) + } + visiting := map[string]bool{} + visited := map[string]bool{} + var visit func(string) error + visit = func(id string) error { + if visiting[id] { + return fmt.Errorf("task dependency cycle includes %s", id) + } + if visited[id] { + return nil + } + visiting[id] = true + for _, dependency := range graph[id] { + if err := visit(dependency); err != nil { + return err + } + } + delete(visiting, id) + visited[id] = true + return nil + } + for id := range taskIDs { + if err := visit(id); err != nil { + return err + } + } + return nil +} + +func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, error) { + if err := ValidatePlan(plan); err != nil { + return nil, nil, "", err + } + criteria, _ := objectSlice(plan["acceptance_criteria"]) + tasks, _ := objectSlice(plan["tasks"]) + rows := make([]any, 0, len(criteria)) + evidence := []string{ + "# Evidence ledger: " + stringValue(plan["feature_id"]), "", + "- Approved plan lock: pending", "- Test gate: `BLOCKED`", "- Review gate: `BLOCKED`", "- Ship gate: `BLOCKED`", "", + "## Acceptance evidence", "", "| Criterion | Tasks | Result | Evidence |", "|---|---|---|---|", + } + for _, criterion := range criteria { + criterionID := stringValue(criterion["id"]) + servingIDs := []string{} + validations := []any{} + for _, task := range tasks { + mapped, _ := stringSlice(task["acceptance_criteria"]) + if !contains(mapped, criterionID) { + continue + } + taskID := stringValue(task["id"]) + servingIDs = append(servingIDs, taskID) + checks, _ := validationSlice(task["validation"]) + for _, check := range checks { + checkCriteria, _ := stringSlice(check["criteria"]) + if !contains(checkCriteria, criterionID) { + continue + } + validations = append(validations, map[string]any{ + "task_id": taskID, + "check": check["run"], + "origin": check["origin"], + "oracle": check["oracle"], + "independence": check["independence"], + }) + } + } + row := map[string]any{ + "criterion_id": criterionID, + "criterion": stringValue(criterion["text"]), + "tasks": servingIDs, + "validations": validations, + "result": "BLOCKED", + "evidence": nil, + } + rows = append(rows, row) + evidence = append(evidence, fmt.Sprintf("| %s: %s | %s | `BLOCKED` | |", criterionID, stringValue(criterion["text"]), strings.Join(servingIDs, ", "))) + } + evidence = append(evidence, "", "## Commands and checks", "", "## Review findings", "", "## Known gaps", "", "## Rollout and rollback", "") + taskGraph := map[string]any{ + "schema_version": 1, + "feature_id": plan["feature_id"], + "source_plan_path": plan["source_plan_path"], + "source_plan_status": "HASH_LOCKED_INPUT", + "structured_plan_status": "HUMAN_APPROVED", + "tasks": plan["tasks"], + } + testMatrix := map[string]any{ + "schema_version": 1, + "feature_id": plan["feature_id"], + "requirements": rows, + } + return taskGraph, testMatrix, strings.Join(evidence, "\n"), nil +} + +func CompilePlanFiles(planPath, outDir string) error { + plan, err := LoadPlan(planPath) + if err != nil { + return err + } + sourcePlan, err := SourcePlanForStructuredPlan(planPath) + if err != nil { + return err + } + if err := CheckSourcePlan(sourcePlan); err != nil { + return err + } + tasks, matrix, evidence, err := CompilePlan(plan) + if err != nil { + return err + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + tasksJSON, err := MarshalJSON(tasks) + if err != nil { + return err + } + matrixJSON, err := MarshalJSON(matrix) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, "tasks.json"), tasksJSON, 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, "test-matrix.json"), matrixJSON, 0o644); err != nil { + return err + } + return os.WriteFile(filepath.Join(outDir, "evidence.md"), []byte(evidence), 0o644) +} + +type ApprovalOptions struct { + SourcePlanPath string + SpecPath string + PlanPath string + TasksPath string + ApprovedBy string + ApprovedAt string + SourceCommit string + OutputPath string +} + +func gitCommit(directory string) string { + command := exec.Command("git", "-C", directory, "rev-parse", "HEAD") + value, err := command.Output() + if err != nil { + return "unknown" + } + return strings.TrimSpace(string(value)) +} + +func CreateApprovalLock(options ApprovalOptions) error { + if strings.TrimSpace(options.ApprovedBy) == "" { + return fmt.Errorf("approved-by must name the human who explicitly approved the plan") + } + if err := checkApprovalSourcePlan(options); err != nil { + return err + } + for _, path := range []string{options.SpecPath, options.PlanPath, options.TasksPath} { + if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() { + return fmt.Errorf("required approved artifact does not exist: %s", path) + } + } + approvedAt := options.ApprovedAt + if approvedAt == "" { + approvedAt = time.Now().UTC().Truncate(time.Second).Format(time.RFC3339) + } + sourceCommit := options.SourceCommit + if sourceCommit == "" { + sourceCommit = gitCommit(filepath.Dir(options.SpecPath)) + } + specHash, _ := SHA256File(options.SpecPath) + sourcePlanHash, _ := SHA256File(options.SourcePlanPath) + planHash, _ := SHA256File(options.PlanPath) + tasksHash, _ := SHA256File(options.TasksPath) + lock := map[string]any{ + "schema_version": 1, + "status": "APPROVED", + "approved_by": options.ApprovedBy, + "approved_at": approvedAt, + "source_commit": sourceCommit, + "source_plan_path": options.SourcePlanPath, + "source_plan_sha256": sourcePlanHash, + "spec_path": options.SpecPath, + "spec_sha256": specHash, + "plan_path": options.PlanPath, + "plan_sha256": planHash, + "task_graph_path": options.TasksPath, + "task_graph_sha256": tasksHash, + "invalidated_at": nil, + "invalidation_reason": nil, + } + value, err := MarshalJSON(lock) + if err != nil { + return err + } + return writeFile(options.OutputPath, value, 0o644) +} + +func CheckApprovalLock(options ApprovalOptions) error { + if err := checkApprovalSourcePlan(options); err != nil { + return err + } + value, err := os.ReadFile(options.OutputPath) + if err != nil { + return fmt.Errorf("plan lock is missing or unreadable: %w", err) + } + lock := map[string]any{} + if err := json.Unmarshal(value, &lock); err != nil { + return fmt.Errorf("plan lock is unreadable: %w", err) + } + mismatches := []string{} + paths := map[string]string{"source_plan": options.SourcePlanPath, "spec": options.SpecPath, "plan": options.PlanPath, "task_graph": options.TasksPath} + for _, label := range []string{"source_plan", "spec", "plan", "task_graph"} { + hash, hashErr := SHA256File(paths[label]) + if hashErr != nil || stringValue(lock[label+"_sha256"]) != hash { + mismatches = append(mismatches, label) + } + } + if stringValue(lock["status"]) != "APPROVED" || lock["invalidated_at"] != nil { + mismatches = append(mismatches, "status") + } + if stringValue(lock["approved_by"]) == "" { + mismatches = append(mismatches, "approver") + } + if len(mismatches) > 0 { + return fmt.Errorf("stale or invalid plan lock: %s", strings.Join(mismatches, ", ")) + } + return nil +} diff --git a/boatstack/plan_test.go b/boatstack/plan_test.go new file mode 100644 index 0000000..c9cd046 --- /dev/null +++ b/boatstack/plan_test.go @@ -0,0 +1,217 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func validPlan() map[string]any { + return map[string]any{ + "schema_version": float64(1), + "feature_id": "feature-one", + "source_plan_path": "source-plan.md", + "acceptance_criteria": []any{ + map[string]any{"id": "AC-1", "text": "observable result"}, + }, + "tasks": []any{ + map[string]any{ + "id": "T-1", "title": "implement result", "depends_on": []any{}, + "acceptance_criteria": []any{"AC-1"}, + "validation": []any{map[string]any{ + "criteria": []any{"AC-1"}, + "run": "go test ./...", "origin": "AC-1", + "oracle": "approved contract assertions", "independence": "contract-derived", + }}, + }, + }, + } +} + +func TestPlanCompilationApprovalAndStaleness(t *testing.T) { + root := t.TempDir() + sourcePlan := filepath.Join(root, "source-plan.md") + spec := filepath.Join(root, "spec.md") + planPath := filepath.Join(root, "plan.json") + compiled := filepath.Join(root, "compiled") + lock := filepath.Join(root, "plan.lock.json") + if err := os.WriteFile(sourcePlan, []byte("# Host Plan-mode proposal\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(spec, []byte("# Accepted spec\n"), 0o644); err != nil { + t.Fatal(err) + } + planJSON, _ := MarshalJSON(validPlan()) + if err := os.WriteFile(planPath, planJSON, 0o644); err != nil { + t.Fatal(err) + } + if err := CompilePlanFiles(planPath, compiled); err != nil { + t.Fatal(err) + } + tasks := filepath.Join(compiled, "tasks.json") + options := ApprovalOptions{ + SourcePlanPath: sourcePlan, + SpecPath: spec, PlanPath: planPath, TasksPath: tasks, + ApprovedBy: "Test Human", ApprovedAt: "2026-07-16T12:00:00Z", + SourceCommit: "test", OutputPath: lock, + } + if err := CreateApprovalLock(options); err != nil { + t.Fatal(err) + } + if err := CheckApprovalLock(options); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sourcePlan, []byte("# Changed host plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := CheckApprovalLock(options); err == nil || !strings.Contains(err.Error(), "source_plan") { + t.Fatalf("expected stale source plan lock, got %v", err) + } + if err := os.WriteFile(sourcePlan, []byte("# Host Plan-mode proposal\n"), 0o644); err != nil { + t.Fatal(err) + } + value, _ := os.ReadFile(planPath) + if err := os.WriteFile(planPath, append(value, '\n'), 0o644); err != nil { + t.Fatal(err) + } + if err := CheckApprovalLock(options); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("expected stale plan lock, got %v", err) + } +} + +func TestSourcePlanPreflightBlocksMissingAndEmptyFiles(t *testing.T) { + root := t.TempDir() + missing := filepath.Join(root, "missing.md") + if err := CheckSourcePlan(missing); err == nil { + t.Fatal("expected missing source plan to block") + } + empty := filepath.Join(root, "empty.md") + if err := os.WriteFile(empty, []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + if err := CheckSourcePlan(empty); err == nil { + t.Fatal("expected empty source plan to block") + } + valid := filepath.Join(root, "valid.md") + if err := os.WriteFile(valid, []byte("# Plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := CheckSourcePlan(valid); err != nil { + t.Fatal(err) + } +} + +func TestSourcePlanDiscoveryUsesOneBoundedCandidateAndBlocksAmbiguity(t *testing.T) { + repo := t.TempDir() + intake := filepath.Join(repo, ".product-loop", "intake") + if err := os.MkdirAll(intake, 0o755); err != nil { + t.Fatal(err) + } + first := filepath.Join(intake, "feature-a.md") + if err := os.WriteFile(first, []byte("# Feature A plan\n"), 0o644); err != nil { + t.Fatal(err) + } + discovered, err := DiscoverSourcePlan(repo, "") + if err != nil { + t.Fatal(err) + } + if discovered != ".product-loop/intake/feature-a.md" { + t.Fatalf("unexpected discovered path: %s", discovered) + } + second := filepath.Join(intake, "feature-b.md") + if err := os.WriteFile(second, []byte("# Feature B plan\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := DiscoverSourcePlan(repo, ""); err == nil || !strings.Contains(err.Error(), "multiple") { + t.Fatalf("expected ambiguous source plans to block, got %v", err) + } + explicit, err := DiscoverSourcePlan(repo, ".product-loop/intake/feature-b.md") + if err != nil { + t.Fatal(err) + } + if explicit != ".product-loop/intake/feature-b.md" { + t.Fatalf("unexpected explicit path: %s", explicit) + } +} + +func TestCompilerRequiresSourcePlanPath(t *testing.T) { + plan := validPlan() + delete(plan, "source_plan_path") + _, _, _, err := CompilePlan(plan) + if err == nil || !strings.Contains(err.Error(), "source_plan_path") { + t.Fatalf("expected missing source plan path failure, got %v", err) + } +} + +func TestCompilerRejectsValidationWithoutOracleProvenance(t *testing.T) { + plan := validPlan() + task := plan["tasks"].([]any)[0].(map[string]any) + task["validation"] = []any{map[string]any{"criteria": []any{"AC-1"}, "run": "go test ./..."}} + _, _, _, err := CompilePlan(plan) + if err == nil || !strings.Contains(err.Error(), "origin, oracle, and independence") { + t.Fatalf("expected validation provenance failure, got %v", err) + } +} + +func TestValidationsOnlySupportTheirMappedCriteria(t *testing.T) { + plan := validPlan() + plan["acceptance_criteria"] = []any{ + map[string]any{"id": "AC-1", "text": "first result"}, + map[string]any{"id": "AC-2", "text": "second result"}, + } + task := plan["tasks"].([]any)[0].(map[string]any) + task["acceptance_criteria"] = []any{"AC-1", "AC-2"} + task["validation"] = []any{ + map[string]any{ + "criteria": []any{"AC-1"}, "run": "check first", + "origin": "AC-1", "oracle": "first oracle", "independence": "pre-existing", + }, + map[string]any{ + "criteria": []any{"AC-2"}, "run": "check second", + "origin": "AC-2", "oracle": "second oracle", "independence": "external", + }, + } + _, matrix, _, err := CompilePlan(plan) + if err != nil { + t.Fatal(err) + } + rows := matrix["requirements"].([]any) + for _, item := range rows { + row := item.(map[string]any) + validations := row["validations"].([]any) + if len(validations) != 1 { + t.Fatalf("criterion %s received unrelated validations: %v", row["criterion_id"], validations) + } + validation := validations[0].(map[string]any) + expected := "check first" + if row["criterion_id"] == "AC-2" { + expected = "check second" + } + if validation["check"] != expected { + t.Fatalf("criterion %s received %v, expected %s", row["criterion_id"], validation["check"], expected) + } + } +} + +func TestCompilerBlocksUncoveredCriterion(t *testing.T) { + plan := validPlan() + criteria := plan["acceptance_criteria"].([]any) + plan["acceptance_criteria"] = append(criteria, map[string]any{"id": "AC-2", "text": "uncovered"}) + _, _, _, err := CompilePlan(plan) + if err == nil || !strings.Contains(err.Error(), "uncovered acceptance criteria") { + t.Fatalf("expected uncovered criterion failure, got %v", err) + } +} + +func TestCompiledTaskGraphPreservesTaskFields(t *testing.T) { + tasks, _, _, err := CompilePlan(validPlan()) + if err != nil { + t.Fatal(err) + } + value, _ := json.Marshal(tasks) + if !strings.Contains(string(value), "implement result") { + t.Fatal("compiler dropped an approved task field") + } +} diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index b03bcd1..12e9a3e 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -4,13 +4,14 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | Artifact | Purpose | Create or update when | |---|---|---| +| Source plan | Host Plan-mode interpretation of ordinary product intent; required input and provenance for `auto-plan` | Before invoking `auto-plan`; keep hash-current through build | | Project constitution | Stable principles and non-negotiable invariants | A rule should govern most future work | | Repository map | Minimal entry points, interfaces, commands, and verification boundaries | The relevant architecture or tooling changes | | Feature brief/spec | Product intent, outcomes, scenarios, acceptance criteria, non-goals | A product slice is proposed or its intent changes | | Question ledger | Unknowns, choices, human answers, provenance, expiry | The repo cannot answer a material question | | ADR | Accepted durable architecture decision and rationale | A meaningful architecture choice is accepted | | Plan/tasks | Dependency-ordered implementation operations and checks | A spec is resolved enough to build | -| Test plan | Requirement-to-evidence mapping and oracle independence | Planning and after discovered failure modes | +| Test plan | Requirement-to-evidence mapping with each validation's origin, falsifiable oracle, procedure, and independence | Planning and after discovered failure modes | | Gap ledger | Known divergence between desired and current state | Work is deferred, partial, incompatible, or intentionally absent | | 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 | @@ -48,6 +49,7 @@ A gap is an explicit difference between the accepted target and the current impl Every material statement should indicate whether it came from: +- the supplied host Plan-mode file; - repository evidence; - runtime evidence; - a human answer; diff --git a/boatstack/references/portability.md b/boatstack/references/portability.md index 1cfee27..2d7f360 100644 --- a/boatstack/references/portability.md +++ b/boatstack/references/portability.md @@ -9,6 +9,7 @@ The source of truth is `.product-loop/`: - `artifacts.md`: document contract; - `failure-moves.md`: failure taxonomy and experimental rules; - `templates/`: artifact templates; +- `bin/boatstack-helper`: ignored, platform-native deterministic helper installed locally; - `generated.lock.json`: generator version, config hash, and generated file list. Host-specific files are compiled adapters: @@ -46,6 +47,8 @@ An installation or update PR should show: Generated output is reviewable code. Do not auto-merge it simply because generation succeeded. +The project-local helper is not committed. A fresh clone restores the verified platform binary by re-running the one-command installer; no Python, Node, Go, or package manager is required for Boatstack core. + ## Host notes ### Cursor diff --git a/boatstack/references/workflow.md b/boatstack/references/workflow.md index eb95c0d..775e2a5 100644 --- a/boatstack/references/workflow.md +++ b/boatstack/references/workflow.md @@ -4,6 +4,7 @@ ```text INTENT + -> SOURCE_PLAN -> PROJECT -> QUESTIONS -> SPEC @@ -20,9 +21,25 @@ INTENT Each transition emits an artifact and evidence. A host adapter may change how a command is invoked, but it must not skip a transition or redefine a gate. +The `SOURCE_PLAN` file is required from entry through completion of `BUILD`. After build, its path and hash remain recorded for provenance, but `TEST_GATE`, `REVIEW_GATE`, and `SHIP_GATE` do not require the original file to be present. + ## State contracts -### `INTENT -> PROJECT` +### `INTENT -> SOURCE_PLAN` + +Begin in the active coding host's Plan mode. Explore the ordinary product intent without editing implementation files, then save that host-generated plan as a file. Invoke `auto-plan` without a path in the normal case. + +Before repository inspection, run: + +```bash +.product-loop/bin/boatstack-helper check-source-plan --repo . --plan +# If the host exposes no active path: +.product-loop/bin/boatstack-helper check-source-plan --repo . +``` + +The host/system conversation path is authoritative when present. Fallback discovery checks `.product-loop/intake/` and bounded repo-local host plan directories; it never scans the whole repository or selects a file solely because it is newest. If the file is missing, ambiguous, empty, or unreadable, `auto-plan` is `BLOCKED` and may request an explicit path. It must not manufacture the missing input. This source plan is an initial proposal rather than human approval. + +### `SOURCE_PLAN -> PROJECT` Define the request as: @@ -71,7 +88,7 @@ Create tasks in dependency order. Each task names: - files or components likely affected; - contract or acceptance criteria served; -- validation command or evidence; +- validation procedure, its origin, its oracle, and its independence; - rollback boundary; - unknowns that would stop implementation. @@ -84,6 +101,16 @@ Run only relevant review lenses: If gstack is installed, its review skills can execute these lenses. If Spec Kit is installed, it can generate and cross-check the spec, plan, tasks, and checklists. Their output is normalized into this artifact contract. +Validation must be derived before implementation. Each check records: + +- `run`: an executable command or a specific human/external procedure; +- `criteria`: only the acceptance claims this procedure can actually support; +- `origin`: the acceptance criterion, repository invariant, human decision, risk, or external contract that requires it; +- `oracle`: the fixture, schema, threshold, rubric, external fact, or authorized judgment capable of falsifying the claim; +- `independence`: whether the oracle is pre-existing, contract-derived, external, human, or implementation-authored. + +Subjective work is not exempt from validation. Convert ambiguity into an approved reference, rubric, scenario, threshold, and evidence owner. If materially different interpretations remain or no defensible oracle exists, keep the plan `BLOCKED` at `PLAN_GATE`. + ### `PLAN -> PLAN_GATE` Present the full draft and require an explicit human `approve` or a change request. Do not interpret silence, a new implementation question, or a tool permission as plan approval. @@ -93,11 +120,12 @@ Present the full draft and require an explicit human `approve` or a change reque After approval, deterministically: 1. hash the approved spec and plan; -2. compile the approved structured plan into the task graph, requirement-test traceability rows, evidence skeleton, and expected gate commands without adding semantics; -3. record approver, timestamp, source commit, and all artifact hashes in `plan.lock.json`; -4. verify every task maps to at least one acceptance criterion or declared enabling dependency. +2. hash the saved source Plan-mode file; +3. compile the approved structured plan into the task graph, requirement-test traceability rows, evidence skeleton, and expected gate commands without adding semantics; +4. record approver, timestamp, source commit, and all artifact hashes in `plan.lock.json`; +5. verify every task maps to at least one acceptance criterion or declared enabling dependency. -Any later change to the approved spec or plan invalidates the lock and returns the feature to `PLAN_GATE`. +Any later change to the source plan, approved spec, or structured plan before build completes invalidates the lock and returns the feature to `PLAN_GATE`. ### `PLAN_LOCKED -> BUILD` @@ -111,6 +139,8 @@ Implement one coherent task slice at a time. After each slice: ### `BUILD -> TEST_GATE` +Crossing this boundary ends the requirement to keep loading or checking the source Plan-mode file. Its recorded path and hash preserve provenance. Subsequent gates judge the approved intent against the actual diff and evidence. + Create requirement-to-evidence traceability. Use this evidence ladder: 1. syntax, schema, and load/collect checks; diff --git a/boatstack/runtime.go b/boatstack/runtime.go new file mode 100644 index 0000000..d8f3cd9 --- /dev/null +++ b/boatstack/runtime.go @@ -0,0 +1,135 @@ +package boatstack + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +var ( + Version = "dev" + SourceCommit = "unknown" + ChecksumsSHA256 = "development" + GStackRef = "a3259400a366593e0c909dd9ac3e59752efd2488" + SpecKitVersion = "v0.12.16" +) + +const ( + Generator = "boatstack-exporter" + Marker = "Generated by Boatstack. Do not edit; change canonical source or .boatstack-project.json." +) + +//go:embed references/*.md assets/templates/* +var canonical embed.FS + +type ProjectConfig struct { + SchemaVersion int `json:"schema_version"` + Project Project `json:"project"` + Workflow Workflow `json:"workflow"` + Adapters []string `json:"adapters"` + Integrations map[string]IntegrationState `json:"integrations,omitempty"` +} + +type Project struct { + Name string `json:"name"` + DefaultBranch string `json:"default_branch,omitempty"` + Context []string `json:"context,omitempty"` + Commands map[string]string `json:"commands"` + HighRiskPaths []string `json:"high_risk_paths,omitempty"` +} + +type Workflow struct { + HumanPlanApproval bool `json:"human_plan_approval"` + IndependentReviewForHighRisk bool `json:"independent_review_for_high_risk"` + AllowPassWithGaps bool `json:"allow_pass_with_gaps"` +} + +type IntegrationState struct { + Requested bool `json:"requested"` + Status string `json:"status,omitempty"` + Version string `json:"version,omitempty"` + Detail string `json:"detail,omitempty"` +} + +func ReadCanonical(path string) ([]byte, error) { + return canonical.ReadFile(path) +} + +func ReadCanonicalDir(path string) ([]fs.DirEntry, error) { + return canonical.ReadDir(path) +} + +func SHA256Bytes(value []byte) string { + digest := sha256.Sum256(value) + return hex.EncodeToString(digest[:]) +} + +func SHA256File(path string) (string, error) { + value, err := os.ReadFile(path) + if err != nil { + return "", err + } + return SHA256Bytes(value), nil +} + +func MarshalJSON(value any) ([]byte, error) { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func GeneratedJSON(value any) ([]byte, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + data := map[string]any{} + if err := json.Unmarshal(raw, &data); err != nil { + return nil, err + } + data["_generated_by"] = Generator + data["_boatstack_version"] = Version + return MarshalJSON(data) +} + +func GeneratedMarkdown(body string) []byte { + return []byte(fmt.Sprintf("\n\n%s\n", Marker, strings.TrimSpace(body))) +} + +func GeneratedFrontmatter(body string) ([]byte, error) { + if !strings.HasPrefix(body, "---\n") { + return nil, fmt.Errorf("frontmatter adapter must start with ---") + } + closing := strings.Index(body[4:], "\n---\n") + if closing < 0 { + return nil, fmt.Errorf("frontmatter adapter is missing its closing ---") + } + insertAt := 4 + closing + len("\n---\n") + marked := body[:insertAt] + "\n\n" + body[insertAt:] + return []byte(strings.TrimSpace(marked) + "\n"), nil +} + +func sortedKeys[T any](values map[string]T) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func writeFile(path string, value []byte, mode fs.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, value, mode) +} diff --git a/boatstack/scripts/approve_plan.py b/boatstack/scripts/approve_plan.py deleted file mode 100644 index 6bfcd08..0000000 --- a/boatstack/scripts/approve_plan.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Create or verify a human-approved, hash-addressed plan lock.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import subprocess -from datetime import datetime, timezone -from pathlib import Path - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def git_commit(cwd: Path) -> str: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=cwd, text=True, capture_output=True - ) - return result.stdout.strip() if result.returncode == 0 else "unknown" - - -def expected(args: argparse.Namespace) -> dict[str, object]: - approved_at = args.approved_at - if not approved_at: - approved_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat() - return { - "schema_version": 1, - "status": "APPROVED", - "approved_by": args.approved_by, - "approved_at": approved_at, - "source_commit": args.source_commit or git_commit(args.spec.parent), - "spec_path": str(args.spec), - "spec_sha256": sha256(args.spec), - "plan_path": str(args.plan), - "plan_sha256": sha256(args.plan), - "task_graph_path": str(args.tasks), - "task_graph_sha256": sha256(args.tasks), - "invalidated_at": None, - "invalidation_reason": None, - } - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Create a plan lock only after a human explicitly approves the draft." - ) - parser.add_argument("--spec", type=Path, required=True) - parser.add_argument("--plan", type=Path, required=True) - parser.add_argument("--tasks", type=Path, required=True) - parser.add_argument("--approved-by") - parser.add_argument("--approved-at") - parser.add_argument("--source-commit") - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - - for path in [args.spec, args.plan, args.tasks]: - if path is not None and not path.is_file(): - parser.error(f"required approved artifact does not exist: {path}") - - if args.check: - if not args.output.is_file(): - print(f"BLOCKED: plan lock is missing: {args.output}") - return 1 - try: - lock = json.loads(args.output.read_text()) - except (OSError, ValueError, TypeError) as exc: - print(f"BLOCKED: plan lock is unreadable: {exc}") - return 1 - mismatches = [] - for label, path in [("spec", args.spec), ("plan", args.plan), ("task_graph", args.tasks)]: - expected_hash = sha256(path) - if lock.get(f"{label}_sha256") != expected_hash: - mismatches.append(label) - if lock.get("status") != "APPROVED" or lock.get("invalidated_at"): - mismatches.append("status") - if not lock.get("approved_by"): - mismatches.append("approver") - if mismatches: - print("BLOCKED: stale or invalid plan lock: " + ", ".join(mismatches)) - return 1 - print("PASS: approved plan lock matches the current artifacts") - return 0 - - if not args.approved_by or not args.approved_by.strip(): - parser.error("--approved-by must name the human who explicitly approved the plan") - lock = expected(args) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n") - print(f"wrote approved plan lock: {args.output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/boatstack/scripts/compile_plan.py b/boatstack/scripts/compile_plan.py deleted file mode 100644 index 1ee4338..0000000 --- a/boatstack/scripts/compile_plan.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -"""Validate an approved structured plan and compile executable gate artifacts.""" - -from __future__ import annotations - -import argparse -import json -from pathlib import Path - - -def fail(message: str) -> None: - raise ValueError(message) - - -def validate(plan: dict) -> None: - if plan.get("schema_version") != 1: - fail("schema_version must be 1") - if not plan.get("feature_id"): - fail("feature_id is required") - criteria = plan.get("acceptance_criteria") - tasks = plan.get("tasks") - if not isinstance(criteria, list) or not criteria: - fail("at least one acceptance criterion is required") - if not isinstance(tasks, list) or not tasks: - fail("at least one task is required") - - criterion_ids = [item.get("id") for item in criteria if isinstance(item, dict)] - task_ids = [item.get("id") for item in tasks if isinstance(item, dict)] - if len(criterion_ids) != len(criteria) or None in criterion_ids or len(set(criterion_ids)) != len(criterion_ids): - fail("acceptance criterion ids must be present and unique") - if len(task_ids) != len(tasks) or None in task_ids or len(set(task_ids)) != len(task_ids): - fail("task ids must be present and unique") - - known_criteria = set(criterion_ids) - known_tasks = set(task_ids) - covered: set[str] = set() - graph: dict[str, list[str]] = {} - for task in tasks: - task_id = task["id"] - dependencies = task.get("depends_on") or [] - mapped = task.get("acceptance_criteria") or [] - validations = task.get("validation") or [] - if task_id in dependencies: - fail(f"task {task_id} cannot depend on itself") - unknown_dependencies = set(dependencies) - known_tasks - if unknown_dependencies: - fail(f"task {task_id} has unknown dependencies: {sorted(unknown_dependencies)}") - unknown_criteria = set(mapped) - known_criteria - if unknown_criteria: - fail(f"task {task_id} maps unknown criteria: {sorted(unknown_criteria)}") - if not mapped and not task.get("enabling_reason"): - fail(f"task {task_id} must map acceptance criteria or state an enabling_reason") - if not isinstance(validations, list) or not validations: - fail(f"task {task_id} requires at least one validation command or procedure") - covered.update(mapped) - graph[task_id] = list(dependencies) - - uncovered = known_criteria - covered - if uncovered: - fail(f"uncovered acceptance criteria: {sorted(uncovered)}") - - visiting: set[str] = set() - visited: set[str] = set() - - def visit(task_id: str) -> None: - if task_id in visiting: - fail(f"task dependency cycle includes {task_id}") - if task_id in visited: - return - visiting.add(task_id) - for dependency in graph[task_id]: - visit(dependency) - visiting.remove(task_id) - visited.add(task_id) - - for task_id in task_ids: - visit(task_id) - - -def compile_artifacts(plan: dict) -> tuple[dict, dict, str]: - criteria = {item["id"]: item for item in plan["acceptance_criteria"]} - task_graph = { - "schema_version": 1, - "feature_id": plan["feature_id"], - "source_plan_status": "HUMAN_APPROVED", - "tasks": plan["tasks"], - } - rows = [] - for criterion_id, criterion in criteria.items(): - serving = [task for task in plan["tasks"] if criterion_id in (task.get("acceptance_criteria") or [])] - validations = [] - for task in serving: - for check in task.get("validation") or []: - validations.append({"task_id": task["id"], "check": check}) - rows.append({ - "criterion_id": criterion_id, - "criterion": criterion.get("text", ""), - "tasks": [task["id"] for task in serving], - "validations": validations, - "result": "BLOCKED", - "evidence": None, - }) - test_matrix = { - "schema_version": 1, - "feature_id": plan["feature_id"], - "requirements": rows, - } - evidence_lines = [ - f"# Evidence ledger: {plan['feature_id']}", - "", - "- Approved plan lock: pending", - "- Test gate: `BLOCKED`", - "- Review gate: `BLOCKED`", - "- Ship gate: `BLOCKED`", - "", - "## Acceptance evidence", - "", - "| Criterion | Tasks | Result | Evidence |", - "|---|---|---|---|", - ] - for row in rows: - evidence_lines.append( - f"| {row['criterion_id']}: {row['criterion']} | {', '.join(row['tasks'])} | `BLOCKED` | |" - ) - evidence_lines.extend(["", "## Commands and checks", "", "## Review findings", "", "## Known gaps", "", "## Rollout and rollback", ""]) - return task_graph, test_matrix, "\n".join(evidence_lines) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--plan", type=Path, required=True) - parser.add_argument("--out-dir", type=Path, required=True) - args = parser.parse_args() - try: - plan = json.loads(args.plan.read_text()) - validate(plan) - task_graph, test_matrix, evidence = compile_artifacts(plan) - except (OSError, ValueError, json.JSONDecodeError) as exc: - print(f"BLOCKED: invalid approved plan: {exc}") - return 1 - args.out_dir.mkdir(parents=True, exist_ok=True) - (args.out_dir / "tasks.json").write_text(json.dumps(task_graph, indent=2, sort_keys=True) + "\n") - (args.out_dir / "test-matrix.json").write_text(json.dumps(test_matrix, indent=2, sort_keys=True) + "\n") - (args.out_dir / "evidence.md").write_text(evidence) - print(f"PASS: compiled approved plan into {args.out_dir}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/boatstack/scripts/export_repo.py b/boatstack/scripts/export_repo.py deleted file mode 100644 index afee25b..0000000 --- a/boatstack/scripts/export_repo.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 -"""Export the canonical product loop into thin repo-specific host adapters.""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -from pathlib import Path - - -VERSION = "0.1.0" -GENERATOR = "product-engineering-loop-exporter" -ALLOWED_ADAPTERS = {"cursor", "claude", "codex", "github"} -MARKER = "Generated by product-engineering-loop exporter. Do not edit; change canonical source or project.json." -ADAPTER_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def sha256_bytes(value: bytes) -> str: - return hashlib.sha256(value).hexdigest() - - -def generated_markdown(body: str) -> bytes: - return f"\n\n{body.rstrip()}\n".encode() - - -def generated_frontmatter(body: str) -> bytes: - if not body.startswith("---\n"): - raise ValueError("frontmatter adapter must start with ---") - closing = body.find("\n---\n", 4) - if closing < 0: - raise ValueError("frontmatter adapter is missing its closing ---") - insert_at = closing + len("\n---\n") - marked = body[:insert_at] + f"\n\n" + body[insert_at:] - return (marked.rstrip() + "\n").encode() - - -def generated_script(body: str) -> bytes: - lines = body.splitlines() - if lines and lines[0].startswith("#!"): - lines.insert(1, f"# {MARKER}") - else: - lines.insert(0, f"# {MARKER}") - return ("\n".join(lines).rstrip() + "\n").encode() - - -def generated_json(value: dict) -> bytes: - data = dict(value) - data["_generated_by"] = GENERATOR - data["_loop_version"] = VERSION - return (json.dumps(data, indent=2, sort_keys=True) + "\n").encode() - - -def load_config(path: Path) -> dict: - data = json.loads(path.read_text()) - if data.get("schema_version") != 1: - raise ValueError("project config schema_version must be 1") - project = data.get("project") - if not isinstance(project, dict) or not project.get("name"): - raise ValueError("project.name is required") - commands = project.get("commands") - if not isinstance(commands, dict) or not commands.get("test"): - raise ValueError("project.commands.test is required; the exporter will not invent it") - adapters = set(data.get("adapters") or ALLOWED_ADAPTERS) - unknown = adapters - ALLOWED_ADAPTERS - if unknown: - raise ValueError("unsupported adapters: " + ", ".join(sorted(unknown))) - return data - - -def command_body(operation: str, extra: str) -> str: - return f"""# {operation} - -Run the `{operation}` operation from `@.product-loop/workflow.md`. - -Read `@.product-loop/project.json`, `@.product-loop/artifacts.md`, and only the minimal repository context relevant to the current feature. {extra} - -Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. -""" - - -def build_files( - config_path: Path, - config: dict, - skill_root: Path, - adapters: set[str], - adapter_name: str = "product-engineering-loop", -) -> dict[Path, bytes]: - files: dict[Path, bytes] = {} - files[Path(".product-loop/project.json")] = generated_json(config) - for name in ["workflow.md", "artifacts.md", "failure-moves.md"]: - files[Path(".product-loop") / name] = generated_markdown( - (skill_root / "references" / name).read_text() - ) - for template in sorted((skill_root / "assets" / "templates").glob("*")): - if template.suffix == ".json": - value = json.loads(template.read_text()) - files[Path(".product-loop/templates") / template.name] = generated_json(value) - else: - files[Path(".product-loop/templates") / template.name] = generated_markdown(template.read_text()) - files[Path(".product-loop/tools/approve_plan.py")] = generated_script( - (skill_root / "scripts" / "approve_plan.py").read_text() - ) - files[Path(".product-loop/tools/compile_plan.py")] = generated_script( - (skill_root / "scripts" / "compile_plan.py").read_text() - ) - - operations = { - "auto-plan": "Produce a draft only. Do not implement and do not imply the user accepted it.", - "plan-gate": "Require explicit human approval. Only then run `.product-loop/tools/compile_plan.py` and `.product-loop/tools/approve_plan.py` to create the executable task/evidence package and lock.", - "build": "Before editing, locate the feature spec, plan, compiled tasks, and plan lock; run `.product-loop/tools/approve_plan.py --check` against them. Stop if it reports `BLOCKED`.", - "test-gate": "Build a requirement-to-evidence matrix and treat self-authored tests as evidence rather than the sole oracle.", - "review-gate": "Review the actual diff against approved intent, invariants, risks, gaps, and test evidence.", - "ship-gate": "Prepare a PR only; do not merge or deploy without separate authorization.", - "review": "Alias of `review-gate`: review the actual diff against approved intent, invariants, risks, gaps, and test evidence.", - "ship": "Alias of `ship-gate`: prepare a PR only; do not merge or deploy without separate authorization.", - "retro": "Classify evidence and propose a move; never promote it or change durable rules without a paired gate.", - } - - if "cursor" in adapters: - rule = """--- -description: Use the canonical product engineering loop for planning, approved implementation, evidence gates, PR preparation, and loop retrospectives. -globs: -alwaysApply: false ---- - -The source of truth is @.product-loop/workflow.md and @.product-loop/project.json. -Use @.product-loop/artifacts.md for document meanings and @.product-loop/failure-moves.md for retrospectives. -Do not start build work until the explicit plan gate has produced a valid plan lock. -Do not branch behavior on model name, provider, or price; branch on observed work state and evidence. -""" - files[Path(f".cursor/rules/{adapter_name}.mdc")] = generated_frontmatter(rule) - for operation, extra in operations.items(): - files[Path(f".cursor/commands/{operation}.md")] = generated_markdown(command_body(operation, extra)) - - adapter_skill = f"""--- -name: {adapter_name} -description: Run the repository's canonical question-led product engineering loop for planning, explicit plan approval, implementation, test/review/ship gates, and evidence-based retrospectives. ---- - -# Product Engineering Loop Adapter - -Read `.product-loop/project.json` and `.product-loop/workflow.md`. The requested operation is supplied by the user; valid operations are `auto-plan`, `plan-gate`, `build`, `test-gate`, `review-gate`/`review`, `ship-gate`/`ship`, and `retro`. - -Use `.product-loop/artifacts.md` for document boundaries and `.product-loop/failure-moves.md` for improvement experiments. Do not implement from an unapproved or stale plan. Do not branch on model identity; use observable state and gate evidence. -""" - if "claude" in adapters: - files[Path(f".claude/skills/{adapter_name}/SKILL.md")] = generated_frontmatter(adapter_skill) - if "codex" in adapters: - files[Path(f".agents/skills/{adapter_name}/SKILL.md")] = generated_frontmatter(adapter_skill) - - if "github" in adapters: - pr = """# Product-loop PR - -## Approved intent - -- Feature spec: -- Approved plan hash: -- Human approver: -- Linked ADRs/questions: - -## Outcome - -- User-visible change: -- Non-goals preserved: - -## Gate evidence - -- Test gate: `BLOCKED` -- Review gate: `BLOCKED` -- Ship gate: `BLOCKED` -- Evidence ledger: - -## Known gaps - -- Gap ledger: -- `PASS_WITH_GAPS` rationale, owner, and revisit trigger: - -## Rollout and rollback - -- Rollout: -- Observability: -- Rollback: - -## Generated adapter update - -- Canonical loop version: -- Config hash: -- Export check: -""" - files[Path(f".github/PULL_REQUEST_TEMPLATE/{adapter_name}.md")] = generated_markdown(pr) - - lock_entries = { - str(path): sha256_bytes(content) for path, content in sorted(files.items(), key=lambda item: str(item[0])) - } - lock = { - "schema_version": 1, - "generator": GENERATOR, - "loop_version": VERSION, - "config_source": config_path.name, - "config_sha256": sha256_bytes(config_path.read_bytes()), - "adapters": sorted(adapters), - "files": lock_entries, - } - files[Path(".product-loop/generated.lock.json")] = generated_json(lock) - return files - - -def owned(content: bytes, path: Path) -> bool: - if MARKER.encode() in content: - return True - if path.suffix == ".json": - try: - return json.loads(content).get("_generated_by") == GENERATOR - except (ValueError, AttributeError): - return False - return False - - -def write_files(repo: Path, files: dict[Path, bytes]) -> int: - collisions = [] - for relative, content in files.items(): - target = repo / relative - if target.exists() and target.read_bytes() != content and not owned(target.read_bytes(), relative): - collisions.append(str(relative)) - if collisions: - print("BLOCKED: refusing to overwrite user-owned files:") - for collision in collisions: - print(f" {collision}") - return 2 - for relative, content in files.items(): - target = repo / relative - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(content) - print(f"wrote {len(files)} generated files to {repo}") - return 0 - - -def check_files(repo: Path, files: dict[Path, bytes]) -> int: - problems = [] - for relative, content in files.items(): - target = repo / relative - if not target.exists(): - problems.append(f"missing {relative}") - elif target.read_bytes() != content: - problems.append(f"drift {relative}") - if problems: - print("BLOCKED: generated output is stale") - for problem in problems: - print(f" {problem}") - return 1 - print(f"PASS: {len(files)} generated files match canonical source and config") - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--repo", type=Path, required=True) - parser.add_argument("--config", type=Path, required=True) - parser.add_argument("--adapters", help="comma-separated override") - parser.add_argument( - "--adapter-name", - default="product-engineering-loop", - help="kebab-case name for generated host adapter files (default: product-engineering-loop)", - ) - mode = parser.add_mutually_exclusive_group() - mode.add_argument("--write", action="store_true") - mode.add_argument("--check", action="store_true") - args = parser.parse_args() - - repo = args.repo.resolve() - config_path = args.config.resolve() - if not repo.is_dir(): - parser.error(f"repo does not exist: {repo}") - try: - config = load_config(config_path) - adapters = set(args.adapters.split(",")) if args.adapters else set(config.get("adapters") or ALLOWED_ADAPTERS) - unknown = adapters - ALLOWED_ADAPTERS - if unknown: - raise ValueError("unsupported adapters: " + ", ".join(sorted(unknown))) - if not ADAPTER_NAME.fullmatch(args.adapter_name): - raise ValueError("adapter name must be a lowercase kebab-case slug") - except (OSError, ValueError, json.JSONDecodeError) as exc: - parser.error(str(exc)) - - skill_root = Path(__file__).resolve().parent.parent - files = build_files(config_path, config, skill_root, adapters, args.adapter_name) - if args.check: - return check_files(repo, files) - if args.write: - return write_files(repo, files) - print(f"dry run: would generate {len(files)} files in {repo}") - for relative in sorted(files, key=str): - print(f" {relative}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/docs/evidence-engineered-coding.md b/docs/evidence-engineered-coding.md new file mode 100644 index 0000000..e9d080d --- /dev/null +++ b/docs/evidence-engineered-coding.md @@ -0,0 +1,144 @@ + + +# Evidence-engineered coding + +Boatstack is a mathematically modeled coding node, not a prescribed loop. It leaves implementation open and makes authority, evidence, and accepted outcomes observable at the node boundary. + +```text +product intent + repository state + | + v + [ BOATSTACK ] + | + v +diff + evidence + decisions + known gaps +``` + +## The minimal state + +For current repository state `x_t`, select only the task-relevant slice: + +```text +z_t = P_s(x_t) +``` + +Canonicalize that slice into one domain, one contract, one outcome, and one next operator: + +```text +r_t = R(z_t) +u_t = f(r_t) +x_(t+1) = V(u_t, acceptance, invariants) +``` + +In the repository, those terms are not decorative notation: + +| Term | Boatstack artifact or check | +|---|---| +| `P_s` | context paths in `project.json` plus the current feature boundary | +| `R` | question ledger, feature spec, acceptance criteria, structured plan | +| `f` | one operation: auto-plan, plan-gate, build, test, review, or ship | +| `V` | requirement/test matrix, command evidence, review findings, plan hashes | +| `x_(t+1)` | locked plan, bounded diff, gate result, PR, or recorded gap | + +ZCA creates immediate value by reducing a vague feature request to one verifiable slice. For a shipped SDK, API, or CLI, Boatstack uses two slices: the implementation boundary and one representative consumer path. + +The entry state is not an unstructured chat message. Ordinary product intent is first explored in the active host's Plan mode and saved as a source plan. `auto-plan` resolves the active path from host conversation context or bounded fallback discovery, then requires that file before projecting repository context: + +```text +ordinary intent --host Plan mode--> source plan file --auto-plan--> reviewable feature package +``` + +The source plan is neither approval nor executable truth. It is the provenance-bearing proposal that Boatstack questions, grounds in repository evidence, and refines. Its file and hash are required through build. After build, evidence gates operate from the approved lock and actual change, so they do not depend on continuing to load the original source plan. + +## Preserve the source; project the slice + +Context is potential value, not guaranteed value. Let `C` be canonical repository context, `Y` the desired outcome, and `T(C)` a deterministic translation into another representation. The data processing inequality gives the motivating bound: + +```text +I(Y; T(C)) <= I(Y; C) +``` + +A translation cannot create information about `Y` that was absent from `C`, and it may lose relationships, provenance, or constraints. This is a mathematical bound on information, not proof that every summary or projection makes an AI system perform worse. Under a finite context window and finite attention, a relevant projection can improve effective task performance by excluding noise. + +Boatstack therefore keeps two distinct context slices: + +1. **Canonical product knowledge:** repository-owned documents, code, decisions, and history remain authoritative and keep their existing structure. +2. **Temporary task projection:** the smallest relevant subset is selected for the current operator, with source references and reviewable transformations. + +Generated specs and plans may clarify or canonicalize the task, but they never silently replace their sources. Value emerges from lowering task entropy while retaining a path back to the information from which each claim was derived. + +## Open execution, controlled claims + +Boatstack does not constrain the implementation operator. A model may explore, edit, test, backtrack, delegate, or select any suitable method inside the approved boundary. What it cannot do is silently convert activity into authority or a completion claim. + +```text +open: implementation method, model, tools, local tactics +controlled: approved intent, evidence, acceptance, review, shipping authority +``` + +This is the central separation: **you are free in how you build; only claims of completion require evidence.** + +## Optimization is constrained, not blind compression + +Boatstack aims to minimize the cost of context and ceremony subject to an accepted outcome: + +```text +minimize C(context) + C(ceremony) + C(rework) +subject to acceptance criteria pass + project invariants hold + required evidence exists + 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 **4044 estimated tokens**, while host adapters point to one operation at a time. + +## Control appears at transitions + +The plan gate is a concrete controller boundary: + +```text +if sha256(current_plan) != approval.plan_sha256: + BLOCKED: plan changed after approval +``` + +The plan compiler is another: + +```text +uncovered = acceptance_ids - task_acceptance_ids +if uncovered: + BLOCKED: acceptance criteria lack tasks or verification +``` + +The test gate maps each claim to evidence instead of asking the implementer whether it feels finished: + +```text +AC-4: ASCII output stays byte-compatible + -> diff -u expected-output.txt actual-output.txt + -> PASS | FAIL | BLOCKED +``` + +These boundaries do not guarantee correct software. They make missing authority, missing coverage, stale state, and failed evidence observable before shipping. + +The full oracle, ambiguity, and evidence model is documented in [Validation and evidence](validation-and-evidence.md). + +## The graph follows the work + +Boatstack does not force every change through a feedback cycle: + +```text +linear: intent -> Boatstack -> accepted change +feedback: evidence -> revision -> new evidence +branch/merge: path A --\ + compare -> accepted change + path B --/ +``` + +A successful first pass stays linear. A failed or incomplete check creates a feedback edge. Parallel implementation or review creates branches. Boatstack can therefore be a node in a loop without defining itself by the loop. + +Delivery and system improvement also remain separate. A failed task may suggest a better harness move, but one anecdote cannot silently rewrite every future instruction. Promotion requires a representative comparison and a non-regression boundary. + +## What is evidence-backed + +The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`40ebae5dfb3d090812301a438aaac079426edcfc`](https://github.com/operatorstack/intelligence-flow/tree/40ebae5dfb3d090812301a438aaac079426edcfc/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/loop-engineering.md b/docs/loop-engineering.md deleted file mode 100644 index 6ba3c05..0000000 --- a/docs/loop-engineering.md +++ /dev/null @@ -1,92 +0,0 @@ - - -# Loop engineering - -Boatstack treats software delivery as a feedback system around a coding model. The model matters, but it is not the whole system. - -## The minimal state - -For current repository state `x_t`, select only the task-relevant slice: - -```text -z_t = P_s(x_t) -``` - -Canonicalize that slice into one domain, one contract, one outcome, and one next operator: - -```text -r_t = R(z_t) -u_t = f(r_t) -x_(t+1) = V(u_t, acceptance, invariants) -``` - -In the repository, those terms are not decorative notation: - -| Term | Boatstack artifact or check | -|---|---| -| `P_s` | context paths in `project.json` plus the current feature boundary | -| `R` | question ledger, feature spec, acceptance criteria, structured plan | -| `f` | one operation: auto-plan, plan-gate, build, test, review, or ship | -| `V` | requirement/test matrix, command evidence, review findings, plan hashes | -| `x_(t+1)` | locked plan, bounded diff, gate result, PR, or recorded gap | - -ZCA creates immediate value by reducing a vague feature request to one verifiable slice. For a shipped SDK, API, or CLI, Boatstack uses two slices: the implementation boundary and one representative consumer path. - -## Optimization is constrained, not blind compression - -Boatstack aims to minimize the cost of context and ceremony subject to an accepted outcome: - -```text -minimize C(context) + C(ceremony) + C(rework) -subject to acceptance criteria pass - project invariants hold - required evidence exists - 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 **3371 estimated tokens**, while host adapters point to one operation at a time. - -## Control appears at state transitions - -The plan gate is a concrete controller boundary: - -```python -if sha256(current_plan) != approval["plan_sha256"]: - return "BLOCKED: plan changed after approval" -``` - -The plan compiler is another: - -```python -uncovered = acceptance_ids - task_acceptance_ids -if uncovered: - raise ValueError(f"uncovered acceptance criteria: {sorted(uncovered)}") -``` - -The test gate maps each claim to evidence instead of asking the implementer whether it feels finished: - -```text -AC-4: ASCII output stays byte-compatible - -> diff -u expected-output.txt actual-output.txt - -> PASS | FAIL | BLOCKED -``` - -These boundaries do not guarantee correct software. They make missing authority, missing coverage, stale state, and failed evidence observable before shipping. - -## There are two loops - -Delivery and loop improvement remain separate: - -```text -delivery: intent -> approved plan -> code -> gates -> PR -improvement: failure evidence -> mechanism -> candidate move -> paired gate - -> promote | wash | reject -``` - -A failed task can suggest a better move, but one anecdote cannot silently rewrite every future prompt. Promotion requires a representative comparison and a non-regression boundary. - -## What is evidence-backed - -The current moves were derived from the Intelligence Flow benchmark corpus and product-repository studies. The generated source commit is [`fcc2faa55ac3c2332ce4a19293d89023f578784d`](https://github.com/operatorstack/intelligence-flow/tree/fcc2faa55ac3c2332ce4a19293d89023f578784d/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/research-and-design.md b/docs/research-and-design.md index f199b27..5cf807b 100644 --- a/docs/research-and-design.md +++ b/docs/research-and-design.md @@ -1,4 +1,4 @@ -# Research and design: a harness-neutral product engineering loop +# Research and design: an evidence-engineered coding node ## Outcome @@ -11,7 +11,7 @@ The proposed product is not a large prompt and not a Codex-, Cursor-, Claude-, o The initial implementation is in [`product-engineering-loop/`](product-engineering-loop/). Its exporter generates Cursor rules/commands, Claude Code and Codex skills, and a GitHub PR template from one source. -The public [Boatstack](https://github.com/operatorstack/boatstack) repository is a compiled distribution, not a second source of workflow truth. `scripts/build_boatstack.py` projects this package into a branded README, loop-engineering explanation, worked example, tests, and installable skill; `UPSTREAM.json` binds every generated file to its Intelligence Flow commit. A Boatstack-owned scheduled workflow polls this public source and proposes changes by PR. +The public [Boatstack](https://github.com/operatorstack/boatstack) repository is a compiled distribution, not a second source of product/runtime truth. `scripts/build_boatstack.py` projects this package into a branded README, evidence-engineered-coding explanation, worked example, tests, and installable skill; `UPSTREAM.json` binds every projected file to its Intelligence Flow commit. A Boatstack-owned scheduled workflow polls this public source and proposes content changes by PR. Boatstack's `.github/workflows` directory is a deliberately separate control-plane slice: it originates and changes in Boatstack through ordinary, manually reviewed PRs and is never emitted, owned, or removed by the Intelligence Flow projector. ## Outcome sizing and where value emerges @@ -21,7 +21,7 @@ For a feature, the minimal outcome definition is: one domain + one contract + one outcome + one next operator + one verifier ``` -Because the loop may become a shipped product, it keeps delivery and improvement as separate paths: +Because the coding node may become a shipped product, it keeps delivery and improvement as separate paths: - **Delivery path:** developer intent -> questions -> draft -> human approval -> deterministic materialization -> build -> test -> review -> PR. - **Improvement path:** run evidence -> failure mode -> proposed move -> paired representative gate -> promote/reject/wash. @@ -32,23 +32,26 @@ Value emerges twice. The delivery path reduces assumption-driven code and produc ```text Cursor/GitHub intent - -> /auto-plan draft spec + structured plan; no code + -> host Plan mode saved source plan; no implementation + -> /auto-plan validate source plan, then draft spec + structured plan; no code -> /plan-gate explicit human approve/change request after approval: compile tasks/test matrix/evidence + hash lock -> /build refuses absent or stale lock -> /test-gate requirement-derived independent evidence -> /review diff + intent + invariant + risk + gap review -> /ship PR preparation, not merge/deploy - -> /retro propose a loop move; never silently promote it + -> /retro propose a harness move; never silently promote it ``` -`/auto-plan` cannot infer acceptance from silence. `/plan-gate` records the approver and hashes the spec, plan, and compiled task graph. Any semantic edit invalidates the lock and returns to approval. This turns the developer's agreement into a machine-checkable state transition instead of conversational memory. +`/auto-plan` cannot infer acceptance from silence. `/plan-gate` records the approver and hashes the source Plan-mode file, spec, structured plan, and compiled task graph. Any semantic edit before build completes invalidates the lock and returns to approval. This turns the developer's agreement into a machine-checkable state transition instead of conversational memory. + +`/auto-plan` is deliberately not the first planning surface. It requires exactly one non-empty file produced by the active host's Plan mode and refuses to invent that input. It resolves the active plan from host/system conversation context first, then checks only bounded plan locations; zero or multiple candidates block instead of silently choosing the newest file. The source file is hash-bound through build; after build, test/review/ship consume the approved lock, diff, and evidence rather than repeatedly loading the exploratory plan. ## Why the workflow has no model conditions The benchmark evidence shows that the binding failure mode changes by task, distribution, and intervention. It does not justify hardcoding “cheap model workflow” and “strong model workflow.” A model name, provider, or price is not an observed failure state. -The loop therefore branches only on: +The node therefore branches only on: - unknown versus discoverable information; - risk and reversibility; @@ -82,7 +85,7 @@ Those are **summary-only evidence** in this design. They are not represented as ## What the Terminal-Bench data actually encodes -| Observation | Evidence | Coding-loop rule | +| Observation | Evidence | Boatstack rule | |---|---|---| | Fatal command timeouts hid capability | Non-fatal timeout handling moved the original score from roughly 54% to 65.4% | Tool failures become observations when safely recoverable; external timeout remains authoritative | | Malformed structured responses were recoverable | July screen repaired 56/63 malformed responses; full run repaired 490/567 exposures | Validate schemas and attempt bounded same-step parse repair; do not label protocol failure as reasoning failure | @@ -102,6 +105,8 @@ Sources: [`RESEARCH_LOG.md`](../11-harbor-submit/RESEARCH_LOG.md), [`EXPERIMENT_ Terminal-Bench supplies failure mechanics; the product repositories supply real engineering context. +Repository context remains canonical rather than being migrated into a Boatstack-owned memory. For desired outcome `Y`, source context `C`, and deterministic translation `T`, the data processing inequality gives `I(Y; T(C)) <= I(Y; C)`: translation cannot manufacture missing information and may discard provenance or relationships. This does not imply that every projection reduces model performance; selecting a smaller relevant slice can improve effective use of a finite context window. The implemented rule is therefore **preserve the source; project only the relevant slice**, with generated artifacts kept reviewable and traceable to source paths. + The first example repository demonstrates: - durable non-negotiables for tenancy, evidence handling, audit logs, and resource caps; @@ -117,7 +122,7 @@ The second example repository demonstrates: - mechanical CI rules derived from recurring real-world failure patterns; - test plans spanning unit/build/staging/fail-soft behavior. -This is why ADRs are only one artifact. The loop also needs a question ledger, feature spec, gap ledger, test plan, risk note, evidence ledger, and runbook when relevant. +This is why ADRs are only one artifact. The evidence contract also needs a question ledger, feature spec, gap ledger, test plan, risk note, evidence ledger, and runbook when relevant. ## What is adopted from gstack and Spec Kit @@ -125,7 +130,7 @@ From [gstack](https://github.com/garrytan/gstack/blob/main/docs/skills.md): forc From [GitHub Spec Kit](https://github.com/github/spec-kit): constitution, specify, clarify, plan, tasks, analyze, checklist, implement, and converge stages. Spec Kit can generate artifacts, but `.product-loop/` normalizes their meaning and preserves the explicit human plan gate. -The loop does not adopt a universal “boil the ocean” policy. Completeness is required for the approved outcome; unrelated architecture remains outside its boundary. +The node does not adopt a universal “boil the ocean” policy. Completeness is required for the approved outcome; unrelated architecture remains outside its boundary. ## Host portability @@ -136,12 +141,12 @@ The loop does not adopt a universal “boil the ocean” policy. Completeness is 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. -## Evaluation of the finished loop +## Evaluation of the finished node The best public primary benchmark is [FeatureBench](https://github.com/LiberCoders/FeatureBench), because it targets complex feature development and provides a 100-instance fast split plus agent integrations. Evaluate the same model and tasks with: ```text -plain host harness vs product engineering loop +plain host harness vs evidence-engineered coding node ``` Measure resolved rate, regression rate, tokens/cost, elapsed time, question count, plan revisions, stale-lock blocks, test-oracle independence, review findings, and ship-gate false accepts. @@ -157,4 +162,4 @@ Add a private held-out feature set drawn from the two example repositories for t 5. How should private traces be redacted before entering the improvement corpus? 6. What promotion sample size/noise band should the product default to outside benchmarks? -The next valuable step is to install the exporter into a clean fixture repository, forward-test `/auto-plan -> /plan-gate -> /build` on one real feature, and only then apply it to the two example repositories. +The next valuable step is to install the exporter into a clean fixture repository, forward-test `host Plan mode -> saved plan -> /auto-plan -> /plan-gate -> /build` on one real feature, and only then apply it to the two example repositories. diff --git a/docs/validation-and-evidence.md b/docs/validation-and-evidence.md new file mode 100644 index 0000000..10a7f09 --- /dev/null +++ b/docs/validation-and-evidence.md @@ -0,0 +1,97 @@ + + +# Validation and evidence + +Boatstack separates producing a change from proving a claim about that change. A successful command is evidence only when its relationship to an approved requirement and a falsifiable oracle is explicit. + +```text +claim -> origin -> oracle -> procedure -> observation -> gate result +``` + +## The validation contract + +Every planned validation records: + +| Field | Question it answers | +|---|---| +| `criteria` | Which exact acceptance claims may this evidence support? | +| `run` | What command or human/external procedure produces the observation? | +| `origin` | Why is this check required? | +| `oracle` | What independent fact, fixture, threshold, rubric, or judgment can falsify the claim? | +| `independence` | How separate is the oracle from the implementation that is being judged? | + +The compiler rejects validation without those fields, a validation mapped outside its task's criteria, or an acceptance criterion with no validation procedure. This prevents a broad task-level test from being presented as proof for every claim the task touches. + +## Where validation originates + +Validation is derived before implementation from one or more sources: + +1. **Product intent:** observable outcomes and explicit human decisions. +2. **Existing repository behavior:** public contracts, fixtures, tests, schemas, type checks, builds, and documented invariants. +3. **Risk and failure analysis:** security boundaries, rollback requirements, destructive paths, recovery behavior, and previously observed failure modes. +4. **External contracts:** provider schemas, standards, deployment state, compatibility targets, and downstream consumers. + +gstack and Spec Kit can propose questions, criteria, checks, or review rubrics. Boatstack treats those as generators. Their output becomes authoritative only through the same approval, provenance, oracle, and evidence contract as any other proposal. + +## Validation forms + +| Form | Suitable oracle | Typical evidence | +|---|---|---| +| Static/build | Existing compiler, linter, schema, package build | Exit code and diagnostic output | +| Unit/contract | Approved interface behavior or pre-existing fixture | Test result plus named contract rows | +| Differential/property | Independent implementation, invariant, or generated property | Compared outputs, counterexamples, mutation score | +| Integration/runtime | Real dependency or representative environment | Requests, responses, logs, traces, screenshots | +| Operational | Rollout, rollback, alert, recovery, and migration invariants | Rehearsal output, monitored state, recovery timing | +| Human/product | Approved rubric, reference states, scenarios, named decision owner | Review record, annotated screenshots, acceptance decision | +| External state | Authoritative third-party system or downstream consumer | API query, deployment observation, linked external record | + +Not every criterion should be forced into an automated test. Subjective or externally controlled outcomes can use a named human or external procedure, but the rubric, owner, artifact, and decision must be observable. + +## Ambiguity is a state, not a test result + +An ambiguous phrase cannot be validated by repeating it: + +```text +"fast" -> workload + environment + metric + threshold +"looks good" -> approved states + rubric + reviewer + captured artifact +"safe" -> named invariants + failure cases + rollback/recovery evidence +``` + +`auto-plan` first asks whether the missing information is discoverable from the repository. If not, and different answers materially change the contract, it asks the responsible human. A reversible assumption may be recorded with an expiry trigger. A material unresolved ambiguity remains `BLOCKED` at the plan gate; the implementer cannot declare its own interpretation correct. + +## Independence is graded + +Evidence is not simply independent or circular: + +```text +pre-existing/external oracle + > contract-derived check + > implementation-authored check + > same-agent narrative self-review +``` + +The ordering is a risk signal, not a universal scoring formula. Implementation-authored tests are valuable, but higher-risk changes need another oracle: a pre-existing fixture, independent contract, differential system, mutation/property check, representative runtime, external authority, or named human review. + +## Gate outcomes + +- `PASS`: every required claim has current evidence from its mapped validation contract. +- `FAIL`: an observation contradicts the accepted claim or invariant. +- `BLOCKED`: required evidence, authority, environment, or ambiguity resolution is missing. +- `PASS_WITH_GAPS`: allowed only when repository policy permits it, every gap is explicit and owned, and no gap is critical. + +Skipped checks do not disappear. They remain blocked or become an explicitly accepted gap with impact, owner, and revisit trigger. + +## Why Boatstack uses this structure + +The benchmark program did not show that stronger self-verification language reliably creates truth: + +- verify-before-finish and same-model repair variants washed; +- strict self-checking caused collateral damage against its non-strict base; +- a spec-first development slice improved by 7 points while its frozen test oracle had only about 47% fidelity, and the intervention washed on the full board; +- structured-response repair recovered 56 of 63 malformed responses in the screen and 490 of 567 exposures in the full run, showing that protocol validation can recover useful work without proving task correctness. + +These observations motivate—not mathematically prove—the separation between protocol success, implementation activity, and correctness evidence. See [Research and design](research-and-design.md), [Benchmark corpus audit](benchmark-corpus-audit.md), and [Terminal-Bench 2.1 submission audit](benchmark-submission-audit.md) for scope and evidence boundaries. + +## ZCA translation + +For ordinary work, Boatstack projects the smallest implementation-relevant slice. For something shipped as an SDK, API, CLI, or reusable product, it also requires a representative verifier/consumer slice. Value emerges where the implementation claim meets an oracle capable of disproving it—not from adding ceremony to the implementation itself. diff --git a/examples/diagram-json/README.md b/examples/diagram-json/README.md index 3f26bec..5b3d971 100644 --- a/examples/diagram-json/README.md +++ b/examples/diagram-json/README.md @@ -1,6 +1,6 @@ # Worked example: JSON output for diagrams -This is a worked demonstration of the product engineering loop. The feature is +This is a worked demonstration of the evidence-engineered coding node. The feature is intentionally small and uses code already in this repository: > Add machine-readable JSON output to the diagram printer while preserving the @@ -12,15 +12,25 @@ feature. ## What a developer does -First, install the loop adapters in a repository and open the coding agent's -plan mode. The developer can type the product request in ordinary language: +First, install the Boatstack adapters in a repository and open the coding agent's +Plan mode. The developer types the product request in ordinary language: ```text Add machine-readable JSON output to the diagram printer while preserving the current text output. ``` -Then run `/auto-plan`. In this repository the agent should inspect only: +The host explores that intent without implementing it and saves +[the initial Plan-mode file](source-plan.md). The active host context identifies +that plan, so the normal command needs no path: + +```text +/auto-plan +``` + +If the host does not expose a path, Boatstack checks its bounded plan locations; +an explicit path is only the ambiguity fallback. If the file is absent or empty, +`/auto-plan` is `BLOCKED`. With the file present, the agent should inspect only: - `src/diagram.ts` for the current contract and rendering behavior; - `src/index.ts` for the public export boundary; @@ -30,6 +40,7 @@ Then run `/auto-plan`. In this repository the agent should inspect only: The result is a draft, not code: - [product request](request.md) +- [source Plan-mode file](source-plan.md) - [question and decision ledger](questions.md) - [feature specification](spec.md) - [structured plan](plan.json) @@ -50,11 +61,12 @@ Example Maintainer: Approve this demonstration plan. Only after that explicit answer does `/plan-gate` compile and lock the plan: ```bash -python3 ../../boatstack/scripts/compile_plan.py \ +.product-loop/bin/boatstack-helper compile-plan \ --plan plan.json \ --out-dir compiled -python3 ../../boatstack/scripts/approve_plan.py \ +.product-loop/bin/boatstack-helper approve-plan \ + --source-plan source-plan.md \ --spec spec.md \ --plan plan.json \ --tasks compiled/tasks.json \ @@ -70,10 +82,12 @@ That produces: - [content-addressed plan lock](plan.lock.json) The lock is the deterministic boundary between agreement and implementation. -Editing `spec.md`, `plan.json`, or the compiled task graph makes its check fail. +Editing `source-plan.md`, `spec.md`, `plan.json`, or the compiled task graph +makes its check fail before or during `/build`. ```bash -python3 ../../boatstack/scripts/approve_plan.py \ +.product-loop/bin/boatstack-helper approve-plan \ + --source-plan source-plan.md \ --spec spec.md \ --plan plan.json \ --tasks compiled/tasks.json \ diff --git a/examples/diagram-json/compiled/tasks.json b/examples/diagram-json/compiled/tasks.json index f829f26..9d1b86b 100644 --- a/examples/diagram-json/compiled/tasks.json +++ b/examples/diagram-json/compiled/tasks.json @@ -1,7 +1,9 @@ { "feature_id": "diagram-json-v1", "schema_version": 1, - "source_plan_status": "HUMAN_APPROVED", + "source_plan_path": "source-plan.md", + "source_plan_status": "HASH_LOCKED_INPUT", + "structured_plan_status": "HUMAN_APPROVED", "tasks": [ { "acceptance_criteria": [ @@ -14,8 +16,28 @@ "rollback_boundary": "Revert the serializer and schema types in src/diagram.ts without touching the ASCII renderer.", "title": "Define the v1 schema and pure serializer at the diagram boundary", "validation": [ - "pnpm typecheck", - "pnpm exec tsx examples/05-diagram-printer/json-check.ts" + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "Public schema types required by AC-1, AC-2, and AC-3", + "run": "pnpm typecheck" + }, + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "independence": "contract-derived", + "oracle": "Parser, schema-version, ordering, and compact-overlay assertions derived from the approved contract", + "origin": "The approved JSON contract in AC-1, AC-2, and AC-3", + "run": "pnpm exec tsx examples/05-diagram-printer/json-check.ts" + } ] }, { @@ -29,8 +51,24 @@ "rollback_boundary": "Remove the new src/index.ts exports and JSON documentation together.", "title": "Expose and document the additive public contract", "validation": [ - "pnpm typecheck", - "pnpm build" + { + "criteria": [ + "AC-5" + ], + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type-export requirement in AC-5", + "run": "pnpm typecheck" + }, + { + "criteria": [ + "AC-5" + ], + "independence": "pre-existing", + "oracle": "The repository's existing production build", + "origin": "The package export and documentation contract in AC-5", + "run": "pnpm build" + } ] }, { @@ -49,11 +87,56 @@ "rollback_boundary": "Revert the JSON fixture/check and documentation; retain the pre-feature expected ASCII fixture.", "title": "Add contract fixtures and prove text-renderer compatibility", "validation": [ - "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "pnpm example:diagram", - "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", - "pnpm typecheck", - "pnpm build" + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "independence": "contract-derived", + "oracle": "Contract-derived parser and fixture assertions", + "origin": "The approved JSON behaviors in AC-1, AC-2, and AC-3", + "run": "pnpm exec tsx examples/05-diagram-printer/json-check.ts" + }, + { + "criteria": [ + "AC-4" + ], + "independence": "pre-existing", + "oracle": "The repository's pre-feature executable example", + "origin": "The existing diagram behavior protected by AC-4", + "run": "pnpm example:diagram" + }, + { + "criteria": [ + "AC-4" + ], + "independence": "pre-existing", + "oracle": "The pre-feature expected ASCII fixture", + "origin": "The byte-compatibility decision in AC-4", + "run": "diff -u examples/05-diagram-printer/expected-output.txt \u003c(pnpm --silent example:diagram)" + }, + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-5" + ], + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type contracts in AC-1 through AC-5", + "run": "pnpm typecheck" + }, + { + "criteria": [ + "AC-5" + ], + "independence": "pre-existing", + "oracle": "The repository's existing production build", + "origin": "The distributable package contract in AC-5", + "run": "pnpm build" + } ] } ] diff --git a/examples/diagram-json/compiled/test-matrix.json b/examples/diagram-json/compiled/test-matrix.json index 4817763..25ed2ec 100644 --- a/examples/diagram-json/compiled/test-matrix.json +++ b/examples/diagram-json/compiled/test-matrix.json @@ -13,30 +13,30 @@ "validations": [ { "check": "pnpm typecheck", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "Public schema types required by AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "independence": "contract-derived", + "oracle": "Parser, schema-version, ordering, and compact-overlay assertions derived from the approved contract", + "origin": "The approved JSON contract in AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "task_id": "T-3" - }, - { - "check": "pnpm example:diagram", - "task_id": "T-3" - }, - { - "check": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", + "independence": "contract-derived", + "oracle": "Contract-derived parser and fixture assertions", + "origin": "The approved JSON behaviors in AC-1, AC-2, and AC-3", "task_id": "T-3" }, { "check": "pnpm typecheck", - "task_id": "T-3" - }, - { - "check": "pnpm build", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type contracts in AC-1 through AC-5", "task_id": "T-3" } ] @@ -53,30 +53,30 @@ "validations": [ { "check": "pnpm typecheck", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "Public schema types required by AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "independence": "contract-derived", + "oracle": "Parser, schema-version, ordering, and compact-overlay assertions derived from the approved contract", + "origin": "The approved JSON contract in AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "task_id": "T-3" - }, - { - "check": "pnpm example:diagram", - "task_id": "T-3" - }, - { - "check": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", + "independence": "contract-derived", + "oracle": "Contract-derived parser and fixture assertions", + "origin": "The approved JSON behaviors in AC-1, AC-2, and AC-3", "task_id": "T-3" }, { "check": "pnpm typecheck", - "task_id": "T-3" - }, - { - "check": "pnpm build", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type contracts in AC-1 through AC-5", "task_id": "T-3" } ] @@ -93,30 +93,30 @@ "validations": [ { "check": "pnpm typecheck", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "Public schema types required by AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "independence": "contract-derived", + "oracle": "Parser, schema-version, ordering, and compact-overlay assertions derived from the approved contract", + "origin": "The approved JSON contract in AC-1, AC-2, and AC-3", "task_id": "T-1" }, { "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "task_id": "T-3" - }, - { - "check": "pnpm example:diagram", - "task_id": "T-3" - }, - { - "check": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", + "independence": "contract-derived", + "oracle": "Contract-derived parser and fixture assertions", + "origin": "The approved JSON behaviors in AC-1, AC-2, and AC-3", "task_id": "T-3" }, { "check": "pnpm typecheck", - "task_id": "T-3" - }, - { - "check": "pnpm build", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type contracts in AC-1 through AC-5", "task_id": "T-3" } ] @@ -130,24 +130,18 @@ "T-3" ], "validations": [ - { - "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "task_id": "T-3" - }, { "check": "pnpm example:diagram", + "independence": "pre-existing", + "oracle": "The repository's pre-feature executable example", + "origin": "The existing diagram behavior protected by AC-4", "task_id": "T-3" }, { - "check": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", - "task_id": "T-3" - }, - { - "check": "pnpm typecheck", - "task_id": "T-3" - }, - { - "check": "pnpm build", + "check": "diff -u examples/05-diagram-printer/expected-output.txt \u003c(pnpm --silent example:diagram)", + "independence": "pre-existing", + "oracle": "The pre-feature expected ASCII fixture", + "origin": "The byte-compatibility decision in AC-4", "task_id": "T-3" } ] @@ -164,30 +158,30 @@ "validations": [ { "check": "pnpm typecheck", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type-export requirement in AC-5", "task_id": "T-2" }, { "check": "pnpm build", + "independence": "pre-existing", + "oracle": "The repository's existing production build", + "origin": "The package export and documentation contract in AC-5", "task_id": "T-2" }, - { - "check": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "task_id": "T-3" - }, - { - "check": "pnpm example:diagram", - "task_id": "T-3" - }, - { - "check": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", - "task_id": "T-3" - }, { "check": "pnpm typecheck", + "independence": "pre-existing", + "oracle": "The repository's existing TypeScript compiler configuration", + "origin": "The public type contracts in AC-1 through AC-5", "task_id": "T-3" }, { "check": "pnpm build", + "independence": "pre-existing", + "oracle": "The repository's existing production build", + "origin": "The distributable package contract in AC-5", "task_id": "T-3" } ] diff --git a/examples/diagram-json/plan.json b/examples/diagram-json/plan.json index 3ccb58c..352ccbd 100644 --- a/examples/diagram-json/plan.json +++ b/examples/diagram-json/plan.json @@ -1,6 +1,7 @@ { "schema_version": 1, "feature_id": "diagram-json-v1", + "source_plan_path": "source-plan.md", "spec_path": "examples/diagram-json/spec.md", "acceptance_criteria": [ { @@ -35,8 +36,28 @@ "AC-3" ], "validation": [ - "pnpm typecheck", - "pnpm exec tsx examples/05-diagram-printer/json-check.ts" + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "run": "pnpm typecheck", + "origin": "Public schema types required by AC-1, AC-2, and AC-3", + "oracle": "The repository's existing TypeScript compiler configuration", + "independence": "pre-existing" + }, + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "run": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "origin": "The approved JSON contract in AC-1, AC-2, and AC-3", + "oracle": "Parser, schema-version, ordering, and compact-overlay assertions derived from the approved contract", + "independence": "contract-derived" + } ], "rollback_boundary": "Revert the serializer and schema types in src/diagram.ts without touching the ASCII renderer." }, @@ -50,8 +71,24 @@ "AC-5" ], "validation": [ - "pnpm typecheck", - "pnpm build" + { + "criteria": [ + "AC-5" + ], + "run": "pnpm typecheck", + "origin": "The public type-export requirement in AC-5", + "oracle": "The repository's existing TypeScript compiler configuration", + "independence": "pre-existing" + }, + { + "criteria": [ + "AC-5" + ], + "run": "pnpm build", + "origin": "The package export and documentation contract in AC-5", + "oracle": "The repository's existing production build", + "independence": "pre-existing" + } ], "rollback_boundary": "Remove the new src/index.ts exports and JSON documentation together." }, @@ -70,11 +107,56 @@ "AC-5" ], "validation": [ - "pnpm exec tsx examples/05-diagram-printer/json-check.ts", - "pnpm example:diagram", - "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", - "pnpm typecheck", - "pnpm build" + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3" + ], + "run": "pnpm exec tsx examples/05-diagram-printer/json-check.ts", + "origin": "The approved JSON behaviors in AC-1, AC-2, and AC-3", + "oracle": "Contract-derived parser and fixture assertions", + "independence": "contract-derived" + }, + { + "criteria": [ + "AC-4" + ], + "run": "pnpm example:diagram", + "origin": "The existing diagram behavior protected by AC-4", + "oracle": "The repository's pre-feature executable example", + "independence": "pre-existing" + }, + { + "criteria": [ + "AC-4" + ], + "run": "diff -u examples/05-diagram-printer/expected-output.txt <(pnpm --silent example:diagram)", + "origin": "The byte-compatibility decision in AC-4", + "oracle": "The pre-feature expected ASCII fixture", + "independence": "pre-existing" + }, + { + "criteria": [ + "AC-1", + "AC-2", + "AC-3", + "AC-5" + ], + "run": "pnpm typecheck", + "origin": "The public type contracts in AC-1 through AC-5", + "oracle": "The repository's existing TypeScript compiler configuration", + "independence": "pre-existing" + }, + { + "criteria": [ + "AC-5" + ], + "run": "pnpm build", + "origin": "The distributable package contract in AC-5", + "oracle": "The repository's existing production build", + "independence": "pre-existing" + } ], "rollback_boundary": "Revert the JSON fixture/check and documentation; retain the pre-feature expected ASCII fixture." } diff --git a/examples/diagram-json/plan.lock.json b/examples/diagram-json/plan.lock.json index 5589593..9c9c52d 100644 --- a/examples/diagram-json/plan.lock.json +++ b/examples/diagram-json/plan.lock.json @@ -4,12 +4,14 @@ "invalidated_at": null, "invalidation_reason": null, "plan_path": "examples/diagram-json/plan.json", - "plan_sha256": "d1208003042a9d10f5efb010fc32fc7ac7bdefa427938260586e90daa0cb4414", + "plan_sha256": "df1b205517cf7dbdf5c5db65a342622922bd959a5ba885326888f6dd2b9c50d3", "schema_version": 1, - "source_commit": "fcc2faa55ac3c2332ce4a19293d89023f578784d", + "source_commit": "40ebae5dfb3d090812301a438aaac079426edcfc", + "source_plan_path": "source-plan.md", + "source_plan_sha256": "e10593ddaa7522ab80cc991d0a09399257139799e37f737794cd49d68a39985b", "spec_path": "examples/diagram-json/spec.md", "spec_sha256": "a943c81cf2a88d23d5b300e6b9dc1dafc80923a9b6b9ab5297a67b4e2054b9d5", "status": "APPROVED", "task_graph_path": "examples/diagram-json/compiled/tasks.json", - "task_graph_sha256": "d66d693df1ba7dd34f65ce93afea54006563c14d642a1bf0d1d9311b3fcfb37b" + "task_graph_sha256": "f040696f1f8bcedc4a8ed9816a61a49edbda970ec0cc3b28175ba37b73bbc896" } diff --git a/examples/diagram-json/source-plan.md b/examples/diagram-json/source-plan.md new file mode 100644 index 0000000..60ecadd --- /dev/null +++ b/examples/diagram-json/source-plan.md @@ -0,0 +1,22 @@ +# Source plan from host Plan mode + +## Intent + +Add machine-readable JSON output to the diagram printer without changing the +existing text output. + +## Initial approach + +- Inspect the current diagram representation, printer, and public exports. +- Prefer an additive serializer over changing the text printer's contract. +- Define an explicit versioned JSON shape rather than exposing internal objects. +- Add contract checks for parseability, determinism, public exports, and existing + ASCII compatibility. + +## Unknowns for Boatstack to resolve + +- Whether the serializer is a sibling public API or a printer mode. +- Which schema stability guarantee is appropriate. +- How much optional run data belongs in the public JSON document. + +This is an exploratory plan, not approval to implement. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..53c137d --- /dev/null +++ b/install.ps1 @@ -0,0 +1,53 @@ +# Generated from operatorstack/intelligence-flow. +$ErrorActionPreference = "Stop" + +$repository = "operatorstack/boatstack" +$version = if ($env:BOATSTACK_VERSION) { $env:BOATSTACK_VERSION } else { "latest" } +$targetRepo = if ($env:BOATSTACK_REPO) { $env:BOATSTACK_REPO } else { (Get-Location).Path } + +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + throw "BLOCKED: Git is required because Boatstack operates on reviewable repository state" +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +$arch = switch ($architecture) { + "x64" { "amd64" } + "arm64" { "arm64" } + default { throw "BLOCKED: unsupported Windows architecture: $architecture" } +} + +$asset = "boatstack-helper_windows_${arch}.exe" +$base = if ($version -eq "latest") { + "https://github.com/$repository/releases/latest/download" +} else { + "https://github.com/$repository/releases/download/$version" +} + +$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("boatstack-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $temporary | Out-Null +try { + $binary = Join-Path $temporary $asset + $checksum = "$binary.sha256" + Write-Host "Downloading verified Boatstack helper for windows/$arch..." + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset" -OutFile $binary + Invoke-WebRequest -UseBasicParsing -Uri "$base/$asset.sha256" -OutFile $checksum + $expected = ((Get-Content -Raw $checksum).Trim() -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 $binary).Hash.ToLowerInvariant() + if ($expected -ne $actual) { + throw "BLOCKED: Boatstack binary checksum mismatch" + } + + $arguments = @("init", "--repo", $targetRepo, "--binary", $binary) + if ($env:BOATSTACK_INTEGRATIONS) { + $arguments += @("--integrations", $env:BOATSTACK_INTEGRATIONS) + } + if ($env:BOATSTACK_YES -eq "1") { + $arguments += "--yes" + } + & $binary @arguments + if ($LASTEXITCODE -ne 0) { + throw "Boatstack initialization failed with exit code $LASTEXITCODE" + } +} finally { + Remove-Item -Recurse -Force $temporary -ErrorAction SilentlyContinue +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ab5ccff --- /dev/null +++ b/install.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Generated from operatorstack/intelligence-flow. +set -euo pipefail + +repository="operatorstack/boatstack" +version="${BOATSTACK_VERSION:-latest}" +target_repo="${BOATSTACK_REPO:-$PWD}" + +case "$(uname -s)" in + Darwin) os_name="darwin" ;; + Linux) os_name="linux" ;; + MINGW*|MSYS*|CYGWIN*) os_name="windows" ;; + *) echo "BLOCKED: unsupported operating system: $(uname -s)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "BLOCKED: unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +command -v curl >/dev/null 2>&1 || { echo "BLOCKED: curl is required to download Boatstack" >&2; exit 1; } +command -v git >/dev/null 2>&1 || { echo "BLOCKED: Git is required because Boatstack operates on reviewable repository state" >&2; exit 1; } + +extension="" +[ "$os_name" = "windows" ] && extension=".exe" +asset="boatstack-helper_${os_name}_${arch}${extension}" +if [ "$version" = "latest" ]; then + base="https://github.com/${repository}/releases/latest/download" +else + base="https://github.com/${repository}/releases/download/${version}" +fi + +temporary="$(mktemp -d 2>/dev/null || mktemp -d -t boatstack)" +trap 'rm -rf "$temporary"' EXIT +binary="$temporary/$asset" +checksum="$temporary/$asset.sha256" + +echo "Downloading verified Boatstack helper for ${os_name}/${arch}..." +curl -fsSL "$base/$asset" -o "$binary" +curl -fsSL "$base/$asset.sha256" -o "$checksum" +expected="$(awk '{print $1}' "$checksum")" +if command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$binary" | awk '{print $1}')" +elif command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$binary" | awk '{print $1}')" +else + echo "BLOCKED: shasum or sha256sum is required to verify the Boatstack binary" >&2 + exit 1 +fi +[ "$expected" = "$actual" ] || { echo "BLOCKED: Boatstack binary checksum mismatch" >&2; exit 1; } +chmod +x "$binary" + +arguments=(init --repo "$target_repo" --binary "$binary") +if [ -n "${BOATSTACK_INTEGRATIONS:-}" ]; then + arguments+=(--integrations "$BOATSTACK_INTEGRATIONS") +fi +if [ "${BOATSTACK_YES:-0}" = "1" ]; then + arguments+=(--yes) +fi + +exec "$binary" "${arguments[@]}" diff --git a/project.example.json b/project.example.json index 3252080..b59db1d 100644 --- a/project.example.json +++ b/project.example.json @@ -26,5 +26,15 @@ "independent_review_for_high_risk": true, "allow_pass_with_gaps": true }, + "integrations": { + "gstack": { + "requested": false, + "version": "a3259400a366593e0c909dd9ac3e59752efd2488" + }, + "spec-kit": { + "requested": false, + "version": "v0.12.16" + } + }, "adapters": ["cursor", "claude", "codex", "github"] } diff --git a/tests/test_boatstack.py b/tests/test_boatstack.py deleted file mode 100644 index 7e190d3..0000000 --- a/tests/test_boatstack.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -SKILL = ROOT / "boatstack" -EXPORTER = SKILL / "scripts" / "export_repo.py" -COMPILER = SKILL / "scripts" / "compile_plan.py" -APPROVER = SKILL / "scripts" / "approve_plan.py" -CONFIG = ROOT / "project.example.json" - - -class BoatstackDistributionTests(unittest.TestCase): - def run_script(self, *args: object, expected: int = 0) -> subprocess.CompletedProcess[str]: - result = subprocess.run( - [sys.executable, *map(str, args)], text=True, capture_output=True - ) - self.assertEqual(result.returncode, expected, result.stdout + result.stderr) - return result - - def test_branded_multi_host_export_and_drift_check(self) -> None: - with tempfile.TemporaryDirectory() as temp: - repo = Path(temp) - arguments = [ - EXPORTER, - "--repo", repo, - "--config", CONFIG, - "--adapter-name", "boatstack", - ] - self.run_script(*arguments, "--write") - result = self.run_script(*arguments, "--check") - self.assertIn("PASS", result.stdout) - self.assertTrue((repo / ".cursor/commands/plan-gate.md").is_file()) - self.assertTrue((repo / ".agents/skills/boatstack/SKILL.md").is_file()) - self.assertTrue((repo / ".claude/skills/boatstack/SKILL.md").is_file()) - - def test_compiler_and_hash_lock_block_stale_plan(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - spec = root / "spec.md" - plan = root / "plan.json" - compiled = root / "compiled" - lock = root / "plan.lock.json" - spec.write_text("# Accepted spec\n") - plan.write_text(json.dumps({ - "schema_version": 1, - "feature_id": "feature-one", - "acceptance_criteria": [{"id": "AC-1", "text": "observable result"}], - "tasks": [{ - "id": "T-1", - "title": "implement result", - "depends_on": [], - "acceptance_criteria": ["AC-1"], - "validation": ["python3 -m unittest"], - }], - })) - self.run_script(COMPILER, "--plan", plan, "--out-dir", compiled) - tasks = compiled / "tasks.json" - self.run_script( - APPROVER, - "--spec", spec, - "--plan", plan, - "--tasks", tasks, - "--approved-by", "Test Human", - "--approved-at", "2026-07-16T12:00:00+00:00", - "--source-commit", "test", - "--output", lock, - ) - self.run_script( - APPROVER, - "--spec", spec, - "--plan", plan, - "--tasks", tasks, - "--output", lock, - "--check", - ) - plan.write_text(plan.read_text() + "\n") - blocked = self.run_script( - APPROVER, - "--spec", spec, - "--plan", plan, - "--tasks", tasks, - "--output", lock, - "--check", - expected=1, - ) - self.assertIn("stale", blocked.stdout) - - def test_uncovered_acceptance_criterion_is_not_compiled(self) -> None: - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - plan = root / "plan.json" - plan.write_text(json.dumps({ - "schema_version": 1, - "feature_id": "invalid", - "acceptance_criteria": [ - {"id": "AC-1", "text": "covered"}, - {"id": "AC-2", "text": "not covered"}, - ], - "tasks": [{ - "id": "T-1", - "depends_on": [], - "acceptance_criteria": ["AC-1"], - "validation": ["python3 -m unittest"], - }], - })) - result = self.run_script( - COMPILER, "--plan", plan, "--out-dir", root / "compiled", expected=1 - ) - self.assertIn("uncovered acceptance criteria", result.stdout) - - -if __name__ == "__main__": - unittest.main()