diff --git a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md index d8ca0271b..296ab2d2a 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/CONFIGURATION.md @@ -1,206 +1,87 @@ -# Configure Boatstack +# Configure Boatstack outcomes -Boatstack keeps delivery policy in `.boatstack-project.json` so the same project rules apply when the coding agent, model, session, or worktree changes. Start with the outcome you want, then set only the policies your repository needs. +Boatstack's installer owns the complete `.boatstack-project.json` shape. Edit only the controls below, then regenerate the export and review the infrastructure diff. Fields not listed here are identity, compatibility, or installer state rather than product policy. ## Choose the outcome -| If you want to… | Configure… | What changes | +| Outcome | Control | Enforcement | |---|---|---| -| Run the right project checks | `project.commands` | Boatstack uses repository-owned commands instead of inventing validation. `test` is required. | -| Give planning durable project context | `project.context` | Planning can find the named documents and directories without scanning the whole repository. | -| Treat selected files as higher risk | `project.high_risk_paths` and `workflow.independent_review_for_high_risk` | Changes matching those globs require the configured independent review boundary. | -| Require a person to approve plans | `workflow.human_plan_approval` | Build waits for an explicit approval receipt. | -| Allow a gate to pass with recorded gaps | `workflow.allow_pass_with_gaps` | A gate may report a pass with visible, retained gaps instead of requiring a gap-free result. | -| Keep reader-facing release history | `workflow.maintain_changelog` | Every managed delivery slice and Boatstack-prepared ad-hoc PR must update `CHANGELOG.md`. | -| Look for a missing systemic boundary | `workflow.boundary_analysis` | Planning checks whether the request is a local symptom and asks before expanding it into boundary work. | -| Attach fresh screenshots to frontend PRs | `workflow.pr_visual_evidence` | Boatstack structures visual review evidence without adding media or frontend tooling to Git. | -| Start features in fresh Git workspaces | `workspace` | Boatstack can create a branch or linked worktree and manage local cleanup under the selected policy. | -| Limit generated host adapters | `adapters` | Only the named Cursor, Claude Code, Codex, Gemini CLI, or GitHub surfaces are exported. | -| Add supported specialist workflows | `integrations` | The installer records whether gstack or Spec Kit was requested and its installed state. | - -Changing configuration is an infrastructure change. Regenerate the Boatstack export and review the resulting diff through the repository's normal change process. - -## Complete example - -JSON does not support comments, so the explanations follow the example. +| Use the correct base branch | `project.default_branch` | Boatstack uses it for freshness, PR, update, and workspace boundaries. | +| Give planning bounded durable context | `project.context` | The coding agent consults these paths when relevant; Boatstack does not load all of them automatically. | +| Advertise repository-owned checks | `project.commands` | The coding agent receives these commands. `test` is required by configuration validation. | +| Mark sensitive paths | `project.high_risk_paths` | Matching changed paths participate in safety and PR-risk classification. | +| Require human plan authorization | `workflow.human_plan_approval` | `true` requires a current fingerprinted human receipt; `false` creates a fingerprinted policy-activation lock without claiming human approval. | +| Require independent high-risk review | `workflow.independent_review_for_high_risk` | Matching diffs require a typed review receipt naming the reviewer and `human_peer` or `separate_agent` method. | +| Permit visible verification gaps | `workflow.allow_pass_with_gaps` | `false` rejects `PASS_WITH_GAPS` at delivery and PR gates; `true` retains the gaps as evidence. | +| Maintain reader-facing history | `workflow.maintain_changelog` | Managed delivery and Boatstack-prepared PRs require a categorized `CHANGELOG.md` entry. | +| Check for a systemic boundary | `workflow.boundary_analysis` | Planning guidance asks whether the request is a local symptom before scope expands. | +| Add frontend PR screenshots | `workflow.pr_visual_evidence` | `suggest` exposes missing screenshots as a gap; `require` blocks completed publication. | +| Use fresh feature workspaces | `workspace.*` | Boatstack creates and cleans branches or linked worktrees under the selected policy. | +| Limit generated host surfaces | `adapters` | Export generates only the selected supported adapters. | + +The distinction in the Enforcement column matters: context, commands, and boundary analysis guide the coding agent; approval, gap, review, changelog, workspace, adapter, and visual-evidence policies also have deterministic Boatstack checks. + +## Project controls ```json { - "schema_version": 1, "project": { - "name": "example-product", "default_branch": "main", - "context": [ - "README.md", - "AGENTS.md", - "docs/architecture/", - "docs/decisions/" - ], + "context": ["README.md", "AGENTS.md", "docs/decisions/"], "commands": { - "build": "npm run build", - "lint": "npm run lint", "test": "npm test", + "lint": "npm run lint", "typecheck": "npm run typecheck" }, - "high_risk_paths": [ - "migrations/**", - "auth/**", - "billing/**" - ] - }, - "workflow": { - "human_plan_approval": true, - "independent_review_for_high_risk": true, - "allow_pass_with_gaps": true, - "maintain_changelog": false, - "boundary_analysis": false, - "pr_visual_evidence": "off" - }, - "workspace": { - "enabled": true, - "mode": "worktree", - "cleanup": "confirm", - "cleanup_after": "merge" - }, - "adapters": ["cursor", "claude", "codex", "gemini", "github"], - "integrations": { - "gstack": { - "requested": false, - "version": "" - }, - "spec-kit": { - "requested": false, - "version": "" - } + "high_risk_paths": ["migrations/**", "auth/**", "billing/**"] } } ``` -Use the versions written by the installer; the placeholders above describe ownership and are not literal version values to copy. - -## Field reference - -### Root fields - -| Field | Required | Values and default | Effect | -|---|---:|---|---| -| `schema_version` | Yes | Integer; currently `1` | Selects the configuration contract. A newer value requires a newer Boatstack; an older supported value is migrated during update. | -| `project` | Yes | Object | Names the project and supplies repository context and commands. | -| `workflow` | Yes | Object; booleans use `false` when omitted | Controls approval, review, gap, changelog, and boundary-analysis behavior. | -| `workspace` | No | Object; disabled when absent | Controls optional per-feature branch or worktree management. | -| `adapters` | No | Array of supported adapter names; empty or absent enables all supported adapters | Selects generated host surfaces. Duplicate and blank entries are removed during export. | -| `integrations` | No | Object keyed by supported integration name | Records requested specialist integrations and installer-maintained state. | - -### `project` - -| Field | Required | Values and default | Effect | -|---|---:|---|---| -| `name` | Yes | Non-empty string | Human-readable project name used in generated configuration. | -| `default_branch` | No | Branch name; PR operations fall back to `origin/HEAD`, then `main` | Sets the canonical base branch for freshness checks, PRs, updates, and managed workspace cuts. Boatstack updates require it to be explicit. | -| `context` | No | Array of repository-relative file or directory paths; empty by default | Identifies durable context that planning should consult when relevant. | -| `commands` | Yes | Object of command-name to shell-command strings | Declares repository-owned validation commands. | -| `commands.test` | Yes | Non-empty command string | Supplies the minimum test boundary; configuration validation fails if it is absent or blank. | -| Other `commands.*` entries | No | Command strings such as `build`, `lint`, or `typecheck` | Make additional project checks available under their chosen names. Only `test` has a required name. | -| `high_risk_paths` | No | Array of Git-style glob patterns; empty by default | Marks paths for safety scanning and, when enabled, independent high-risk review. | - -Context paths guide bounded discovery; they are not a request to load every listed file for every feature. Commands run from the repository and should be deterministic enough to act as evidence. - -### `workflow` - -The defaults below describe an omitted JSON field. A fresh installer-generated configuration writes its recommended policies explicitly, including human approval, independent high-risk review, and pass-with-gaps behavior, so review the actual file rather than assuming omission. - -| Field | Default | Effect | -|---|---:|---| -| `human_plan_approval` | `false` | When `true`, requires explicit human plan approval before Build can activate the plan. | -| `independent_review_for_high_risk` | `false` | When `true`, changes matching `project.high_risk_paths` require the independent review boundary before shipping. Configure both fields for this policy to have a target. | -| `allow_pass_with_gaps` | `false` | When `true`, verification may pass with explicitly recorded outstanding gaps. It does not hide or discard them. | -| `maintain_changelog` | `false` | When `true`, requires a reader-visible `CHANGELOG.md` entry for every managed delivery slice and Boatstack-prepared ad-hoc PR. | -| `boundary_analysis` | `false` | When `true`, planning checks whether a request indicates a missing systemic boundary. Scope expansion remains a material human decision; choosing programmatic enforcement produces a boundary slice followed by the feature slice. | -| `pr_visual_evidence` | `off` | `suggest` records missing relevant screenshots as a visible PR gap; `require` blocks completed PR publication until current screenshot evidence is available. Media remains machine-local until PR attachment. | - -### `workspace` - -Workspace management is off unless `workspace.enabled` is `true`. Empty policy fields receive defaults only after it is enabled. - -| Field | Values and default | Effect | -|---|---|---| -| `enabled` | Boolean; `false` | Master switch. When `false`, Boatstack does not create or remove branches or worktrees. | -| `mode` | `worktree` (default) or `branch` | Creates a linked worktree or switches to a fresh in-place feature branch. | -| `cleanup` | `confirm` (default), `auto`, or `off` | Asks before eligible cleanup, performs it automatically, or disables managed cleanup. | -| `cleanup_after` | `merge` (default) or `ship` | Makes cleanup eligible after the PR is confirmed merged or after the feature is published. Safety checks still prevent discarding uncommitted or unmerged local work without an explicit operator override. | - -Managed workspaces are cut from the current remote default branch. Boatstack does not rewrite history, reuse an existing branch, delete remote branches, merge pull requests, or silently discard local work. - -### `adapters` - -Supported values are `cursor`, `claude`, `codex`, `gemini`, and `github`. An empty or omitted array enables all five. Use a subset only when the repository intentionally does not support the other host surfaces. - -### `integrations` - -Supported keys are `gstack` and `spec-kit`. Installation normally owns this object; prefer selecting integrations through the installer instead of hand-editing its result. - -| Field | Ownership | Effect | -|---|---|---| -| `requested` | User choice recorded by installer | Whether the integration was requested. | -| `status` | Installer-maintained, optional | Current installation result, such as installed or partial. | -| `version` | Installer-maintained, optional | Pinned integration version or revision. | -| `detail` | Installer-maintained, optional | Human-readable installation or diagnostic detail. | +`context` is a bounded discovery hint, not a request to scan every path. Command names other than `test` are optional and become available to the coding agent under their chosen names. -## Common policies - -### Require a repository changelog +## Workflow controls ```json { "workflow": { - "maintain_changelog": true + "human_plan_approval": true, + "independent_review_for_high_risk": true, + "allow_pass_with_gaps": false } } ``` -Add a categorized entry under `CHANGELOG.md`'s current `Unreleased` heading. See [the format and first-entry example](getting-started.md#keep-a-repository-changelog). - -### Analyze systemic boundaries during planning +When human approval is disabled, Boatstack still locks the exact plan and inputs using `authorization_mode: policy`. For high-risk review, the review gate records reviewer provenance; this is an auditable claim, not cryptographic identity proof. ```json { "workflow": { + "maintain_changelog": true, "boundary_analysis": true } } ``` -This adds a product decision when repository evidence suggests that a local request is a symptom of a broader missing boundary. It does not silently turn every feature into a refactor. - -### Add screenshots to relevant pull requests +Changelog enforcement is mechanical. Boundary analysis is model-mediated planning guidance and cannot silently expand approved scope. ```json { @@ -210,24 +91,9 @@ This adds a product decision when repository evidence suggests that a local requ } ``` -`suggest` keeps delivery nonblocking when capture or attachment is unavailable and exposes the missing evidence as a PR gap. Use `require` only when every relevant frontend PR must publish current screenshots. Boatstack stores PNG bytes in Git-common machine state, never in the product tree. - -### Require independent review for high-risk paths - -```json -{ - "project": { - "high_risk_paths": ["migrations/**", "auth/**", "billing/**"] - }, - "workflow": { - "independent_review_for_high_risk": true - } -} -``` - -Choose paths where a distinct reviewer is meaningful. Broad patterns increase review cost and should reflect actual repository risk boundaries. +Visual-evidence values are `off`, `suggest`, and `require`. Screenshot bytes stay outside Git history until explicitly attached to the PR. -### Manage a fresh worktree for each feature +## Workspace and adapter controls ```json { @@ -236,8 +102,15 @@ Choose paths where a distinct reviewer is meaningful. Broad patterns increase re "mode": "worktree", "cleanup": "confirm", "cleanup_after": "merge" - } + }, + "adapters": ["cursor", "claude", "codex", "github"] } ``` -This is the conservative managed-workspace policy: start from a fresh remote base, use a linked worktree, and ask before reclaiming local state after merge. +Workspace `mode` is `worktree` or `branch`; cleanup is `confirm`, `auto`, or `off`; and cleanup eligibility begins after `merge` or `ship`. Supported adapters are `cursor`, `claude`, `codex`, `gemini`, and `github`. Empty or omitted adapters enable all supported surfaces. + +## Installer-owned fields + +The installer maintains `schema_version`, `project.name`, and integration records. Select gstack or Spec Kit through installation and update flows. Their `requested`, `status`, `version`, and `detail` values are receipts and provenance, not hand-edited workflow switches. + +For serialization, defaults, migration, and installer compatibility details, see the generated internal configuration schema in `.product-loop/config-schema.md`. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md b/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md index a47b471e9..f34e6660a 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/TROUBLESHOOTING.md @@ -79,16 +79,26 @@ Planning is Markdown-only. The adapter may use Boatstack's bounded planning writ ## `/build` says it is ready but cannot start -The plan is approved, but the host remains read-only. Enter the host's normal execution-capable mode and rerun `/build`. Boatstack deliberately creates no compiled state or lock before that transition. +The plan is authorized, but the host remains read-only. Enter the host's normal execution-capable mode and rerun `/build`. Boatstack deliberately creates no compiled state or lock before that transition. ## Approval is stale The source plan, feature spec, or complete plan changed after approval. Return to `/auto-plan`, review the new plan at `/plan-gate`, and approve it again. Never edit approval metadata manually. +## Build does not create `approval.md` + +Check `workflow.human_plan_approval`. When it is `false`, this is expected: activation writes a fingerprinted schema-v2 plan lock with `authorization_mode: policy` and does not claim human approval. + ## A gate passes with gaps The proven criteria passed while named non-critical gaps remain. Each gap needs an impact, owner, reason, affected criteria, and revisit trigger. A critical correctness, safety, or acceptance gap blocks instead. +If `workflow.allow_pass_with_gaps` is `false`, resolve the gaps and record `PASS`; changing evidence text alone cannot bypass the controller. + +## High-risk review requires reviewer provenance + +The current diff matches `project.high_risk_paths` and independent review is enabled. Rerun review with a real `--reviewer-identity` and `--review-method human_peer` or `separate_agent`. Boatstack retains these fields in the review receipt. + ## An unrelated base-branch check fails Reproduce the failure against the target branch. Keep its repair in a separate PR. Use a bypass only when repository policy permits it and a human explicitly authorizes it; do not hide unrelated edits in the approved feature. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-enforce-public-configuration-controls.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-enforce-public-configuration-controls.md new file mode 100644 index 000000000..98a45f309 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-07-22-enforce-public-configuration-controls.md @@ -0,0 +1,5 @@ +### Make public configuration controls trustworthy + +Boatstack now enforces the three workflow switches that were previously serialized and documented without controlling delivery. Human approval can be replaced by an explicit fingerprinted policy-activation lock, `PASS_WITH_GAPS` is rejected unless allowed, and configured high-risk reviews retain typed reviewer provenance. + +The public guide now presents only behavior-backed user controls. The exhaustive internal schema still documents compatibility and installer-owned fields without suggesting that integration status, versions, or project identity are hand-edited workflow policy. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md index bf8802ed7..b6e202bca 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -124,27 +124,28 @@ Treat repository-owned product context as canonical. Do not require it to be mig ``` 2. Present the draft spec, plan, open decisions, accepted assumptions, gaps, risks, validation provenance, and `PLAN_FINGERPRINT` in a reviewable form. -3. Ask the developer to approve it or request changes. End the pending response with exactly this Markdown: Reply `a` to approve. Silence, continued conversation, tool permission, permission to build, `[a]`, and an `a` embedded in other text are not approval. +3. When `workflow.human_plan_approval` is true, ask the developer to approve it or request changes and end with: Reply `a` to approve. When false, state that Build will create a policy-activation lock and do not imply human approval. 4. On changes, return to `auto-plan`, preserve the feedback in the question ledger, and issue a new draft. -5. On explicit approval, invoke `boatstack-helper record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint returned before approval. It verifies the current plan and creates only `approval.md`. -6. End in Plan mode and tell the developer the feature is approved and ready for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code. +5. When human approval is enabled, invoke `boatstack-helper record-approval` with the plan, named human, RFC3339 timestamp, and exact fingerprint. When disabled, create no `approval.md`. +6. End in Plan mode and tell the developer the feature is authorized for the host's normal Build transition. Do not compile tasks, create a lock, request Agent mode merely to write a file, or edit product code. All files created or updated by `auto-plan` and `plan-gate` must be Markdown. gstack and Spec Kit may help produce those documents, but their implementation stages and non-Markdown executable state are deferred to `build`. ## Build without erasing evidence - First confirm the host is in an execution-capable mode. If a requested transition is rejected or product-code writes remain unavailable, return `READY_FOR_BUILD` and stop without activating, compiling, or writing a lock. -- Before the first product-code edit, activate the exact approved Markdown plan: +- Before the first product-code edit, activate the exact authorized Markdown plan. Include `--approval` only when `workflow.human_plan_approval` is true: ```bash .product-loop/bin/boatstack-helper activate-plan \ --plan .product-loop/features//plan.md \ - --approval .product-loop/features//approval.md \ --out-dir .product-loop/features//compiled \ --output .product-loop/features//plan.lock.json ``` -- Activation verifies the approval fingerprint, compiles `tasks.json`, `test-matrix.json`, and the evidence skeleton, writes the content-addressed lock last, and rechecks it. It adds no semantics. Missing approval, open blocking questions, or any change to the source plan, spec, or complete `plan.md` returns `BLOCKED`. +For human authorization, add `--approval .product-loop/features//approval.md`. + +- Activation verifies the plan fingerprint and any required approval, compiles `tasks.json`, `test-matrix.json`, and the evidence skeleton, then writes a schema-v2 lock with `authorization_mode: human` or `policy`. Missing required approval, open blocking questions, or any changed input returns `BLOCKED`. - Activation also creates ignored delivery state bound to the plan lock. Read it with `delivery-status`; implement only the active slice's `task_ids`. A multi-slice plan advances only after the current slice publishes through `ship-gate`. - Keep the source plan present and hash-current through completion of `build`. - Choose any suitable model, tool, or implementation tactic inside the approved boundary. Boatstack controls transitions and claims, not local creativity. @@ -180,14 +181,14 @@ A published delivery is immutable. Record the observation against it, then plan - For relevant PR visual scenarios, use the repository runner first, then a host browser against the existing development server, one supplied launch instruction, or an explicitly approved machine-local runtime. Do not modify repository dependencies or configuration for capture. Review each exact PNG for secrets and private data, then import the temporary manifest with `record-pr-visual-evidence`; keep the images outside the repository. - Treat model-authored tests and same-model self-review as evidence, not ground truth. - Validate that tests load and exercise the intended interface. For high-risk code, add an independent oracle such as contract fixtures, mutation testing, differential checks, staging verification, or human acceptance. -- A failing check blocks the gate. A skipped check must include a reason and risk owner. +- A failing check blocks the gate. A skipped check must include a reason and risk owner. `PASS_WITH_GAPS` is accepted only when `workflow.allow_pass_with_gaps` is true. - Commit the intentional active-slice product and evidence diff, then record the test result with `record-delivery-gate --feature --slice --gate test`. The receipt is bound to the base/head branches, commit, product diff, and evidence hash. Editing an evidence status is not a gate transition. ### Review gate - Review the actual diff, not the intended plan alone. - Check spec traceability, invariants, data/security/tenancy boundaries, failure behavior, backward compatibility, migrations, observability, tests, docs, and gaps. -- Use an independent reviewer for high-risk changes, repeated failures, or when the existing review evidence is circular. +- When configured high-risk paths changed, use a human peer or separate agent and record `--reviewer-identity` with `--review-method human_peer|separate_agent`. - Convert actionable findings into tasks. Do not pass while critical findings are open. - On pass, record `record-delivery-gate --feature --slice --gate review`. Review is rejected unless the same diff already has a test receipt; any later product change makes both receipts stale. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json index 89019717c..cf5273e84 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan-lock.json @@ -1,6 +1,8 @@ { - "schema_version": 1, - "status": "APPROVED", + "schema_version": 2, + "status": "LOCKED", + "authorization_mode": "human", + "activated_at": "", "approved_by": "", "approved_at": "", "source_commit": "", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index 25f98f642..ffb1adf62 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -227,8 +227,8 @@ func activatePlanCommand(arguments []string) int { if err := flags.Parse(arguments); err != nil { return 2 } - if options.PlanPath == "" || options.ApprovalPath == "" || options.OutDir == "" || options.OutputPath == "" { - return fail(fmt.Errorf("activate-plan requires --plan, --approval, --out-dir, and --output")) + if options.PlanPath == "" || options.OutDir == "" || options.OutputPath == "" { + return fail(fmt.Errorf("activate-plan requires --plan, --out-dir, and --output; --approval is required when human_plan_approval is enabled")) } if err := boatstack.ActivatePlan(options); err != nil { return fail(fmt.Errorf("plan activation failed: %w", err)) @@ -295,6 +295,8 @@ func recordDeliveryGateCommand(arguments []string) int { flags.StringVar(&options.Status, "status", "", "PASS or PASS_WITH_GAPS") flags.StringVar(&options.BaseBranch, "base", "", "delivery base branch; defaults from the active slice or project") flags.StringVar(&options.EvidencePath, "evidence", "", "current evidence ledger") + flags.StringVar(&options.ReviewerIdentity, "reviewer-identity", "", "reviewer identity required for configured high-risk independent review") + flags.StringVar(&options.ReviewMethod, "review-method", "", "human_peer or separate_agent") if err := flags.Parse(arguments); err != nil { return 2 } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go index de3d5fa9d..2a94d073f 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/config_documentation_test.go @@ -8,7 +8,10 @@ import ( "testing" ) -const configFieldMarkerPrefix = "boatstack-config-field:" +const ( + configFieldMarkerPrefix = "boatstack-config-field:" + userConfigFieldMarkerPrefix = "boatstack-user-config-field:" +) func configSurface(value reflect.Type, prefix string) []string { if value.Kind() == reflect.Pointer { @@ -44,25 +47,25 @@ func configSurface(value reflect.Type, prefix string) []string { return fields } -func configFieldMarkers(content string) []string { +func configFieldMarkers(content, prefix string) []string { var fields []string for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(line) - if strings.HasPrefix(line, configFieldMarkerPrefix) { - fields = append(fields, strings.TrimPrefix(line, configFieldMarkerPrefix)) + if strings.HasPrefix(line, prefix) { + fields = append(fields, strings.TrimPrefix(line, prefix)) } } sort.Strings(fields) return fields } -func documentedConfigSurface(t *testing.T, path string) []string { +func documentedConfigSurface(t *testing.T, path, prefix string) []string { t.Helper() content, err := os.ReadFile(path) if err != nil { t.Fatalf("read configuration documentation %s: %v", path, err) } - return configFieldMarkers(string(content)) + return configFieldMarkers(string(content), prefix) } func publicConfigurationDocument(t *testing.T) string { @@ -92,22 +95,48 @@ func publicConfigurationDocument(t *testing.T) string { func TestConfigFieldMarkersAcceptWindowsLineEndings(t *testing.T) { content := "\r\n" want := []string{"project.name", "workflow"} - if got := configFieldMarkers(content); !reflect.DeepEqual(got, want) { + if got := configFieldMarkers(content, configFieldMarkerPrefix); !reflect.DeepEqual(got, want) { t.Fatalf("CRLF configuration markers were not parsed: got %v, want %v", got, want) } } -func TestPublicConfigurationSurfaceIsDocumented(t *testing.T) { +func TestSerializedConfigurationSurfaceIsDocumentedInternally(t *testing.T) { want := configSurface(reflect.TypeOf(ProjectConfig{}), "") sort.Strings(want) + document := "references/config-schema.md" + got := documentedConfigSurface(t, document, configFieldMarkerPrefix) + if !reflect.DeepEqual(got, want) { + t.Errorf("configuration documentation drift in %s\nimplementation: %v\ndocumented: %v", document, want, got) + } +} - for _, document := range []string{ - "references/config-schema.md", - publicConfigurationDocument(t), - } { - got := documentedConfigSurface(t, document) - if !reflect.DeepEqual(got, want) { - t.Errorf("configuration documentation drift in %s\nimplementation: %v\ndocumented: %v", document, want, got) +func TestPublicConfigurationGuideContainsOnlySupportedUserControls(t *testing.T) { + want := []string{ + "adapters", + "project.commands", + "project.context", + "project.default_branch", + "project.high_risk_paths", + "workflow.allow_pass_with_gaps", + "workflow.boundary_analysis", + "workflow.human_plan_approval", + "workflow.independent_review_for_high_risk", + "workflow.maintain_changelog", + "workflow.pr_visual_evidence", + "workspace.cleanup", + "workspace.cleanup_after", + "workspace.enabled", + "workspace.mode", + } + document := publicConfigurationDocument(t) + got := documentedConfigSurface(t, document, userConfigFieldMarkerPrefix) + if !reflect.DeepEqual(got, want) { + t.Errorf("public user-control documentation drift in %s\nsupported: %v\ndocumented: %v", document, want, got) + } + serialized := configSurface(reflect.TypeOf(ProjectConfig{}), "") + for _, field := range got { + if !contains(serialized, field) { + t.Errorf("public guide exposes unknown configuration field %s", field) } } } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index 6ad1683d4..ba353e7da 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -55,16 +55,45 @@ type DeliveryGateReceipt struct { Attempt int `json:"attempt,omitempty"` TriggerObservationID string `json:"trigger_observation_id,omitempty"` Supersedes string `json:"supersedes,omitempty"` + ReviewerIdentity string `json:"reviewer_identity,omitempty"` + ReviewMethod string `json:"review_method,omitempty"` } type DeliveryGateOptions struct { - Repo string - Feature string - SliceID string - Gate string - Status string - BaseBranch string - EvidencePath string + Repo string + Feature string + SliceID string + Gate string + Status string + BaseBranch string + EvidencePath string + ReviewerIdentity string + ReviewMethod string +} + +func validateDeliveryGatePolicy(config ProjectConfig, gate, status string, changed []string, reviewerIdentity, reviewMethod string) error { + if status == "PASS_WITH_GAPS" && !config.Workflow.AllowPassWithGaps { + return fmt.Errorf("workflow.allow_pass_with_gaps is false; record PASS only after resolving gaps") + } + if gate != "review" { + return nil + } + identity := strings.TrimSpace(reviewerIdentity) + method := strings.ToLower(strings.TrimSpace(reviewMethod)) + if identity != "" || method != "" { + if identity == "" { + return fmt.Errorf("reviewer_identity is required when review_method is recorded") + } + if method != "human_peer" && method != "separate_agent" { + return fmt.Errorf("review_method must be human_peer or separate_agent") + } + } + if config.Workflow.IndependentReviewForHighRisk && len(highRiskChangedFiles(changed, config.Project.HighRiskPaths)) > 0 { + if identity == "" || method == "" { + return fmt.Errorf("high-risk review requires reviewer_identity and review_method") + } + } + return nil } type ChangeObservationOptions struct { @@ -369,7 +398,7 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio resume := map[string]string{ "implementation_repair": "BUILD", "verification_repair": "TEST_GATE", "review_repair": "REVIEW_GATE", "requirement_amendment": "PLAN_GATE", - "plan_invalid": "AUTO_PLAN", + "plan_invalid": "AUTO_PLAN", "needs_clarification": "", }[classification] if _, ok := map[string]bool{"implementation_repair": true, "verification_repair": true, "review_repair": true, "requirement_amendment": true, "needs_clarification": true, "plan_invalid": true}[classification]; !ok { @@ -446,7 +475,7 @@ func checkDeliveryPlanLock(repo, feature string, state DeliveryState) error { return fmt.Errorf("managed delivery requires its current plan lock: %w", err) } if state.PlanLockHash == "" || state.PlanLockHash != lockHash { - return fmt.Errorf("managed delivery state is stale for the current plan lock; reactivate the approved plan") + return fmt.Errorf("managed delivery state is stale for the current plan lock; reactivate the authorized plan") } return nil } @@ -570,6 +599,13 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if status != "PASS" && status != "PASS_WITH_GAPS" { return DeliveryGateReceipt{}, fmt.Errorf("a delivery gate receipt may record only PASS or PASS_WITH_GAPS") } + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return DeliveryGateReceipt{}, fmt.Errorf("delivery gate requires a valid Boatstack project configuration: %w", configErr) + } + if err := validateDeliveryGatePolicy(config, gate, status, nil, options.ReviewerIdentity, options.ReviewMethod); err != nil { + return DeliveryGateReceipt{}, err + } state, err := LoadDeliveryState(repo, options.Feature) if err != nil { return DeliveryGateReceipt{}, err @@ -602,6 +638,9 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if err := validateDeliveryScope(options.Feature, slice, changed); err != nil { return DeliveryGateReceipt{}, err } + if err := validateDeliveryGatePolicy(config, gate, status, changed, options.ReviewerIdentity, options.ReviewMethod); err != nil { + return DeliveryGateReceipt{}, err + } if slice.HeadBranch != "" && slice.HeadBranch != head { return DeliveryGateReceipt{}, fmt.Errorf("delivery slice %s requires head branch %s; current branch is %s", slice.ID, slice.HeadBranch, head) } @@ -616,10 +655,6 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error if testReceipt.HeadCommit != headCommit || testReceipt.DiffSHA256 != diffHash || testReceipt.BaseBranch != base { return DeliveryGateReceipt{}, fmt.Errorf("delivery diff changed after the test gate; rerun test-gate for slice %s", slice.ID) } - config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) - if configErr != nil { - return DeliveryGateReceipt{}, fmt.Errorf("review requires a valid Boatstack project configuration: %w", configErr) - } baseCommit, baseErr := resolveBaseCommit(repo, base) if baseErr != nil { return DeliveryGateReceipt{}, baseErr @@ -669,6 +704,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error DiffSHA256: diffHash, EvidencePath: relEvidence, EvidenceHash: evidenceHash, RecordedAt: time.Now().UTC().Truncate(time.Second).Format(time.RFC3339), Attempt: state.RepairAttempt + 1, TriggerObservationID: state.ActiveObservationID, + ReviewerIdentity: strings.TrimSpace(options.ReviewerIdentity), ReviewMethod: strings.ToLower(strings.TrimSpace(options.ReviewMethod)), } if previous.RecordedAt != "" { receipt.Supersedes = previous.RecordedAt @@ -721,6 +757,10 @@ func CheckDeliveryReadyForShip(repo, feature, base, head, diffHash string, chang return DeliveryState{}, DeliverySlice{}, nil, err } sources := []PRSource{} + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("ship readiness requires a valid Boatstack project configuration: %w", configErr) + } for _, gate := range []string{"test", "review"} { receipt, receiptErr := readDeliveryReceipt(repo, feature, slice.ID, gate) if receiptErr != nil { @@ -729,6 +769,9 @@ func CheckDeliveryReadyForShip(repo, feature, base, head, diffHash string, chang if receipt.BaseBranch != base || receipt.HeadBranch != head || receipt.DiffSHA256 != diffHash { return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("stale delivery receipt: diff changed after the %s gate; rerun gates for slice %s", gate, slice.ID) } + if err := validateDeliveryGatePolicy(config, gate, receipt.Status, changed, receipt.ReviewerIdentity, receipt.ReviewMethod); err != nil { + return DeliveryState{}, DeliverySlice{}, nil, fmt.Errorf("%s gate receipt violates current workflow policy: %w", gate, err) + } path, _ := deliveryReceiptPath(repo, feature, slice.ID, gate) hash, _ := SHA256File(path) sources = append(sources, PRSource{Kind: gate + "_gate_receipt", Path: ".git/boatstack/deliveries/" + feature + "/receipts/" + slice.ID + "/" + gate + ".json", SHA256: hash}) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go index 72f6cc866..0386ab7f9 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery_test.go @@ -49,13 +49,20 @@ func TestDeliverySlicesPartitionTasksAndRejectForwardDependencies(t *testing.T) } func activateTwoSliceDelivery(t *testing.T) (string, string) { - return activateTwoSliceDeliveryWithChangelog(t, false) + return activateTwoSliceDeliveryConfigured(t, false, nil) } func activateTwoSliceDeliveryWithChangelog(t *testing.T, maintainChangelog bool) (string, string) { + return activateTwoSliceDeliveryConfigured(t, maintainChangelog, nil) +} + +func activateTwoSliceDeliveryConfigured(t *testing.T, maintainChangelog bool, configure func(*ProjectConfig)) (string, string) { t.Helper() repo := prTestRepoConfigured(t, func(config *ProjectConfig) { config.Workflow.MaintainChangelog = maintainChangelog + if configure != nil { + configure(config) + } }) feature := "phased-feature" directory := filepath.Join(repo, ".product-loop", "features", feature) @@ -101,6 +108,53 @@ func activateTwoSliceDeliveryWithChangelog(t *testing.T, maintainChangelog bool) return repo, feature } +func TestDeliveryGatePoliciesControlGapsAndHighRiskReview(t *testing.T) { + t.Run("gaps disabled", func(t *testing.T) { + repo, feature := activateTwoSliceDeliveryConfigured(t, false, func(config *ProjectConfig) { + config.Workflow.AllowPassWithGaps = false + }) + evidencePath := filepath.Join(repo, ".product-loop", "features", feature, "evidence.md") + value, err := os.ReadFile(evidencePath) + if err != nil { + t.Fatal(err) + } + value = []byte(strings.Replace(string(value), "Test gate (phase-one): `PASS`", "Test gate (phase-one): `PASS_WITH_GAPS`", 1)) + if err := os.WriteFile(evidencePath, value, 0o644); err != nil { + t.Fatal(err) + } + _, err = RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS_WITH_GAPS"}) + if err == nil || !strings.Contains(err.Error(), "allow_pass_with_gaps is false") { + t.Fatalf("disabled gap policy did not block PASS_WITH_GAPS: %v", err) + } + }) + + t.Run("high risk review", func(t *testing.T) { + repo, feature := activateTwoSliceDeliveryConfigured(t, false, func(config *ProjectConfig) { + config.Workflow.IndependentReviewForHighRisk = true + }) + if _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "test", Status: "PASS"}); err != nil { + t.Fatal(err) + } + base := DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Gate: "review", Status: "PASS"} + if _, err := RecordDeliveryGate(base); err == nil || !strings.Contains(err.Error(), "reviewer_identity") { + t.Fatalf("high-risk review accepted missing provenance: %v", err) + } + base.ReviewerIdentity = "reviewer-2" + base.ReviewMethod = "same_agent" + if _, err := RecordDeliveryGate(base); err == nil || !strings.Contains(err.Error(), "human_peer or separate_agent") { + t.Fatalf("high-risk review accepted unsupported method: %v", err) + } + base.ReviewMethod = "separate_agent" + receipt, err := RecordDeliveryGate(base) + if err != nil { + t.Fatal(err) + } + if receipt.ReviewerIdentity != "reviewer-2" || receipt.ReviewMethod != "separate_agent" { + t.Fatalf("review provenance was not persisted: %#v", receipt) + } + }) +} + func TestManagedReviewRequiresChangelogEntryAndBindsItToTestEvidence(t *testing.T) { repo, feature := activateTwoSliceDeliveryWithChangelog(t, true) options := DeliveryGateOptions{Repo: repo, Feature: feature, SliceID: "phase-one", Status: "PASS"} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/export.go b/labs/12-product-engineering-loop/product-engineering-loop/export.go index 0761c0505..41400e586 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -268,11 +268,11 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "boatstack-next": "Run the project-local helper next-status --repo . --json. This operation is strictly read-only: do not run the reported operation, edit artifacts, contact GitHub, or advance a gate. Translate the structured result into the canonical response contract. Show the verified feature and active slice when present. Distinguish NOT_STARTED and SOURCE_PLAN_READY, whose next operation is auto-plan, from FEATURE_COMPLETE, which responds Feature complete and requires no action. If verification_status is BLOCKED, name the ambiguity or invalid evidence and make its safe restoration the one action; never clear artifacts. Conversation, terminal, worktree, or process observations may be included as clearly labeled context only and must never override the repository-backed result. Otherwise make the returned next_operation the one next action.", "boatstack-run": "First run the read-only next-status --repo . --json. If SOURCE_PLAN_READY, execute auto-plan without Git preflight and pause at its normal decision or approval boundary. If NOT_STARTED, respond Start a Boatstack feature and ask the user to save exactly one host Plan-mode file, then run /auto-plan; do not fetch or require a feature branch. If FEATURE_COMPLETE, respond Feature complete with No action required without requiring a remote or fetching. Stop on UNVERIFIED, BLOCKED, ambiguous, stale, or invalid state. Before executing the first delivery-stage next_operation (build, repair, test-gate, review-gate, or ship-gate), run the project-local helper run-preflight --repo . --json; planning and plan-gate do not require it. Stop on a blocked preflight; never merge, rebase, force-push, discard changes, switch branches, or create a constrained delivery branch to repair freshness. Then execute exactly the verified next_operation using the canonical operation semantics, verify the resulting repository state, and resolve again. Continue across every declared delivery slice. Pause for the exact plan approval reply a, any material product decision, and the exact PR publication reply o or u; after a valid reply in the current host session, automatically continue the run. A run request never supplies approval or publication authority. For a same-intent test or review failure, use repair, record the observation, and retry from the returned stage, up to three complete automated repair-and-gate cycles for the active slice in this invocation. Stop immediately on an amendment, ambiguity, unsafe or destructive capability, stale evidence, branch mismatch, unsupported recovery, or exhausted repair budget. If Cursor reports MainThreadShellExec not initialized, explain that Cursor failed before the Boatstack hook started and make Developer: Reload Window the one recovery action; do not recommend reinstall unless Boatstack reports a missing, drifted, unsafe, or checksum-invalid runtime. Do not use conversation as workflow evidence and do not create durable autopilot state. Report the feature, active slice, stages completed during this invocation, completion or pause reason, repair-cycle count, and exactly one next action. Ship means publishing every declared slice PR for review; never merge or deploy.", "auto-plan": "Discover exactly one saved Plan-mode file and refine it into a Markdown-only draft feature package whose canonical structured artifact is plan.md. Run check-plan read-only. If workflow.boundary_analysis is true, evaluate if the change is a symptom of a missing systemic boundary and perform a rapid codebase scan for other vulnerabilities. Present this as a material product decision with tiered paths: [1a] Symptom Patch or [1b] Programmatic Enforcement (Slice 1 for the boundary, Slice 2 for the feature). When workflow.pr_visual_evidence is suggest or require, record a structural pr_visual_evidence decision: relevant with one to three entry/state/viewport/expected scenarios, or not_relevant with a reason. Discover existing visual tooling but never require a frontend framework or add repository tooling during planning. Record affected_paths and structured side_effects for external writes; use an immutable target identity, transactional or fix-forward recovery, and destructive=false. When workflow.maintain_changelog is true, include CHANGELOG.md in every delivery slice's affected paths. Keep internal phases as tasks in one delivery slice. Only when the accepted outcome explicitly needs multiple PRs, declare ordered delivery_slices and assign every task exactly once; plan approval never authorizes publication. Do not implement, create JSON or locks, or imply acceptance. If ready, respond with Plan ready and make Run /plan-gate the one next action. If decisions remain, respond with I need your input and ask only 1-3 material questions.", - "plan-gate": "Run check-plan read-only, present its fingerprint and all open decisions, and require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never invent a placeholder name (e.g., Sam, Eve) and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval with the resolved human, RFC3339 timestamp, and exact displayed fingerprint so it writes only approval.md. While pending, respond Ready for your approval and render the one next action as: Reply `a` to approve. After recording, respond Approved — ready to build and make entering the host execution mode and running /build the one next action. Remain in Plan mode; do not compile or request an early mode switch.", - "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and approval.md and run activate-plan before the first product-code edit. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the approved boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", + "plan-gate": "Run check-plan read-only and present its fingerprint and all open decisions. If workflow.human_plan_approval is true, require explicit human approval. While plan approval is pending, the normal user action is the exact standalone reply a. Trim surrounding whitespace and match a case-insensitively; do not treat [a] or an a embedded in other text as approval. Continue accepting the full reply approve for compatibility, but do not advertise it in the user-facing response. Resolve approved_by from an explicit supplied identity, otherwise from the authenticated GitHub login when available; ask one short identity follow-up only when neither exists, and never infer it from a filesystem username, commit history, or agent identity. On approval invoke record-approval so it writes only approval.md. While pending respond Ready for your approval and render: Reply `a` to approve. After recording respond Approved — ready to build. If human_plan_approval is false, do not request approval or create approval.md; state that Build will create a fingerprinted policy-activation lock. In either mode Remain in Plan mode, do not compile, and make entering execution mode and running /build the next action once ready.", + "build": "First confirm the host is in an execution-capable mode. If the mode transition is rejected or product-code writes remain unavailable, return READY_FOR_BUILD internally without activating the plan, compiling JSON, or writing a lock. Only then locate plan.md and, when workflow.human_plan_approval is true, approval.md; run activate-plan before the first product-code edit and omit --approval for policy activation. Stop if it reports BLOCKED. Read delivery-status and implement only the active delivery slice task_ids. When workflow.maintain_changelog is true, add a concise entry grounded in the active slice's actual changes under the current CHANGELOG.md Unreleased heading before recording test evidence. Use only the one allowed category needed by the entry and do not add empty category headings. If the file is absent, create the documented minimal skeleton with ## [Unreleased] - YYYY-MM-DD and the first categorized entry; if it exists, add to the current file without rewriting its history or layout. Run the internal repository safety check after operational or high-risk edits; a destructive capability blocks execution and gate progression but does not block reviewable source editing. Implementation tactics remain open inside the authorized boundary, but push and PR mutation are never build tactics and are denied while managed delivery is active. On success respond Build complete and make Run /test-gate the one next action. When a new product decision blocks work, respond Build needs a decision and ask only that question.", "repair": "First run next-status --repo . --json. Repair requires an active managed delivery and the user's exact free-form requested change. If NOT_STARTED or SOURCE_PLAN_READY, respond No active delivery to repair and make /auto-plan the one next action; do not ask for repair details. If DRAFT_PLAN or APPROVED, route to the returned plan-gate or build operation because no managed delivery exists yet. If FEATURE_COMPLETE and the user supplied an exact correction, preserve the published evidence and plan a linked Boatstack feature with parent_delivery set to the completed feature; otherwise ask for the exact correction. Stop on BLOCKED or INVALID_STATE and preserve all artifacts. For an active delivery, read delivery-status, the current plan lock and acceptance criteria, the actual diff, and current receipts. Compare the exact request with approved intent. Classify it as implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification, then invoke record-change before any product edit. Same-intent repairs may proceed at the returned RESUME_STAGE; requirement amendments and ambiguous intent must stop for a concise plan amendment or one clarifying question. Never edit changes.md or managed delivery state directly. After a repair, reuse the existing /test-gate and /review-gate; do not invent repair-specific gates. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action because Boatstack's hook did not start; reserve reinstall guidance for Boatstack runtime integrity errors.", "test-gate": "Read delivery-status and test only the active delivery slice. Run the internal repository safety check, build a requirement-to-evidence matrix, and treat self-authored tests as evidence rather than the sole oracle. If the active slice contains a systemic_boundary task, the evidence must prove the verification_oracle actively blocked or normalized a violation attempt (negative test). External writes require immutable target identity, transactional or fix-forward failure behavior, and an independent safety oracle. For relevant PR visual scenarios, use repository-owned capture first, then the host browser against the existing development server, one supplied launch instruction, or an approved machine-only runtime. Do not edit repository dependencies or configuration for capture. Review the exact PNGs for secrets and private data and import their temporary manifest with record-pr-visual-evidence. Commit the intentional slice product and evidence diff, then record-delivery-gate for the active feature and slice with --gate test and PASS or PASS_WITH_GAPS. Editing evidence Markdown alone never passes the gate. On pass respond Tests passed and make Run /review-gate the one next action. On failure respond Testing found a problem and make the required non-destructive repair the one next action.", - "review-gate": "Read delivery-status and review the active slice's actual diff against approved intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact rather than commits, PR metadata, artifacts, or test commands. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject a changed or untested diff and a missing or malformed required changelog entry. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", + "review-gate": "Read delivery-status and review the active slice's actual diff against authorized intent, invariants, risks, gaps, and test evidence. Run the internal repository safety check. Executable destructive capability is blocking even when ordinary tests pass. When workflow.maintain_changelog is true, verify the new CHANGELOG.md Unreleased entry accurately describes the actual reader-visible impact. When workflow.independent_review_for_high_risk is true and changed paths match project.high_risk_paths, use a human peer or separate agent and pass --reviewer-identity plus --review-method human_peer or separate_agent. On pass invoke record-delivery-gate for the same feature and slice with --gate review; it must reject changed or untested diffs, disallowed gaps, missing reviewer provenance, and malformed required changelog evidence. Then respond Review passed and make Run /ship-gate the one next action. When blocked respond Changes required and make the highest-priority blocking repair the one next action.", "ship-gate": "Prepare a reviewer-ready PR only; do not merge or deploy without separate authorization. Require the current managed feature approval, lock, test evidence, review evidence, and a passing repository safety scan, and commit the intentional product/artifact diff before projection. Internally run pr-context --repo . --feature in json and template formats, project the approved intent, actual committed diff, decisions, evidence, gaps, rollout, rollback, safety outcome, and operator-only recovery boundary into its required pr.md path, then run check-pr --repo . --preview . Generate a clear, product-focused PR title that describes the user value or system outcome rather than listing technical components (do not use sequence prefixes like 'PR 1'). Always include why, what changed, review order, evidence, gaps/risks, rollout/rollback, and collapsed provenance. When PR visual evidence is relevant or unresolved, show the exact fingerprinted local PNGs and public-repository warning, render the structural Visual evidence section, and treat o or u as authorization for the exact PR package plus one Boatstack-owned evidence comment. Use a signed-in host browser to upload or update that comment when available and record the observed PR and comment URLs with record-pr-visual-publication; otherwise expose the local paths for manual attachment. Suggest records a visible gap; require blocks completed publication. Preserve an opened PR and fix forward from visual_pending after attachment failure. Add security/privacy, migration, or operations sections only when relevant. Show the exact title and rendered body before any GitHub mutation. If PR_ACTION is open, respond PR ready and render the one next action as: Reply `o` to open PR. If update, render: Reply `u` to update PR. If manual, preserve the preview and give one manual publication action. Continue accepting the full replies open PR and update PR for compatibility without advertising them. Only after the matching state-scoped shortcut or compatible full reply: commit only the reviewed pr.md, rerun check-pr and require the same preview fingerprint (PREVIEW_FINGERPRINT), then run publish-pr with --action open or update and that fingerprint. The publisher performs a non-force push and rechecks context before GitHub mutation. If the diff or evidence changes, regenerate instead. If a required check fails on the base branch too, record the evidence and recommend a separate repair PR. Never edit unrelated code in this approved feature branch; a policy-approved bypass requires explicit human authorization. After publication respond PR opened with the link and make Review the PR the one next action; never imply merge authorization. If publish-pr returns UPDATE_AVAILABLE, keep Review the PR as the only next action and append a collapsed update notice saying no files changed and /boatstack-update may be run from the clean default branch after this feature PR merges. Do not check for releases before successful publication.", "boatstack-update": "Prepare a visible Boatstack infrastructure update; never mix it into product work or merge it. First run the current helper doctor and force check-update. If current, respond Boatstack is current with No action required. Before mutation fetch the default ref, then require the current clean default branch whose HEAD equals origin/; otherwise respond Update postponed and make finishing the current feature, switching to the clean default branch, and rerunning /boatstack-update the one action. Ensure no update PR or branch already exists, create chore/update-boatstack-v, then run the installer fetched from that exact release tag with BOATSTACK_MODE=update, BOATSTACK_VERSION=, BOATSTACK_REPO=, and BOATSTACK_YES=1. Use install.sh on macOS/Linux and install.ps1 on Windows. The verified update must preserve configuration, adapters, integrations, and user-owned host settings, run doctor, and touch only Boatstack infrastructure. Show the version transition, release notes and link, integration state, exact diff, changed paths, checksums, rollout, and rollback. Respond Boatstack update ready and render the one next action as: Reply `o` to open update PR. Continue accepting the full reply open update PR for compatibility without advertising it. Only the matching state-scoped shortcut or compatible full reply authorizes staging the installer-reported paths, committing chore: update Boatstack to , normal push, and opening a reviewer-ready update PR. If GitHub auth is unavailable, preserve the branch and give one manual publication action. After publication respond Update PR opened with the link and make Review the PR the one next action. On one collision or health failure, respond Update needs attention and make addressing that named problem the one next action. Never merge automatically.", "review": "Alias of review-gate: review the actual diff against approved intent, invariants, risks, gaps, and test evidence. Use Review passed or Changes required and the same single-action routing as review-gate.", @@ -292,7 +292,7 @@ alwaysApply: true 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 approval.md and build activation has produced a valid plan lock. +Do not start build work until the plan gate is ready and build activation has produced a valid plan lock. Require approval.md only when workflow.human_plan_approval is true; otherwise the lock must record policy activation. Before modifying product code, check for an active managed delivery. When one exists and the user reports a problem or requests a modification in ordinary language, route through the Boatstack repair operation before editing. The repair operation records the exact request, compares it with approved intent, and either resumes the earliest affected stage or blocks for a plan amendment. If no managed delivery exists, continue ordinary conversation. Implementation methods are open. Claims of completion, approval, review, and shipping require evidence. Plans may contain internal task phases without changing the one-PR flow. Multiple PRs require explicit ordered delivery_slices. Work only on the active slice; every slice must independently pass test-gate, review-gate, and confirmed ship-gate. Direct push and PR mutation are denied while managed delivery is active, and plan approval is never publication authority. @@ -321,7 +321,7 @@ description: Use when the user asks what is next in Boatstack, asks Boatstack to Follow the User-facing response contract in .product-loop/workflow.md for every operation. Lead with the mapped plain-language outcome, show only decision-relevant content, end with exactly one Next step, and move machine statuses, helper output, fingerprints, artifact paths, receipts, and locks into collapsed Technical details. Internal helper names must not appear in the primary response. -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. Auto-plan and plan-gate write Markdown only: plan.md remains canonical and approval.md records explicit acceptance. If the host blocks its normal Markdown writer, use the bounded planning-write helper and never arbitrary shell redirection. Repository facts are DISCOVERED, agent suggestions are PROPOSED, and only human responses are ANSWERED; every material proposal remains blocking. At build, confirm the host can edit product code before activating the plan. A rejected mode transition returns READY_FOR_BUILD and creates no machine artifacts or lock. Once execution is available, activation compiles machine artifacts and the lock before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the approved lock, diff, and evidence after build. +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. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, and approval.md records explicit acceptance only when human approval is enabled. If the host blocks its normal Markdown writer, use the bounded planning-write helper and never arbitrary shell redirection. Repository facts are DISCOVERED, agent suggestions are PROPOSED, and only human responses are ANSWERED; every material proposal remains blocking. At build, confirm the host can edit product code before activating the plan. A rejected mode transition returns READY_FOR_BUILD and creates no machine artifacts or lock. Once execution is available, activation compiles machine artifacts and a human or policy authorization lock before the first product-code edit. The source plan remains required and hash-current through build. Test, review, and ship gates operate from the authorization lock, diff, and evidence after build. Internal phases are ordinary tasks inside one delivery slice. Multiple PRs require explicit ordered delivery_slices with every task assigned exactly once. After activation, read delivery-status and work only on the active slice. Test-gate and review-gate must record slice-scoped receipts bound to the current branches, commit, diff, and evidence. Direct push, PR mutation, and ad-hoc PR routing are denied while managed delivery is active. Successful confirmed publication advances exactly one slice; plan approval never authorizes later slices. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index ca4645f5f..9526c1929 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -207,12 +207,16 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { base.Reason = "This repository has no Boatstack project installation to inspect." return base, nil } + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack project configuration is invalid: "+configErr.Error()), nil + } active, err := ActiveManagedDeliveries(repo) if err != nil { return blockedNextStatus("INVALID_STATE", "repair-state", "Boatstack found invalid managed delivery state. Preserve the artifacts and restore the missing or stale evidence before continuing: "+err.Error()), nil } - + if explicitFeature != "" { found := false for _, f := range active { @@ -290,7 +294,15 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { directory := filepath.Join(repo, ".product-loop", "features", feature) base.VerificationStatus = "VERIFIED" base.Feature = feature - if fileExists(filepath.Join(directory, "approval.md")) { + if !config.Workflow.HumanPlanApproval { + base.ObservedStage = "POLICY_READY" + base.NextOperation = "build" + base.Reason = "The saved feature is ready for fingerprinted policy activation without a human approval receipt." + if workspaceEnabled(repo) && needsFreshCut(repo, feature) { + base.NextOperation = "workspace-cut" + base.Reason = fmt.Sprintf("Feature %q is policy-authorized; cut a fresh workspace from the default branch before building.", feature) + } + } else if fileExists(filepath.Join(directory, "approval.md")) { base.ObservedStage = "APPROVED" base.NextOperation = "build" base.Reason = "The saved feature has an approval receipt but no active delivery state." diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next_test.go b/labs/12-product-engineering-loop/product-engineering-loop/next_test.go index 78801526d..b2c61f4f1 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next_test.go @@ -17,7 +17,12 @@ func nextTestRepo(t *testing.T) string { if err := os.MkdirAll(filepath.Join(repo, ".product-loop", "features"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), []byte("{}\n"), 0o644); err != nil { + config := testConfig() + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".product-loop", "project.json"), value, 0o644); err != nil { t.Fatal(err) } return repo @@ -203,6 +208,28 @@ func TestResolveNextPlanningStates(t *testing.T) { } } +func TestResolveNextRoutesPolicyAuthorizedPlanToBuild(t *testing.T) { + repo := nextTestRepo(t) + configPath := filepath.Join(repo, ".product-loop", "project.json") + config, _, err := LoadConfig(configPath) + if err != nil { + t.Fatal(err) + } + config.Workflow.HumanPlanApproval = false + value, _ := MarshalJSON(config) + if err := os.WriteFile(configPath, value, 0o644); err != nil { + t.Fatal(err) + } + writeSavedFeaturePlan(t, repo, "policy-ready") + status, err := ResolveNext(repo, "") + if err != nil { + t.Fatal(err) + } + if status.ObservedStage != "POLICY_READY" || status.NextOperation != "build" || status.Feature != "policy-ready" { + t.Fatalf("policy-authorized plan did not route to build: %+v", status) + } +} + func TestResolveNextDeliveryTransitions(t *testing.T) { for _, test := range []struct{ state, next string }{ {state: "BUILD", next: "build"}, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan.go b/labs/12-product-engineering-loop/product-engineering-loop/plan.go index 85876c5ef..ccf3d4708 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan.go @@ -661,7 +661,7 @@ func CompilePlan(plan map[string]any, opts *ValidatePlanOptions) (map[string]any 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`", "", + "- Authorized plan lock: pending", "- Test gate: `BLOCKED`", "- Review gate: `BLOCKED`", "- Ship gate: `BLOCKED`", "", } if plan["delivery_slices"] != nil { evidence = append(evidence, "## Delivery slices", "") @@ -739,6 +739,10 @@ func CompilePlan(plan map[string]any, opts *ValidatePlanOptions) (map[string]any } func CompilePlanFiles(planPath, outDir string) error { + return compilePlanFiles(planPath, outDir, "HUMAN_APPROVED") +} + +func compilePlanFiles(planPath, outDir, structuredPlanStatus string) error { plan, err := LoadPlan(planPath) if err != nil { return err @@ -759,6 +763,7 @@ func CompilePlanFiles(planPath, outDir string) error { if err != nil { return err } + tasks["structured_plan_status"] = structuredPlanStatus if err := os.MkdirAll(outDir, 0o755); err != nil { return err } @@ -780,14 +785,15 @@ func CompilePlanFiles(planPath, outDir string) error { } type ApprovalOptions struct { - SourcePlanPath string - SpecPath string - PlanPath string - TasksPath string - ApprovedBy string - ApprovedAt string - SourceCommit string - OutputPath string + SourcePlanPath string + SpecPath string + PlanPath string + TasksPath string + ApprovedBy string + ApprovedAt string + AuthorizationMode string + SourceCommit string + OutputPath string } type ApprovalReceipt struct { @@ -863,13 +869,27 @@ func ActivatePlan(options ActivationOptions) error { if err != nil { return err } - receipt, err := CheckApprovalReceipt(options.ApprovalPath, check) + repo, err := ResolveRepository(filepath.Dir(options.PlanPath)) if err != nil { return err } - repo, err := ResolveRepository(filepath.Dir(options.PlanPath)) + config, _, err := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) if err != nil { - return err + return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err) + } + authorizationMode := "policy" + structuredPlanStatus := "POLICY_ACTIVATED" + receipt := ApprovalReceipt{} + if config.Workflow.HumanPlanApproval { + authorizationMode = "human" + structuredPlanStatus = "HUMAN_APPROVED" + if strings.TrimSpace(options.ApprovalPath) == "" { + return fmt.Errorf("human_plan_approval requires --approval") + } + receipt, err = CheckApprovalReceipt(options.ApprovalPath, check) + if err != nil { + return err + } } safety, err := CheckRepositorySafety(repo) if err != nil { @@ -880,14 +900,15 @@ func ActivatePlan(options ActivationOptions) error { } tasksPath := filepath.Join(options.OutDir, "tasks.json") approval := ApprovalOptions{ - SourcePlanPath: check.SourcePlanPath, - SpecPath: check.SpecPath, - PlanPath: options.PlanPath, - TasksPath: tasksPath, - ApprovedBy: receipt.ApprovedBy, - ApprovedAt: receipt.ApprovedAt, - SourceCommit: options.SourceCommit, - OutputPath: options.OutputPath, + SourcePlanPath: check.SourcePlanPath, + SpecPath: check.SpecPath, + PlanPath: options.PlanPath, + TasksPath: tasksPath, + ApprovedBy: receipt.ApprovedBy, + ApprovedAt: receipt.ApprovedAt, + AuthorizationMode: authorizationMode, + SourceCommit: options.SourceCommit, + OutputPath: options.OutputPath, } if fileExists(options.OutputPath) { if err := CheckApprovalLock(approval); err == nil { @@ -907,12 +928,12 @@ func ActivatePlan(options ActivationOptions) error { if stringValue(existing["plan_sha256"]) == currentPlanHash && stringValue(existing["source_plan_sha256"]) == currentSourceHash && stringValue(existing["spec_sha256"]) == currentSpecHash { - return fmt.Errorf("existing activation state is invalid for the unchanged approved plan; repair it instead of resetting delivery progress") + return fmt.Errorf("existing activation state is invalid for the unchanged authorized plan; repair it instead of resetting delivery progress") } } else if statePath, statePathErr := deliveryStatePath(repo, stringValue(check.Plan["feature_id"])); statePathErr == nil && fileExists(statePath) { return fmt.Errorf("managed delivery state exists without its plan lock; do not reset delivery progress") } - if err := CompilePlanFiles(options.PlanPath, options.OutDir); err != nil { + if err := compilePlanFiles(options.PlanPath, options.OutDir, structuredPlanStatus); err != nil { return err } if err := CreateApprovalLock(approval); err != nil { @@ -934,7 +955,14 @@ func gitCommit(directory string) string { } func CreateApprovalLock(options ApprovalOptions) error { - if strings.TrimSpace(options.ApprovedBy) == "" { + mode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode)) + if mode == "" { + mode = "human" + } + if mode != "human" && mode != "policy" { + return fmt.Errorf("authorization mode must be human or policy") + } + if mode == "human" && strings.TrimSpace(options.ApprovedBy) == "" { return fmt.Errorf("approved-by must name the human who explicitly approved the plan") } if err := checkApprovalSourcePlan(options); err != nil { @@ -958,10 +986,10 @@ func CreateApprovalLock(options ApprovalOptions) error { planHash, _ := SHA256File(options.PlanPath) tasksHash, _ := SHA256File(options.TasksPath) lock := map[string]any{ - "schema_version": 1, - "status": "APPROVED", - "approved_by": options.ApprovedBy, - "approved_at": approvedAt, + "schema_version": 2, + "status": "LOCKED", + "authorization_mode": mode, + "activated_at": approvedAt, "source_commit": sourceCommit, "source_plan_path": options.SourcePlanPath, "source_plan_sha256": sourcePlanHash, @@ -974,6 +1002,10 @@ func CreateApprovalLock(options ApprovalOptions) error { "invalidated_at": nil, "invalidation_reason": nil, } + if mode == "human" { + lock["approved_by"] = options.ApprovedBy + lock["approved_at"] = approvedAt + } value, err := MarshalJSON(lock) if err != nil { return err @@ -1001,11 +1033,29 @@ func CheckApprovalLock(options ApprovalOptions) error { mismatches = append(mismatches, label) } } - if stringValue(lock["status"]) != "APPROVED" || lock["invalidated_at"] != nil { + schemaVersion := intValue(lock["schema_version"]) + mode := strings.ToLower(stringValue(lock["authorization_mode"])) + validStatus := schemaVersion == 1 && stringValue(lock["status"]) == "APPROVED" + if schemaVersion == 2 { + validStatus = stringValue(lock["status"]) == "LOCKED" && (mode == "human" || mode == "policy") + } + if !validStatus || lock["invalidated_at"] != nil { mismatches = append(mismatches, "status") } - if stringValue(lock["approved_by"]) == "" { - mismatches = append(mismatches, "approver") + if schemaVersion == 1 || mode == "human" { + if stringValue(lock["approved_by"]) == "" { + mismatches = append(mismatches, "approver") + } + } + expectedMode := strings.ToLower(strings.TrimSpace(options.AuthorizationMode)) + if expectedMode != "" && schemaVersion == 2 && mode != expectedMode { + mismatches = append(mismatches, "authorization_mode") + } + if expectedMode == "policy" && schemaVersion == 1 { + mismatches = append(mismatches, "authorization_mode") + } + if schemaVersion != 1 && schemaVersion != 2 { + mismatches = append(mismatches, "schema_version") } if len(mismatches) > 0 { return fmt.Errorf("stale or invalid plan lock: %s", strings.Join(mismatches, ", ")) diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go b/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go index 921333352..84ccc1c46 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan_test.go @@ -85,12 +85,29 @@ func writePlanInputs(t *testing.T, root string, marked bool) (string, string, st return sourcePlan, spec, planPath } +func writeActivationConfig(t *testing.T, root string, humanApproval bool) { + t.Helper() + config := testConfig() + config.Workflow.HumanPlanApproval = humanApproval + value, err := MarshalJSON(config) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".product-loop"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".product-loop", "project.json"), value, 0o644); err != nil { + t.Fatal(err) + } +} + func TestMarkdownPlanActivationAndStaleness(t *testing.T) { root := t.TempDir() sourcePlan, _, planPath := writePlanInputs(t, root, true) runGit(t, root, "init", "-b", "main") runGit(t, root, "config", "user.name", "Boatstack Test") runGit(t, root, "config", "user.email", "boatstack@example.invalid") + writeActivationConfig(t, root, true) runGit(t, root, "add", ".") runGit(t, root, "commit", "-m", "record approved planning inputs") approval := filepath.Join(root, "approval.md") @@ -128,6 +145,59 @@ func TestMarkdownPlanActivationAndStaleness(t *testing.T) { } } +func TestPolicyActivationCreatesTypedLockWithoutApproval(t *testing.T) { + root := t.TempDir() + _, _, planPath := writePlanInputs(t, root, true) + runGit(t, root, "init", "-b", "main") + runGit(t, root, "config", "user.name", "Boatstack Test") + runGit(t, root, "config", "user.email", "boatstack@example.invalid") + writeActivationConfig(t, root, false) + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record policy-activated planning inputs") + compiled := filepath.Join(root, "compiled") + lockPath := filepath.Join(root, "plan.lock.json") + options := ActivationOptions{PlanPath: planPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"} + if err := ActivatePlan(options); err != nil { + t.Fatal(err) + } + lockValue, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + lock := map[string]any{} + if err := json.Unmarshal(lockValue, &lock); err != nil { + t.Fatal(err) + } + if intValue(lock["schema_version"]) != 2 || stringValue(lock["status"]) != "LOCKED" || stringValue(lock["authorization_mode"]) != "policy" || stringValue(lock["approved_by"]) != "" { + t.Fatalf("unexpected policy lock: %#v", lock) + } + tasksValue, _ := os.ReadFile(filepath.Join(compiled, "tasks.json")) + tasks := map[string]any{} + _ = json.Unmarshal(tasksValue, &tasks) + if stringValue(tasks["structured_plan_status"]) != "POLICY_ACTIVATED" { + t.Fatalf("compiled task graph hid policy activation: %#v", tasks) + } + + lock["schema_version"] = 1 + lock["status"] = "APPROVED" + lock["approved_by"] = "Legacy Human" + delete(lock, "authorization_mode") + legacy, _ := MarshalJSON(lock) + if err := os.WriteFile(lockPath, legacy, 0o644); err != nil { + t.Fatal(err) + } + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + if err := CheckApprovalLock(ApprovalOptions{ + SourcePlanPath: check.SourcePlanPath, SpecPath: check.SpecPath, PlanPath: planPath, + TasksPath: filepath.Join(compiled, "tasks.json"), OutputPath: lockPath, AuthorizationMode: "human", + }); err != nil { + t.Fatalf("legacy v1 human lock was rejected: %v", err) + } +} + func TestCurrentCursorSingleJSONFencePlanIsAccepted(t *testing.T) { root := t.TempDir() _, _, planPath := writePlanInputs(t, root, false) @@ -218,6 +288,8 @@ func TestExternalWritePlanRequiresSafeExplicitSideEffects(t *testing.T) { func TestReadOnlyCheckAndFailedActivationWriteNothing(t *testing.T) { root := t.TempDir() _, _, planPath := writePlanInputs(t, root, true) + runGit(t, root, "init", "-b", "main") + writeActivationConfig(t, root, true) before, _ := os.ReadDir(root) check, err := CheckPlan(planPath) if err != nil { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr.go b/labs/12-product-engineering-loop/product-engineering-loop/pr.go index afe086eb8..1a80f0ab6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go @@ -413,16 +413,25 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro if err != nil { return nil, nil, fmt.Errorf("managed PR requires a current plan: %w", err) } - if _, err := CheckApprovalReceipt(approvalPath, check); err != nil { - return nil, nil, fmt.Errorf("managed PR requires current approval: %w", err) + config, _, configErr := LoadConfig(filepath.Join(repo, ".product-loop", "project.json")) + if configErr != nil { + return nil, nil, fmt.Errorf("managed PR requires a valid Boatstack project configuration: %w", configErr) + } + authorizationMode := "policy" + if config.Workflow.HumanPlanApproval { + authorizationMode = "human" + if _, err := CheckApprovalReceipt(approvalPath, check); err != nil { + return nil, nil, fmt.Errorf("managed PR requires current approval: %w", err) + } } tasksPath := filepath.Join(directory, "compiled", "tasks.json") if err := CheckApprovalLock(ApprovalOptions{ - SourcePlanPath: check.SourcePlanPath, - SpecPath: check.SpecPath, - PlanPath: planPath, - TasksPath: tasksPath, - OutputPath: lockPath, + SourcePlanPath: check.SourcePlanPath, + SpecPath: check.SpecPath, + PlanPath: planPath, + TasksPath: tasksPath, + AuthorizationMode: authorizationMode, + OutputPath: lockPath, }); err != nil { return nil, nil, fmt.Errorf("managed PR requires a current build lock: %w", err) } @@ -455,15 +464,20 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro if status != "PASS" && status != "PASS_WITH_GAPS" { return nil, nil, fmt.Errorf("managed PR requires %s-gate evidence marked PASS or PASS_WITH_GAPS; found %q", gate, status) } + if status == "PASS_WITH_GAPS" && !config.Workflow.AllowPassWithGaps { + return nil, nil, fmt.Errorf("managed PR %s gate violates workflow.allow_pass_with_gaps=false", gate) + } } paths := []struct{ kind, path string }{ {"source_plan", check.SourcePlanPath}, {"feature_spec", check.SpecPath}, {"plan", planPath}, - {"approval", approvalPath}, {"plan_lock", lockPath}, {"evidence", evidencePath}, } + if config.Workflow.HumanPlanApproval { + paths = append(paths, struct{ kind, path string }{"approval", approvalPath}) + } for _, optional := range []struct{ kind, name string }{ {"questions", "questions.md"}, {"gaps", "gaps.md"}, {"test_plan", "test-plan.md"}, } { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr_test.go b/labs/12-product-engineering-loop/product-engineering-loop/pr_test.go index 633d7f1d8..1c75eb955 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr_test.go @@ -35,6 +35,7 @@ func prTestRepoConfigured(t *testing.T, configure func(*ProjectConfig)) string { config.Project.DefaultBranch = "main" config.Project.Context = []string{"README.md"} config.Project.HighRiskPaths = []string{"feature.go"} + config.Workflow.IndependentReviewForHighRisk = false if configure != nil { configure(&config) } @@ -187,9 +188,13 @@ func activateManagedFeature(t *testing.T, repo, feature string) string { if err != nil { t.Fatal(err) } - writeApprovalReceipt(t, filepath.Join(directory, "approval.md"), check.Fingerprint) + approvalPath := "" + if config.Workflow.HumanPlanApproval { + approvalPath = filepath.Join(directory, "approval.md") + writeApprovalReceipt(t, approvalPath, check.Fingerprint) + } if err := ActivatePlan(ActivationOptions{ - PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: filepath.Join(directory, "approval.md"), + PlanPath: filepath.Join(directory, "plan.md"), ApprovalPath: approvalPath, OutDir: filepath.Join(directory, "compiled"), OutputPath: filepath.Join(directory, "plan.lock.json"), SourceCommit: runGit(t, repo, "rev-parse", "HEAD"), }); err != nil { @@ -252,6 +257,47 @@ No migration; revert the feature commit. return directory } +func TestManagedPRRechecksCurrentAuthorizationAndGapPolicy(t *testing.T) { + t.Run("policy activation omits approval source", func(t *testing.T) { + repo := prTestRepoConfigured(t, func(config *ProjectConfig) { + config.Workflow.HumanPlanApproval = false + }) + directory := activateManagedFeature(t, repo, "policy-feature") + if fileExists(filepath.Join(directory, "approval.md")) { + t.Fatal("policy activation created approval.md") + } + context, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "policy-feature"}) + if err != nil { + t.Fatal(err) + } + for _, source := range context.Sources { + if source.Kind == "approval" { + t.Fatalf("policy-activated PR claimed human approval: %#v", source) + } + } + }) + + t.Run("policy change rejects existing gaps", func(t *testing.T) { + repo := prTestRepo(t) + activateManagedFeature(t, repo, "gap-policy") + configPath := filepath.Join(repo, ".product-loop", "project.json") + config, _, err := LoadConfig(configPath) + if err != nil { + t.Fatal(err) + } + config.Workflow.AllowPassWithGaps = false + value, _ := MarshalJSON(config) + if err := os.WriteFile(configPath, value, 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".product-loop/project.json") + runGit(t, repo, "commit", "-m", "tighten gap policy") + if _, err := PreparePRContext(PRContextOptions{Repo: repo, Feature: "gap-policy"}); err == nil || !strings.Contains(err.Error(), "allow_pass_with_gaps=false") { + t.Fatalf("managed PR reused a receipt forbidden by current policy: %v", err) + } + }) +} + func managedPRBody() string { return `## Why this change diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md index 1fad7a96d..64f193bca 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/config-schema.md @@ -36,32 +36,34 @@ This reference document defines the schema and version history of `.boatstack-pr ## Field Reference +This is the exhaustive serialization contract, not a list of recommended user edits. Fields are classified as **deterministic control**, **agent-mediated guidance**, **identity/compatibility metadata**, or **installer-owned state**. The public configuration guide contains only supported user controls. + ### Root Fields -- `schema_version` (integer, required): Must be exactly `1`. +- `schema_version` (integer, required): Must be exactly `1`. Identity/compatibility metadata managed by Boatstack. - `project` (object, required): General project definition. - `workflow` (object, required): Flags controlling state machine transitions and safety gates. - `workspace` (object, optional): Opt-in per-feature branch or worktree management. - `adapters` (array of strings, optional): Enabled host environment adapters. If empty, defaults to enabling all. -- `integrations` (object, optional): Explicit configurations for individual third-party integrations. +- `integrations` (object, optional): Installer-owned state for third-party integrations. ### project Fields -- `name` (string, required): The human-readable name of the project. -- `default_branch` (string, optional): The canonical development/default branch (e.g. `main` or `master`). -- `context` (array of strings, optional): Paths to persistent project directories or contextual documents. -- `commands` (object, required): Custom development commands: +- `name` (string, required): Identity metadata written into generated configuration. +- `default_branch` (string, optional): Deterministic base for freshness, PR, update, and workspace operations. +- `context` (array of strings, optional): Agent-mediated durable-context hints; the controller does not load every path automatically. +- `commands` (object, required): Agent-mediated repository commands: - `test` (string, required): The exact command to execute project-local tests. - Other command names (string, optional): Additional repository-owned commands such as `build`, `lint`, or `typecheck`. - `high_risk_paths` (array of strings, optional): Glob patterns of files requiring independent reviewer sign-off before shipping. ### workflow Fields -- `human_plan_approval` (boolean, optional): Whether a parent plan requires explicit human approval before building. -- `independent_review_for_high_risk` (boolean, optional): Whether modifications to high-risk files require a distinct peer review gate. -- `allow_pass_with_gaps` (boolean, optional): Whether the delivery verification allows outstanding questions or gaps. +- `human_plan_approval` (boolean, optional): Deterministic activation control. `true` requires a current human receipt; `false` creates a fingerprinted policy lock. +- `independent_review_for_high_risk` (boolean, optional): Deterministic review control. Matching diffs require reviewer identity and method `human_peer` or `separate_agent`. +- `allow_pass_with_gaps` (boolean, optional): Deterministic gate control. `false` rejects `PASS_WITH_GAPS`; `true` preserves explicit gaps. - `maintain_changelog` (boolean, optional): Whether a reader-visible `CHANGELOG.md` entry is required for each delivery slice. -- `boundary_analysis` (boolean, optional): Whether planning checks for a missing systemic boundary and presents local repair versus programmatic enforcement as a material product decision. +- `boundary_analysis` (boolean, optional): Agent-mediated planning guidance that presents local repair versus programmatic enforcement as a material product decision. - `pr_visual_evidence` (string, optional): `off`, `suggest`, or `require`. Omission is `off`. Relevant PRs use machine-local PNG evidence without committing media to Git; `suggest` records missing evidence as a visible gap and `require` blocks completed publication. ### workspace Fields @@ -77,7 +79,7 @@ Supported values are `cursor`, `claude`, `codex`, `gemini`, and `github`. An emp ### integrations Fields -Supported integration keys are `gstack` and `spec-kit`. Each integration state can contain: +Supported integration keys are `gstack` and `spec-kit`. The installer owns these records; hand edits do not select or pin an installation. Each state can contain: - `requested` (boolean, required when the integration is present): Whether installation was requested. - `status` (string, optional): Installer-maintained installation status. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md index d3d86732e..ed212ea88 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/workflow.md @@ -242,11 +242,11 @@ When `workflow.pr_visual_evidence` is `suggest` or `require`, every managed plan ### `PLAN -> PLAN_GATE` -Run `boatstack-helper check-plan --plan /plan.md`, present the full draft and returned fingerprint, then require an exact standalone `a`, the compatible full reply `approve`, or a change request. End the pending user-facing response with exactly this Markdown: Reply `a` to approve. The check is read-only. Do not interpret silence, `[a]`, an `a` embedded in other text, a new implementation question, a tool permission, or permission to build as plan approval. +Run `boatstack-helper check-plan --plan /plan.md` and present the full draft and returned fingerprint. When `workflow.human_plan_approval` is `true`, require an exact standalone `a`, the compatible full reply `approve`, or a change request, and end the pending response with: Reply `a` to approve. When it is `false`, report that Build will create a policy-activation lock and do not create or imply human approval. The check is read-only. ### `PLAN_GATE -> PLAN_APPROVED` -After explicit approval, invoke the deterministic `record-approval` operation with the named human, RFC3339 timestamp, and exact approval fingerprint. It rechecks the plan and creates only `approval.md`. This receipt is the only new gate artifact. Remain in the host's Plan mode; do not compile machine artifacts or edit product code. +When human approval is enabled, invoke `record-approval` with the named human, RFC3339 timestamp, and exact fingerprint; it creates only `approval.md`. When disabled, skip that operation and preserve the checked plan for policy activation. Remain in the host's Plan mode; do not compile machine artifacts or edit product code. Ask 1-3 finite questions using the global keyed-choice format whether the host renders them through a structured question tool or plain text, then return `WAITING_FOR_INPUT`. Never convert an unavailable question UI into permission to choose a default. A standalone `r` is an explicit human acceptance of all recommendations displayed in that response, not an agent-selected default. Authoritative repository facts are `DISCOVERED`; agent suggestions and repository-derived product choices are `PROPOSED`; only explicit human responses are `ANSWERED`. Every material proposal remains in `blocking_questions` until answered. @@ -255,9 +255,9 @@ Ask 1-3 finite questions using the global keyed-choice format whether the host r At the host's normal Build transition, first confirm the host is in an execution-capable mode. If the transition is rejected or product-code writes remain unavailable, return `READY_FOR_BUILD` without compiling or writing a lock. Once execution is available and before the first product-code edit, `activate-plan` deterministically: 1. parse and validate the marked structured block in `plan.md`; -2. hash the complete source plan, spec, and `plan.md` and match them to `approval.md`; +2. hash the complete source plan, spec, and `plan.md`, matching them to `approval.md` when human approval is enabled; 3. compile the task graph, requirement-test traceability rows, and evidence skeleton without adding semantics; -4. record approver, timestamp, source commit, and all artifact hashes in `plan.lock.json`; +4. record authorization mode, timestamp, source commit, and all artifact hashes in plan-lock schema v2, plus approver provenance only for human authorization; 5. write the lock last and recheck it before permitting implementation. Activation also initializes ignored, worktree-local Git delivery state bound to the lock. @@ -265,7 +265,7 @@ One implicit `delivery` slice preserves the ordinary one-feature/one-PR flow. An explicit multi-slice plan starts only its first slice in `BUILD`; later slices remain `PENDING`. -Missing approval, unresolved `blocking_questions`, or any change to the source plan, approved spec, or complete `plan.md` blocks activation and returns the feature to `PLAN_GATE`. A failed or partial compilation never creates a valid lock. +Missing required human approval, unresolved `blocking_questions`, or any change to the source plan, spec, or complete `plan.md` blocks activation and returns the feature to `PLAN_GATE`. A failed or partial compilation never creates a valid lock. Existing schema-v1 human locks remain valid; policy activation always writes schema v2. ### `PLAN_LOCKED -> BUILD` @@ -407,7 +407,7 @@ Record unexpected friction and outcomes. A retro may propose a loop move, but it ## Gate semantics - `PASS`: required evidence is present; no gate-blocking gap remains. -- `PASS_WITH_GAPS`: no critical gap remains; each accepted gap has impact, owner, and trigger. +- `PASS_WITH_GAPS`: no critical gap remains; each accepted gap has impact, owner, and trigger, and `workflow.allow_pass_with_gaps` is enabled. - `BLOCKED`: required evidence failed or a critical unknown/gap remains. ## State routing diff --git a/labs/12-product-engineering-loop/tests/test_product_loop.py b/labs/12-product-engineering-loop/tests/test_product_loop.py index eed29ab3a..8c6fb1743 100644 --- a/labs/12-product-engineering-loop/tests/test_product_loop.py +++ b/labs/12-product-engineering-loop/tests/test_product_loop.py @@ -786,14 +786,9 @@ def test_boatstack_is_a_reproducible_upstream_projection(self) -> None: json_examples = re.findall( r"```json\n(.*?)\n```", configuration, re.DOTALL ) - self.assertGreaterEqual(len(json_examples), 5) - parsed_examples = [json.loads(example) for example in json_examples] - with tempfile.TemporaryDirectory() as config_temp: - config_path = Path(config_temp) / ".boatstack-project.json" - config_path.write_text(json.dumps(parsed_examples[0], indent=2) + "\n") - self.run_command( - self.helper, "export", "--repo", config_temp, "--config", config_path - ) + self.assertGreaterEqual(len(json_examples), 4) + for example in json_examples: + json.loads(example) getting_started = (repo / "docs/getting-started.md").read_text() for expected in ( "## Plan ready", @@ -879,6 +874,14 @@ def test_boatstack_is_a_reproducible_upstream_projection(self) -> None: self.run_command( "git", "config", "user.email", "boatstack@example.invalid", cwd=repo ) + projected_state = repo / ".product-loop" + projected_state.mkdir(exist_ok=True) + (projected_state / "project.json").write_text(json.dumps({ + "schema_version": 1, + "project": {"name": "projected-fixture", "commands": {"test": "go test ./..."}}, + "workflow": {"human_plan_approval": True}, + "adapters": [], + }) + "\n") self.run_command("git", "add", ".", cwd=repo) self.run_command("git", "commit", "-m", "projected distribution", cwd=repo) self.run_helper("check-plan", "--plan", demo / "plan.md") @@ -1162,6 +1165,14 @@ def test_markdown_plan_check_activation_and_staleness(self) -> None: self.run_command( "git", "config", "user.email", "boatstack@example.invalid", cwd=root ) + product_loop = root / ".product-loop" + product_loop.mkdir() + (product_loop / "project.json").write_text(json.dumps({ + "schema_version": 1, + "project": {"name": "fixture", "commands": {"test": "go test ./..."}}, + "workflow": {"human_plan_approval": True}, + "adapters": [], + }) + "\n") self.run_command("git", "add", ".", cwd=root) self.run_command("git", "commit", "-m", "planning inputs", cwd=root) before = sorted(root.iterdir())