From e43a4ed28726995d848266cb39bb20902bbb1574 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Sun, 2 Aug 2026 01:27:23 +0100 Subject: [PATCH] feat(boatstack): add goal-driven autonomous runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disclosure-Reviewed: reviewed — public-safe only, private facet kept out of this commit --- .../boatstack-distribution/GENERATED_FILES.md | 1 + .../boatstack-distribution/GETTING_STARTED.md | 2 +- .../boatstack-distribution/README.md | 2 +- .../2026-08-02-goal-driven-autonomous-run.md | 3 + .../product-engineering-loop/SKILL.md | 6 +- .../assets/templates/plan.md | 8 + .../assets/templates/questions.md | 2 +- .../product-engineering-loop/autonomy.go | 342 ++++++++++++++++++ .../autonomy_conformance_test.go | 223 ++++++++++++ .../coverage_conformance_test.go | 1 + .../cmd/boatstack-helper/main.go | 33 +- .../product-engineering-loop/decision.go | 9 + .../product-engineering-loop/decision_test.go | 16 + .../product-engineering-loop/export.go | 8 +- .../product-engineering-loop/next.go | 53 ++- .../product-engineering-loop/plan.go | 40 +- .../plan_validation.go | 16 + .../product-engineering-loop/pr.go | 43 ++- .../references/artifacts.md | 2 + .../references/workflow.md | 2 +- .../product-engineering-loop/safety.go | 4 +- .../product-engineering-loop/statemap.go | 5 + 22 files changed, 795 insertions(+), 26 deletions(-) create mode 100644 labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-goal-driven-autonomous-run.md create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/autonomy.go create mode 100644 labs/12-product-engineering-loop/product-engineering-loop/autonomy_conformance_test.go diff --git a/labs/12-product-engineering-loop/boatstack-distribution/GENERATED_FILES.md b/labs/12-product-engineering-loop/boatstack-distribution/GENERATED_FILES.md index c7af85ed4..62c656e84 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/GENERATED_FILES.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/GENERATED_FILES.md @@ -34,6 +34,7 @@ Boatstack stores feature artifacts under `.product-loop/features//`: | `test-plan.md` | Connects promised outcomes to checks | | `plan.md` | Holds the human-readable approved plan | | `approval.md` | Records who approved which exact plan | +| `autonomy.md` | Records one explicit run target and its bounded policy decisions without claiming human approval | | `compiled/` and `plan.lock.json` | Prove that build activated the approved inputs without drift | | `evidence.md` | Records commands, results, review findings, and gate status | | `pr.md` | Contains the exact reviewer-ready title and body preview | diff --git a/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md b/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md index 2c3f9eed7..d3f203743 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md @@ -18,7 +18,7 @@ Ask Boatstack for the next verified stage without changing anything: Boatstack reads repository-owned plans, approvals, delivery state, and gate receipts, then returns exactly one next action. Chat, terminal, worktree, and running-process observations may add context but never establish a workflow stage. If no managed work remains, Boatstack reports **Feature complete** and **No action required**. -For a small verified feature, `/boatstack-run` in Cursor or Claude Code and `$boatstack run` in Codex drive every declared delivery slice through PR publication. The command fetches `origin` before mutation and pauses for required plan approval, product decisions, and the exact PR open/update confirmation. Managed side effects use durable attempt receipts, so a lost response or new chat resumes or reconciles the same work instead of repeating it. Merge and deploy remain separate. +For a tiny, already-specified feature, use `/boatstack-run --to plan|verified|pr` in Cursor or Claude Code or `$boatstack run --to plan|verified|pr` in Codex. If you omit the target, Boatstack asks once. It may choose only non-material, reversible implementation options that remain inside the specification and have repository evidence plus an independent oracle. `plan` stops at the reviewable plan, `verified` stops after build/test/review, and `pr` authorizes one normal open or update action. Merge and deploy remain separate. ## 1. Install it separately diff --git a/labs/12-product-engineering-loop/boatstack-distribution/README.md b/labs/12-product-engineering-loop/boatstack-distribution/README.md index 24f1082df..7646a190c 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/README.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/README.md @@ -87,7 +87,7 @@ That is all you need to learn up front. Boatstack shows one next action at a tim When you return after an interruption, run `/boatstack-next` in Claude Code, Cursor, or Gemini CLI. In Codex, run `$boatstack next`. Boatstack reports the repository-verified stage and one next action. It does not change state. It tells apart a feature that has not started from one that is complete. -`/boatstack-run` (or `$boatstack run` in Codex) starts from one saved plan. It continues through publication and pauses for approvals and product decisions. It checks branch freshness before delivery. It never merges or deploys. +`/boatstack-run --to plan|verified|pr` (or `$boatstack run --to …` in Codex) starts from one saved plan and stops at the selected goal. An explicit goal-driven run can resolve only low-risk, reversible, evidence-backed implementation choices inside the specification. Any material or uncertain choice still pauses. The `pr` target authorizes one normal PR open or update; it never merges or deploys. In Claude Code, Cursor, and Gemini CLI, that guidance moves through `/plan-gate` → `/build` → `/test-gate` → `/review-gate` → `/ship-gate`. In Codex, use the same operation names after `$boatstack`. diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-goal-driven-autonomous-run.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-goal-driven-autonomous-run.md new file mode 100644 index 000000000..12ce89fef --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-02-goal-driven-autonomous-run.md @@ -0,0 +1,3 @@ +### Drive small features to an explicit goal + +`boatstack run --to plan|verified|pr` can now record a scoped policy receipt for a tiny, already-specified feature. Boatstack chooses only reversible, evidence-backed implementation options with no protected product impact, stops at the selected goal, and fails closed when the plan, repository, branch, evidence, or PR action changes. 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 2bd89915c..4e0e4ccf4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/SKILL.md @@ -34,11 +34,11 @@ Run the project-local helper's read-only `next-status --repo . --json` inspectio To see every feature at once, run the read-only `.product-loop/bin/boatstack-helper flow frontier --repo .`. It lists each delivery, its observed position, and who owes the next step. To wait for a published PR to move (checks finish, a review lands, a merge happens), run the read-only `.product-loop/bin/boatstack-helper flow watch --repo .`. The watch observes on an interval and exits when the frontier changes, when nothing can move, or at its timeout. It never acts on what it sees. When it exits, run `next-status` again and continue from the fresh state. -## Run through ship +## Run to an explicit goal -For `$boatstack run`, `/boatstack-run`, or natural language such as “run Boatstack through ship,” first run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the approval fingerprint, and activation repeats it immediately before writing the immutable lock. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, or incomplete journey decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. +For `$boatstack run --to plan|verified|pr`, `/boatstack-run`, or a natural-language run request, resolve the target from the request. When it is absent, ask once for `plan`, `verified`, or `pr`. First run the read-only `next-status --repo . --json` and `operation-status --repo . --json`. Wait for an executing operation and reconcile unknown completion before retrying. When the host supplies the plan path, enter `auto-plan` with `--plan `; when no plan path is supplied, stop and ask the user for the plan to build. Return **Feature complete** only for a verified completed feature, and stop on unverified, ambiguous, stale, or invalid state. Schema-v3 `check-plan` runs the Git freshness preflight before it displays the plan fingerprint. Record the selected target with `record-autonomy --plan --target ` after all material questions are answered or every remaining question has a valid `RESOLVED_BY_POLICY` autonomy decision. Target `plan` stops after the valid reviewable plan. Targets `verified` and `pr` activate with `--autonomy ` and stop if that receipt becomes stale. A failed fetch, missing remote/base, stale base, upstream drift, wrong worktree, constrained branch mismatch, incomplete journey decision, or ineligible policy decision blocks without creating authority or consuming repair budget. Never repair freshness by merging, rebasing, switching or creating a constrained delivery branch, discarding changes, force-pushing, or broadening permissions. -After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation using the canonical semantics below, verify the resulting repository state, and resolve again. Continue across all declared slices. Pause for explicit `a` plan approval, material product questions, and the exact `o` or `u` PR confirmation; a valid answer resumes the foreground run in the current host session. The run invocation itself is never approval or publication authority. Same-intent test/review failures may be recorded and repaired for at most three complete repair-and-gate cycles per active slice; the durable delivery attempt count does not reset across turns or hosts. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or an exhausted budget. Persist execution facts and retry identity, never autonomous workflow intent; conversation is not workflow evidence. Completion means every slice PR is published for review, never merged or deployed — unless `delivery.terminal` is `merged`, in which case the flow continues past publication with prescribed read-only steps until the PR is observed merged (see the post-publish rules below). +After preflight, repeatedly run `next-status --repo . --json`, execute only its verified next operation, verify the resulting repository state, and resolve again. Continue across all declared slices until the selected target is reached. A policy receipt may resolve only a non-material, within-spec, reversible choice with one recommendation, repository evidence, no protected impact, and a runnable oracle. Record it as `RESOLVED_BY_POLICY`, never `ANSWERED`. Any failed or unknown condition pauses for the human. Target `verified` stops after current test and review evidence passes. Target `pr` supplies scoped authority for one normal open or update action recorded in `autonomy.md`; after the exact preview is revalidated, call `publish-pr --autonomy ` without asking for `o` or `u`. A changed plan, repository, branch, PR action, preview, evidence, or target invalidates that path. Same-intent test/review failures may be repaired for at most three complete cycles per active slice. Stop on amendments, ambiguity, safety failures, stale evidence, unsupported recovery, branch mismatch, or exhausted repairs. Never force-push, merge, deploy, or execute a foreign program. When `delivery.terminal` is `merged`, follow the post-publish prescriptions exactly. After publication, run `flow next` (or `next-status`). When it prescribes `flow watch`, run the watch and re-resolve when it exits. When checks fail, it prescribes `record-change --source-stage ci`; derive the exact message, classification, evidence, and changed repair mechanism from the failing check logs, never from memory, then repair, re-gate, and republish with `publish-pr --action update`. When the PR is observed merge-eligible, it prescribes the exact `gh pr merge` command; run it only as rendered, under the host's own permissions — Boatstack never merges, and you never merge without the prescription. A required review approval, a changes-requested verdict, a closed PR, or an unverifiable PR position always ends your turn at the operator frontier. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md index f3897b7ea..ebb820e95 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/plan.md @@ -18,6 +18,7 @@ "source_plan_path": "source-plan.md", "spec_path": "feature-spec.md", "blocking_questions": [], + "autonomy_decisions": [], "acceptance_criteria": [ { "id": "AC-1", @@ -84,6 +85,13 @@ ``` +Keep `autonomy_decisions` empty for the normal human-driven flow. An explicit +goal-driven run may add a decision only with `resolution: RESOLVED_BY_POLICY`, +one selected recommended option, `material: false`, `within_spec: true`, +`reversible: true`, every protected `impact` flag false, repository +`evidence_ids`, a rationale, and a runnable `verification` with an independent +oracle. + For an external write, replace the empty `side_effects` list with entries such as: ```json diff --git a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/questions.md b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/questions.md index 631a3c674..aa75f6f99 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/questions.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/assets/templates/questions.md @@ -3,6 +3,6 @@ | ID | Question | Why it matters | Options | Recommendation | Answer | Source | Status/expiry | |---|---|---|---|---|---|---|---| -Use `ANSWERED` only for an explicit human answer or an authoritative existing contract. Repository inference is `PROPOSED` until the human accepts it. Material unanswered questions remain `OPEN`, appear in `plan.md` as `blocking_questions`, and block approval. +Use `ANSWERED` only for an explicit human answer or an authoritative existing contract. In an explicit goal-driven run, use `RESOLVED_BY_POLICY` only when the structured autonomy envelope proves the choice non-material, within-spec, reversible, free of protected impacts, evidence-backed, and independently verifiable. Repository inference is otherwise `PROPOSED`. Material unanswered questions remain `OPEN`, appear in `plan.md` as `blocking_questions`, and block activation. When presenting finite questions, give every choice a compact inline-code key (`1a`, `1b`, `1c`, then `2a`, `2b`, and so on) and suffix exactly one choice per question with `(Recommended)`. End with one reply hint: name the keys for explicit selection, or use `r` to accept all displayed recommendations. A standalone `r` is `ANSWERED` human provenance only after the selected question-to-answer mapping is echoed; it is never an agent-selected default. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/autonomy.go b/labs/12-product-engineering-loop/product-engineering-loop/autonomy.go new file mode 100644 index 000000000..a85f21a6e --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/autonomy.go @@ -0,0 +1,342 @@ +package boatstack + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +const ( + autonomyMarkerStart = "" + autonomyMarkerEnd = "" +) + +type RunTarget string + +const ( + RunTargetPlan RunTarget = "plan" + RunTargetVerified RunTarget = "verified" + RunTargetPR RunTarget = "pr" +) + +type AutonomyImpact struct { + PublicContract bool `json:"public_contract"` + AcceptanceCriteria bool `json:"acceptance_criteria"` + Security bool `json:"security"` + Billing bool `json:"billing"` + Migration bool `json:"migration"` + HighRiskPath bool `json:"high_risk_path"` + Destructive bool `json:"destructive"` + ExternalTarget bool `json:"external_target"` +} + +type AutonomyOption struct { + ID string `json:"id"` + Text string `json:"text"` + Recommended bool `json:"recommended"` +} + +type AutonomyVerification struct { + Run string `json:"run"` + Oracle string `json:"oracle"` +} + +type AutonomyDecision struct { + ID string `json:"id"` + Question string `json:"question"` + Resolution string `json:"resolution"` + SelectedOption string `json:"selected_option"` + Options []AutonomyOption `json:"options"` + Material bool `json:"material"` + WithinSpec bool `json:"within_spec"` + Reversible bool `json:"reversible"` + Impact AutonomyImpact `json:"impact"` + EvidenceIDs []string `json:"evidence_ids"` + Verification AutonomyVerification `json:"verification"` + Rationale string `json:"rationale"` +} + +type AutonomyReceipt struct { + SchemaVersion int `json:"schema_version"` + Feature string `json:"feature"` + Target RunTarget `json:"target"` + Repository string `json:"repository"` + Branch string `json:"branch"` + IssuingBranch string `json:"issuing_branch,omitempty"` + PRAction string `json:"pr_action,omitempty"` + PlanPath string `json:"plan_path"` + PlanFingerprint string `json:"plan_fingerprint"` + Decisions []AutonomyDecision `json:"decisions"` + Evidence []EvidenceRecord `json:"evidence"` + Fingerprint string `json:"fingerprint"` +} + +type AutonomyRecordOptions struct { + Repo string + PlanPath string + Target RunTarget + OutputPath string +} + +func autonomyPathIf(enabled bool, path string) string { + if enabled { + return path + } + return "" +} + +func selectedAutonomyEvidence(decisions []AutonomyDecision, ledger map[string]EvidenceRecord) ([]EvidenceRecord, error) { + selected := map[string]EvidenceRecord{} + for _, decision := range decisions { + for _, id := range decision.EvidenceIDs { + record, ok := ledger[id] + if !ok || record.ID == "" || record.Operation == "" || record.RepositoryRevision == "" || record.CreatedBy == "" { + return nil, fmt.Errorf("autonomy evidence %s is missing required repository provenance", id) + } + selected[id] = record + } + } + ids := make([]string, 0, len(selected)) + for id := range selected { + ids = append(ids, id) + } + sort.Strings(ids) + result := make([]EvidenceRecord, 0, len(ids)) + for _, id := range ids { + result = append(result, selected[id]) + } + return result, nil +} + +func ParseRunTarget(value string) (RunTarget, error) { + target := RunTarget(strings.ToLower(strings.TrimSpace(value))) + switch target { + case RunTargetPlan, RunTargetVerified, RunTargetPR: + return target, nil + default: + return "", fmt.Errorf("run target must be plan, verified, or pr") + } +} + +func autonomyDecisions(plan map[string]any) ([]AutonomyDecision, error) { + raw, present := plan["autonomy_decisions"] + if !present { + return []AutonomyDecision{}, nil + } + data, err := MarshalJSON(raw) + if err != nil { + return nil, fmt.Errorf("autonomy_decisions must be a list: %w", err) + } + var decisions []AutonomyDecision + if err := DecodeJSON("autonomy decisions", "plan.md", data, &decisions); err != nil { + return nil, fmt.Errorf("autonomy_decisions must be a typed list: %w", err) + } + return decisions, nil +} + +func validateAutonomyDecisions(plan map[string]any, evidence map[string]EvidenceRecord) ([]AutonomyDecision, error) { + decisions, err := autonomyDecisions(plan) + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, decision := range decisions { + if decision.ID == "" || seen[decision.ID] { + return nil, fmt.Errorf("autonomy decision ids must be present and unique") + } + seen[decision.ID] = true + if decision.Resolution != "RESOLVED_BY_POLICY" { + return nil, fmt.Errorf("autonomy decision %s resolution must be RESOLVED_BY_POLICY", decision.ID) + } + if decision.Material || !decision.WithinSpec || !decision.Reversible { + return nil, fmt.Errorf("autonomy decision %s must be non-material, within-spec, and reversible", decision.ID) + } + impact := decision.Impact + if impact.PublicContract || impact.AcceptanceCriteria || impact.Security || impact.Billing || impact.Migration || impact.HighRiskPath || impact.Destructive || impact.ExternalTarget { + return nil, fmt.Errorf("autonomy decision %s has a protected impact", decision.ID) + } + recommended := "" + for _, option := range decision.Options { + if option.ID == "" || option.Text == "" { + return nil, fmt.Errorf("autonomy decision %s has a malformed option", decision.ID) + } + if option.Recommended { + if recommended != "" { + return nil, fmt.Errorf("autonomy decision %s must have exactly one recommendation", decision.ID) + } + recommended = option.ID + } + } + if recommended == "" || decision.SelectedOption != recommended { + return nil, fmt.Errorf("autonomy decision %s must select its single recommended option", decision.ID) + } + if len(decision.EvidenceIDs) == 0 || decision.Verification.Run == "" || decision.Verification.Oracle == "" || decision.Rationale == "" { + return nil, fmt.Errorf("autonomy decision %s requires evidence, rationale, and a runnable verification oracle", decision.ID) + } + for _, id := range decision.EvidenceIDs { + if evidence != nil { + if _, ok := evidence[id]; !ok { + return nil, fmt.Errorf("autonomy decision %s references unknown evidence %s", decision.ID, id) + } + } + } + } + return decisions, nil +} + +func autonomyFingerprint(receipt AutonomyReceipt) (string, error) { + receipt.Fingerprint = "" + data, err := MarshalJSON(receipt) + if err != nil { + return "", err + } + return SHA256Bytes(data), nil +} + +func RecordAutonomy(options AutonomyRecordOptions) (AutonomyReceipt, error) { + target, err := ParseRunTarget(string(options.Target)) + if err != nil { + return AutonomyReceipt{}, err + } + check, err := CheckPlan(options.PlanPath) + if err != nil { + return AutonomyReceipt{}, err + } + repo, err := ResolveRepository(options.Repo) + if err != nil { + return AutonomyReceipt{}, err + } + evidence, err := LoadEvidenceLedger(filepath.Join(filepath.Dir(options.PlanPath), "evidence.json")) + if err != nil { + return AutonomyReceipt{}, err + } + decisions, err := validateAutonomyDecisions(check.Plan, evidence) + if err != nil { + return AutonomyReceipt{}, err + } + selectedEvidence, err := selectedAutonomyEvidence(decisions, evidence) + if err != nil { + return AutonomyReceipt{}, err + } + branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD") + branch = strings.TrimSpace(branch) + if err != nil || branch == "" || branch == "HEAD" { + return AutonomyReceipt{}, fmt.Errorf("autonomy requires an identifiable current branch") + } + issuingBranch := branch + feature := stringValue(check.Plan["feature_id"]) + if workspaceEnabled(repo) && needsFreshCut(repo, feature) { + branch = branchForFeature(feature) + } + repository, err := gitCommand(repo, "remote", "get-url", "origin") + if err != nil { + return AutonomyReceipt{}, fmt.Errorf("autonomy requires an origin repository identity") + } + action := "" + if target == RunTargetPR { + action, _, err = RecommendedPRAction(repo) + if err != nil { + return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action: %w", err) + } + if action != "open" && action != "update" { + return AutonomyReceipt{}, fmt.Errorf("PR target requires a stable open or update action") + } + } + receipt := AutonomyReceipt{SchemaVersion: 1, Feature: feature, Target: target, Repository: strings.TrimSpace(repository), Branch: branch, IssuingBranch: issuingBranch, PRAction: action, PlanPath: filepath.ToSlash(options.PlanPath), PlanFingerprint: check.Fingerprint, Decisions: decisions, Evidence: selectedEvidence} + receipt.Fingerprint, err = autonomyFingerprint(receipt) + if err != nil { + return AutonomyReceipt{}, err + } + if options.OutputPath == "" { + options.OutputPath = filepath.Join(filepath.Dir(options.PlanPath), "autonomy.md") + } + expectedOutput := filepath.Join(filepath.Dir(options.PlanPath), "autonomy.md") + actualOutput, _ := filepath.Abs(options.OutputPath) + expectedOutput, _ = filepath.Abs(expectedOutput) + if filepath.Clean(actualOutput) != filepath.Clean(expectedOutput) { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt must be written beside plan.md as autonomy.md") + } + body, err := MarshalJSON(receipt) + if err != nil { + return AutonomyReceipt{}, err + } + content := []byte("# Boatstack autonomous run\n\nThis receipt records scoped policy authority. It is not human plan approval.\n\n" + autonomyMarkerStart + "\n```json\n" + string(body) + "```\n" + autonomyMarkerEnd + "\n") + if err := writeFile(options.OutputPath, content, 0o644); err != nil { + return AutonomyReceipt{}, err + } + return receipt, nil +} + +func CheckAutonomyReceipt(path string, check PlanCheck, repo string, minimumTarget RunTarget, action string) (AutonomyReceipt, error) { + return checkAutonomyReceipt(path, check, repo, minimumTarget, action, false) +} + +// CheckAutonomyReceiptForPlanning permits the receipt's issuing branch only +// while resolving the pre-cut planning transition. Activation and publication +// always require the exact feature branch bound into Branch. +func CheckAutonomyReceiptForPlanning(path string, check PlanCheck, repo string, minimumTarget RunTarget) (AutonomyReceipt, error) { + return checkAutonomyReceipt(path, check, repo, minimumTarget, "", true) +} + +func checkAutonomyReceipt(path string, check PlanCheck, repo string, minimumTarget RunTarget, action string, allowIssuingBranch bool) (AutonomyReceipt, error) { + value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true) + if err != nil { + return AutonomyReceipt{}, err + } + data, err := MarshalJSON(value) + if err != nil { + return AutonomyReceipt{}, err + } + var receipt AutonomyReceipt + if err := DecodeJSON("autonomy receipt", path, data, &receipt); err != nil { + return AutonomyReceipt{}, err + } + want, err := autonomyFingerprint(receipt) + if err != nil || receipt.SchemaVersion != 1 || receipt.Fingerprint != want { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt fingerprint is invalid") + } + if receipt.Feature != stringValue(check.Plan["feature_id"]) || receipt.PlanFingerprint != check.Fingerprint { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt does not match the current plan") + } + currentRepo, err := gitCommand(repo, "remote", "get-url", "origin") + if err != nil || strings.TrimSpace(currentRepo) != receipt.Repository { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt repository identity changed") + } + branch, err := gitCommand(repo, "rev-parse", "--abbrev-ref", "HEAD") + branch = strings.TrimSpace(branch) + branchMatches := branch == receipt.Branch || (allowIssuingBranch && receipt.IssuingBranch != "" && branch == receipt.IssuingBranch) + if err != nil || !branchMatches { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt branch identity changed") + } + rank := map[RunTarget]int{RunTargetPlan: 1, RunTargetVerified: 2, RunTargetPR: 3} + if rank[receipt.Target] < rank[minimumTarget] { + return AutonomyReceipt{}, fmt.Errorf("autonomy target %s does not authorize %s", receipt.Target, minimumTarget) + } + if action != "" && receipt.PRAction != action { + return AutonomyReceipt{}, fmt.Errorf("autonomy receipt authorizes PR action %s, not %s", receipt.PRAction, action) + } + evidence, err := LoadEvidenceLedger(filepath.Join(filepath.Dir(check.PlanPath), "evidence.json")) + if err != nil { + return AutonomyReceipt{}, err + } + decisions, err := validateAutonomyDecisions(check.Plan, evidence) + if err != nil { + return AutonomyReceipt{}, err + } + wantDecisions, _ := MarshalJSON(decisions) + gotDecisions, _ := MarshalJSON(receipt.Decisions) + if SHA256Bytes(wantDecisions) != SHA256Bytes(gotDecisions) { + return AutonomyReceipt{}, fmt.Errorf("autonomy decision set changed") + } + selectedEvidence, err := selectedAutonomyEvidence(decisions, evidence) + if err != nil { + return AutonomyReceipt{}, err + } + wantEvidence, _ := MarshalJSON(selectedEvidence) + gotEvidence, _ := MarshalJSON(receipt.Evidence) + if SHA256Bytes(wantEvidence) != SHA256Bytes(gotEvidence) { + return AutonomyReceipt{}, fmt.Errorf("autonomy evidence changed") + } + return receipt, nil +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/autonomy_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/autonomy_conformance_test.go new file mode 100644 index 000000000..3b767d94a --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/autonomy_conformance_test.go @@ -0,0 +1,223 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func eligibleAutonomyDecision() AutonomyDecision { + return AutonomyDecision{ + ID: "Q-1", Question: "Which existing helper should this use?", Resolution: "RESOLVED_BY_POLICY", + SelectedOption: "1a", Options: []AutonomyOption{{ID: "1a", Text: "Reuse helper", Recommended: true}, {ID: "1b", Text: "Inline logic"}}, + WithinSpec: true, Reversible: true, EvidenceIDs: []string{"ev_1"}, + Verification: AutonomyVerification{Run: "go test ./...", Oracle: "existing conformance test passes"}, + Rationale: "The repository already owns the helper.", + } +} + +// control-law: autonomy-receipt-binds-policy-activation-to-plan-repository-and-branch +func TestAutonomyReceiptOverridesHumanPlanGateOnlyForExactPlan(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") + runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git") + writeActivationConfig(t, root, true) + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record planning inputs") + autonomyPath := filepath.Join(root, "autonomy.md") + receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified, OutputPath: autonomyPath}) + if err != nil { + t.Fatal(err) + } + compiled := filepath.Join(root, "compiled") + lockPath := filepath.Join(root, "plan.lock.json") + if err := ActivatePlan(ActivationOptions{PlanPath: planPath, AutonomyPath: autonomyPath, OutDir: compiled, OutputPath: lockPath, SourceCommit: "test"}); err != nil { + t.Fatal(err) + } + value, err := os.ReadFile(lockPath) + if err != nil { + t.Fatal(err) + } + lock := map[string]any{} + if err := json.Unmarshal(value, &lock); err != nil { + t.Fatal(err) + } + if stringValue(lock["authorization_mode"]) != "policy" || stringValue(lock["autonomy_fingerprint"]) != receipt.Fingerprint || stringValue(lock["run_target"]) != "verified" { + t.Fatalf("activation did not bind scoped policy authority: %#v", lock) + } + planBytes, _ := os.ReadFile(planPath) + if err := os.WriteFile(planPath, append([]byte("changed\n"), planBytes...), 0o644); err != nil { + t.Fatal(err) + } + if _, err := CheckAutonomyReceipt(autonomyPath, PlanCheck{Plan: validPlan(), PlanPath: planPath, Fingerprint: "changed"}, root, RunTargetVerified, ""); err == nil { + t.Fatal("changed plan retained autonomous authority") + } +} + +// control-law: pre-cut-policy-authority-binds-the-future-managed-branch +func TestAutonomyReceiptBindsFreshWorkspaceBranch(t *testing.T) { + root := workspaceRepo(t, defaultWorkspace()) + runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git") + _, _, planPath := writePlanInputs(t, root, true) + receipt, err := RecordAutonomy(AutonomyRecordOptions{Repo: root, PlanPath: planPath, Target: RunTargetVerified}) + if err != nil { + t.Fatal(err) + } + if receipt.IssuingBranch != "main" || receipt.Branch != "feat/feature-one" { + t.Fatalf("receipt branches = issuing %q target %q", receipt.IssuingBranch, receipt.Branch) + } + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(filepath.Dir(planPath), "autonomy.md") + if _, err := CheckAutonomyReceiptForPlanning(path, check, root, RunTargetPlan); err != nil { + t.Fatalf("pre-cut planning check: %v", err) + } + if _, err := CheckAutonomyReceipt(path, check, root, RunTargetVerified, ""); err == nil || !strings.Contains(err.Error(), "branch identity changed") { + t.Fatalf("activation on issuing branch should fail, got %v", err) + } +} + +// control-law: autonomous-pr-authority-correlates-one-repository-branch-and-action +func TestAutonomyReceiptRejectsDifferentPRAction(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") + runGit(t, root, "remote", "add", "origin", "https://example.invalid/operatorstack/example.git") + writeActivationConfig(t, root, true) + runGit(t, root, "add", ".") + runGit(t, root, "commit", "-m", "record planning inputs") + check, err := CheckPlan(planPath) + if err != nil { + t.Fatal(err) + } + receipt := AutonomyReceipt{SchemaVersion: 1, Feature: stringValue(check.Plan["feature_id"]), Target: RunTargetPR, Repository: "https://example.invalid/operatorstack/example.git", Branch: "main", PRAction: "open", PlanPath: planPath, PlanFingerprint: check.Fingerprint, Decisions: []AutonomyDecision{}, Evidence: []EvidenceRecord{}} + receipt.Fingerprint, err = autonomyFingerprint(receipt) + if err != nil { + t.Fatal(err) + } + body, _ := MarshalJSON(receipt) + path := filepath.Join(root, "autonomy.md") + content := []byte("# Boatstack autonomous run\n\n" + autonomyMarkerStart + "\n```json\n" + string(body) + "```\n" + autonomyMarkerEnd + "\n") + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + if _, err := CheckAutonomyReceipt(path, check, root, RunTargetPR, "open"); err != nil { + t.Fatal(err) + } + if _, err := CheckAutonomyReceipt(path, check, root, RunTargetPR, "update"); err == nil || !strings.Contains(err.Error(), "open, not update") { + t.Fatalf("changed PR action retained authority: %v", err) + } +} + +func autonomyPlan(decision AutonomyDecision) map[string]any { + return map[string]any{"autonomy_decisions": []AutonomyDecision{decision}} +} + +// control-law: autonomous-decisions-stay-inside-the-declared-low-risk-envelope +func TestAutonomyEligibilityAcceptsOnlyCompleteLowRiskDecision(t *testing.T) { + decision := eligibleAutonomyDecision() + got, err := validateAutonomyDecisions(autonomyPlan(decision), map[string]EvidenceRecord{"ev_1": {ID: "ev_1"}}) + if err != nil || len(got) != 1 || got[0].Resolution != "RESOLVED_BY_POLICY" { + t.Fatalf("eligible decision was not accepted: %#v, %v", got, err) + } +} + +// control-law: autonomous-decisions-stay-inside-the-declared-low-risk-envelope +func TestAutonomyEligibilityRejectsEveryProtectedImpact(t *testing.T) { + cases := map[string]func(*AutonomyDecision){ + "material": func(d *AutonomyDecision) { d.Material = true }, + "outside-spec": func(d *AutonomyDecision) { d.WithinSpec = false }, + "irreversible": func(d *AutonomyDecision) { d.Reversible = false }, + "public-contract": func(d *AutonomyDecision) { d.Impact.PublicContract = true }, + "acceptance": func(d *AutonomyDecision) { d.Impact.AcceptanceCriteria = true }, + "security": func(d *AutonomyDecision) { d.Impact.Security = true }, + "billing": func(d *AutonomyDecision) { d.Impact.Billing = true }, + "migration": func(d *AutonomyDecision) { d.Impact.Migration = true }, + "high-risk": func(d *AutonomyDecision) { d.Impact.HighRiskPath = true }, + "destructive": func(d *AutonomyDecision) { d.Impact.Destructive = true }, + "external-target": func(d *AutonomyDecision) { d.Impact.ExternalTarget = true }, + "missing-evidence": func(d *AutonomyDecision) { d.EvidenceIDs = nil }, + "missing-oracle": func(d *AutonomyDecision) { d.Verification.Oracle = "" }, + "wrong-provenance": func(d *AutonomyDecision) { d.Resolution = "ANSWERED" }, + "wrong-selection": func(d *AutonomyDecision) { d.SelectedOption = "1b" }, + "two-recommendations": func(d *AutonomyDecision) { d.Options[1].Recommended = true }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + decision := eligibleAutonomyDecision() + mutate(&decision) + if _, err := validateAutonomyDecisions(autonomyPlan(decision), map[string]EvidenceRecord{"ev_1": {ID: "ev_1"}}); err == nil { + t.Fatal("ineligible decision was accepted") + } + }) + } +} + +// control-law: every-question-resolution-path-reaches-the-shared-decision-boundary +func TestQuestionResolutionPathInventory(t *testing.T) { + paths := []struct { + name string + input PlanDecisionInput + want DecisionOperator + }{ + {"verified repository fact", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceVerified}, OperatorInfer}, + {"supported fact", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceSupported}, OperatorVerify}, + {"material intent", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, IsMaterial: true}, OperatorQuery}, + {"eligible policy choice", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, AutonomyEligible: true}, OperatorPolicy}, + {"invalid premise", PlanDecisionInput{PremiseStatus: PremiseInvalid}, OperatorReject}, + {"conflicting evidence", PlanDecisionInput{PremiseStatus: PremiseValid, EvidenceLevel: EvidenceConflicting}, OperatorEscalate}, + {"unknown uncertainty", PlanDecisionInput{PremiseStatus: PremiseUnknown, EvidenceLevel: EvidenceAbsent}, OperatorEscalate}, + } + for _, path := range paths { + t.Run(path.name, func(t *testing.T) { + if got := ResolvePlanDecision(path.input).Operator; got != path.want { + t.Fatalf("resolution = %s, want %s", got, path.want) + } + }) + } + if len(paths) != 7 || strings.TrimSpace(string(OperatorPolicy)) == "" { + t.Fatal("question-resolution inventory is incomplete") + } +} + +// control-law: every-question-resolution-path-reaches-the-shared-decision-boundary +func TestQuestionResolutionEntryPointInventory(t *testing.T) { + entries := map[string][]string{ + "decision.go": {"ResolvePlanDecision", "eligible-nonmaterial-policy-resolution"}, + "plan_validation.go": {"validatePlanAutonomy", "validateAutonomyDecisions"}, + "autonomy.go": {"RecordAutonomy", "CheckAutonomyReceipt", "validateAutonomyDecisions"}, + "export.go": {"shared decision boundary", "RESOLVED_BY_POLICY"}, + "SKILL.md": {"RESOLVED_BY_POLICY", "Any failed or unknown condition pauses for the human"}, + } + for path, snippets := range entries { + value, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + for _, snippet := range snippets { + if !strings.Contains(string(value), snippet) { + t.Fatalf("question-resolution entry %s bypasses boundary marker %q", path, snippet) + } + } + } +} + +func TestRunTargetClosedVocabulary(t *testing.T) { + for _, value := range []string{"plan", "verified", "pr"} { + if _, err := ParseRunTarget(value); err != nil { + t.Fatalf("%s: %v", value, err) + } + } + if _, err := ParseRunTarget("merge"); err == nil { + t.Fatal("merge became an autonomous target") + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go index 1cc8b1bfb..086975ec7 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/coverage_conformance_test.go @@ -47,6 +47,7 @@ var nonDeliveryVerbs = map[string]bool{ "check-plan": true, "planning-write": true, "record-approval": true, + "record-autonomy": true, // Read-only status / diagnostics (observe helpers, not modeled transitions). "repair-status": true, "operation-status": true, 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 c12d6bd84..3daf0b1e2 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 @@ -495,11 +495,12 @@ func activatePlanCommand(arguments []string) int { flags.StringVar(&options.OutDir, "out-dir", "", "compiled artifact directory") flags.StringVar(&options.OutputPath, "output", "", "plan lock path") flags.StringVar(&options.SourceCommit, "source-commit", "", "source Git commit") + flags.StringVar(&options.AutonomyPath, "autonomy", "", "fingerprinted autonomy.md receipt") if err := flags.Parse(arguments); err != nil { return 2 } 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")) + return fail(fmt.Errorf("activate-plan requires --plan, --out-dir, and --output; human_plan_approval additionally requires --approval unless a valid --autonomy receipt targets verified or pr")) } if err := boatstack.ActivatePlan(options); err != nil { return fail(fmt.Errorf("plan activation failed: %w", err)) @@ -509,6 +510,30 @@ func activatePlanCommand(arguments []string) int { return 0 } +func recordAutonomyCommand(arguments []string) int { + flags := flag.NewFlagSet("record-autonomy", flag.ContinueOnError) + repo := flags.String("repo", ".", "repository containing the feature package") + plan := flags.String("plan", "", "validated Markdown plan") + target := flags.String("target", "", "plan, verified, or pr") + output := flags.String("output", "", "autonomy.md path; defaults beside plan.md") + if err := flags.Parse(arguments); err != nil { + return 2 + } + if *plan == "" || *target == "" { + return fail(fmt.Errorf("record-autonomy requires --plan and --target plan|verified|pr")) + } + parsed, err := boatstack.ParseRunTarget(*target) + if err != nil { + return fail(err) + } + receipt, err := boatstack.RecordAutonomy(boatstack.AutonomyRecordOptions{Repo: *repo, PlanPath: *plan, Target: parsed, OutputPath: *output}) + if err != nil { + return fail(err) + } + fmt.Printf("PASS: scoped autonomous run recorded\nRUN_TARGET=%s\nAUTONOMY_FINGERPRINT=%s\n", receipt.Target, receipt.Fingerprint) + return 0 +} + func planningWriteCommand(arguments []string) int { flags := flag.NewFlagSet("planning-write", flag.ContinueOnError) repo := flags.String("repo", ".", "repository containing the feature package") @@ -1358,6 +1383,7 @@ func publishPRCommand(arguments []string) int { previewPath := flags.String("preview", "", "reviewed pr.md preview") fingerprint := flags.String("preview-fingerprint", "", "exact preview fingerprint confirmed by the human") action := flags.String("action", "", "open or update") + autonomy := flags.String("autonomy", "", "fingerprinted autonomy.md receipt authorizing the PR target") if err := flags.Parse(arguments); err != nil { return 2 } @@ -1375,6 +1401,7 @@ func publishPRCommand(arguments []string) int { } url, err := boatstack.PublishPR(boatstack.PRPublishOptions{ Repo: *repo, PreviewPath: *previewPath, ExpectedFingerprint: *fingerprint, Action: *action, + AutonomyPath: *autonomy, VisualPublisher: boatstack.SelectVisualPublisher(*repo), }) boatstack.RecordFlowTransition(*repo, boatstack.PublishTransition, guard.From, err == nil) @@ -1510,7 +1537,7 @@ func workspaceSyncCommand(arguments []string) int { func run() int { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") + fmt.Fprintln(os.Stderr, "usage: boatstack-helper ") return 2 } switch os.Args[1] { @@ -1554,6 +1581,8 @@ func run() int { return planningWriteCommand(os.Args[2:]) case "record-approval": return recordApprovalCommand(os.Args[2:]) + case "record-autonomy": + return recordAutonomyCommand(os.Args[2:]) case "activate-plan": return activatePlanCommand(os.Args[2:]) case "delivery-status": diff --git a/labs/12-product-engineering-loop/product-engineering-loop/decision.go b/labs/12-product-engineering-loop/product-engineering-loop/decision.go index 302fad328..669f4e278 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/decision.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/decision.go @@ -8,6 +8,7 @@ const ( OperatorVerify DecisionOperator = "verify" OperatorReject DecisionOperator = "reject" OperatorEscalate DecisionOperator = "escalate" + OperatorPolicy DecisionOperator = "policy" ) type EvidenceLevel string @@ -33,6 +34,7 @@ type PlanDecisionInput struct { RepositoryEvidence []EvidenceRecord EvidenceLevel EvidenceLevel PremiseStatus PremiseStatus + AutonomyEligible bool } type DecisionResolution struct { @@ -80,6 +82,13 @@ func ResolvePlanDecision(input PlanDecisionInput) DecisionResolution { return resolution } + if !input.IsMaterial && input.AutonomyEligible && input.EvidenceLevel == EvidenceAbsent && input.PremiseStatus == PremiseValid { + resolution.Operator = OperatorPolicy + resolution.RuleID = "eligible-nonmaterial-policy-resolution" + resolution.Reason = "declared autonomy evidence permits a bounded policy decision" + return resolution + } + if input.IsMaterial && input.EvidenceLevel == EvidenceAbsent { resolution.Operator = OperatorQuery resolution.RuleID = "material-intent-requires-human" diff --git a/labs/12-product-engineering-loop/product-engineering-loop/decision_test.go b/labs/12-product-engineering-loop/product-engineering-loop/decision_test.go index 1609e91e5..a6e4a7cce 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/decision_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/decision_test.go @@ -57,6 +57,22 @@ func TestResolvePlanDecision(t *testing.T) { }, expected: OperatorEscalate, }, + { + name: "eligible nonmaterial decision resolves by policy", + input: PlanDecisionInput{ + PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, + IsMaterial: false, AutonomyEligible: true, + }, + expected: OperatorPolicy, + }, + { + name: "material decision cannot resolve by policy", + input: PlanDecisionInput{ + PremiseStatus: PremiseValid, EvidenceLevel: EvidenceAbsent, + IsMaterial: true, AutonomyEligible: true, + }, + expected: OperatorQuery, + }, } for _, tt := range tests { 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 b2ac49978..af2f31145 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/export.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/export.go @@ -252,7 +252,7 @@ Run the %s operation from @.product-loop/workflow.md. Read @.product-loop/project.json, @.product-loop/artifacts.md, @.product-loop/verified-boundaries.md (if it exists), and only the minimal repository context relevant to the current feature. Do not propose or write code that violates established repository boundaries. %s -Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Auto-plan and plan-gate may create or update Markdown only. Classify authoritative repository facts as DISCOVERED, agent suggestions as PROPOSED, and only explicit human responses as ANSWERED. Every material proposal remains in blocking_questions; never label an agent default as answered. For 1-3 finite questions, use compact keys such as 1a/1b and 2a/2b, suffix exactly one choice per question with (Recommended), and offer r to accept all displayed recommendations. Treat r as explicit human acceptance only when every displayed question has exactly one recommendation; echo the selected mapping before recording the answers. Use the same format with structured question tools or plain text and return WAITING_FOR_INPUT internally. Never silently choose a default. Boatstack leaves implementation tactics open, but completion, approval, and shipping claims require current evidence. During managed delivery, read the active delivery slice, never push or mutate a PR directly, and require slice-scoped test and review receipts before ship-gate. A successful publication activates the next declared slice; parent-plan approval never skips its gates. +Use the gate semantics in the canonical workflow. Do not redefine them in this adapter. Auto-plan and plan-gate may create or update Markdown only. Classify authoritative repository facts as DISCOVERED, agent suggestions as PROPOSED, explicit human responses as ANSWERED, and eligible autonomous choices as RESOLVED_BY_POLICY. Every material proposal remains in blocking_questions. For 1-3 finite questions, use compact keys such as 1a/1b and 2a/2b, suffix exactly one choice per question with (Recommended), and offer r to accept all displayed recommendations. Treat r as explicit human acceptance only after you echo the selected mapping. Never silently choose a default outside an explicit goal-driven run. Inside $boatstack run --to plan|verified|pr, choose only when the typed autonomy envelope proves the question non-material, within-spec, reversible, free of protected impacts, evidence-backed, and independently verifiable; otherwise ask. Boatstack leaves implementation tactics open, but completion, authorization, and shipping claims require current evidence. During managed delivery, read the active delivery slice and require slice-scoped test and review receipts before ship-gate. A successful publication activates the next declared slice; plan authorization never skips its gates. Follow the User-facing response contract in @.product-loop/workflow.md. Lead with its mapped plain-language outcome, show only decision-relevant content, end with exactly one `+"`### Next step`"+`, and put machine status, helper output, fingerprints, artifact paths, receipts, and locks inside collapsed `+"`Technical details`"+`. Treat helper names in this command as internal control machinery; do not expose them in the primary response. Write your response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word. `, operation, operation, preflight, extra) + executionModeDX @@ -361,14 +361,18 @@ func BuildExportBundle(configPath string, config ProjectConfig, rawConfig []byte "workspace-cleanup": "Reclaim a published feature's managed workspace once its work has landed. This operation is surfaced by boatstack-next after publication; the user does not invoke it directly. Run the project-local helper workspace-status --repo . --branch to report whether the pull request is merged, using the GitHub CLI with a local-ancestry fallback. When workspace.cleanup_after is merge, offer removal only once the PR is confirmed merged; if it is still open, report that and offer to keep waiting or, only on an explicit human override request, proceed. Never remove a workspace with uncommitted or unmerged work without an explicit forced override, and never delete a remote branch or merge anything; cleanup reclaims only the local worktree and branch. In confirm mode respond Workspace ready to clean up and render the one next action as: Reply `c` to clean up, or `k` to keep. Only after the exact reply c run workspace-cleanup --repo . --branch with --confirm (add --force only for an explicit override); on k respond Workspace kept with no action required. In auto mode reclaim a merged workspace without a prompt; in off mode do not offer cleanup. After removal, report whether the worktree and branch were reclaimed.", "workspace-reap": "Sweep every terminal managed workspace at the safe post-merge checkpoint, reclaiming the accumulated backlog in one prompt. This operation is surfaced by boatstack-next when a delivery's PR is confirmed merged; the user does not invoke it directly. Run the project-local helper workspace-reap --repo . to inspect all Boatstack worktrees and branches — those created under .product-loop/worktrees/ — and identify which are reclaimable: confirmed merged (GitHub CLI with a local-ancestry fallback) or explicitly abandoned (their feature slug is in workflow.ignored_deliveries). Never reap an unmerged workspace with an open or unknown-state PR, a non-Boatstack worktree, the base branch, the current worktree, or a workspace with uncommitted or unmerged work without an explicit forced override; and never delete a remote branch or merge anything. In confirm mode the helper returns NEEDS_CONFIRMATION with the reclaimable count: respond N Boatstack worktrees/branches are merged or abandoned and reclaimable and render the one next action as: Reply `c` to reap, or `k` to keep. Only after the exact reply c run workspace-reap --repo . --confirm (add --force only for an explicit override); on k respond Workspaces kept with no action required. In auto mode reclaim them without a prompt; in off mode do not offer reaping. After removal, report how many worktrees and branches were reclaimed.", } + operations["boatstack-run"] = "Resolve an explicit target from --to plan|verified|pr or the user's wording; when absent, ask once for those three choices. If no source plan exists, respond Start a Boatstack feature. Run next-status --repo . --json and operation-status first and reconcile in-flight work. Enter auto-plan only with the supplied durable in-repo source plan. Before delivery mutation, run run-preflight --repo . --json; it may fetch origin and must stop on freshness failure. During planning, route every question through the shared decision boundary: only a non-material, within-spec, reversible choice with one recommendation, cited repository evidence, no protected impact, and a runnable independent oracle may be recorded as RESOLVED_BY_POLICY; every failed or unknown condition requires the human. After check-plan passes, run record-autonomy with the selected target. Target plan stops at the valid reviewable plan. Targets verified and pr pass autonomy.md to activate-plan, drive the canonical build, test, journey, and review operations, and stop on any stale evidence, new product decision, unsafe capability, branch mismatch, unsupported recovery, or exhausted three complete automated repair-and-gate cycles. After each successful canonical operation, automatically continue the run from freshly resolved repository state. Target verified stops after current test and review receipts pass. Target pr prepares and revalidates the exact PR preview, then passes autonomy.md to publish-pr for the single recorded open or update action without asking for o or u. Changed plan, repository, branch, target, PR action, preview, or receipt invalidates publication. Runs without autonomy.md preserve human plan approval and o/u publication confirmation. Never force-push, discard changes, or execute foreign programs; never merge or deploy. When the selected goal is already reached, respond Feature complete. Report the selected target, policy decisions, current stage, stop reason, and one next action." + operations["boatstack-run"] += " If status is NOT_STARTED, route to auto-plan; planning and plan-gate do not require delivery preflight. If Cursor reports MainThreadShellExec not initialized, make Developer: Reload Window the one recovery action." operations["auto-plan"] += " Use plan schema v3. Record journey_evidence as relevant with typed runnable oracles mapped to acceptance criteria, or not_relevant with a reason." operations["plan-gate"] += " check-plan must return current READINESS_FINGERPRINT before approval is displayed. Stop on any branch, worktree, origin, base, upstream, or journey-capability block." operations["build"] = strings.Replace(operations["build"], "compiled task graph, test matrix, evidence ledger, and the plan lock", "compiled task graph, test matrix, evidence ledger, journey-oracle manifest, and the plan lock", 1) operations["build"] = strings.Replace(operations["build"], "so all four land", "so all five land", 1) operations["build"] += " Activation repeats readiness atomically and compiles journey-oracles.json into the same immutable authority boundary." + operations["build"] += " For a goal-driven run targeting verified or pr, require the current autonomy.md and pass it to activate-plan with --autonomy; this scoped receipt selects policy authorization even when the project normally requires human plan approval." operations["repair"] += " Every repair classification requires --mechanism. Implementation, verification, and review repairs each have an independent three-attempt budget; identical class/evidence/mechanism retries are friction and amendments or readiness recovery consume no attempts." operations["test-gate"] += " For relevant journey evidence, run every compiled oracle and import current typed results with record-journey-results before the gate." operations["review-gate"] += " Reject missing, failed, manifest-mismatched, head-mismatched, or diff-stale required journey results." + operations["ship-gate"] += " When a current autonomy.md has target pr, its repository, branch, plan, and open/update action match, and the exact preview fingerprint still passes check-pr, invoke publish-pr with --autonomy without asking for o or u. Any mismatch invalidates autonomous publication and returns to the operator; runs without that receipt retain the normal confirmation." if contains(adapters, "cursor") { rule := fmt.Sprintf(`--- @@ -412,7 +416,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. Write every response in Simplified Technical English: short sentences, the active voice, the present tense, one idea per sentence, the condition first, and the simple common word. -Ordinary product intent must first be explored in the host's Plan mode and saved as a file. The host passes that plan to auto-plan explicitly with --plan , which auto-plan validates and records as source_plan_path. Boatstack never scans directories for plans, so --plan is required; if no plan path is supplied, auto-plan is BLOCKED and must not guess or create a substitute. The plan must live inside the repository so it stays committed and hash-current through build; an out-of-repo path is rejected. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, and approval.md records explicit acceptance only when human approval is enabled. Planning Markdown is authored through the bounded planning-write helper — the owned channel for feature artifacts, not a fallback; use the host's normal Markdown writer only where it is explicitly permitted, and arbitrary shell redirection never. 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. +Ordinary product intent must first be explored in the host's Plan mode and saved as a file. The host passes that plan to auto-plan explicitly with --plan , which auto-plan validates and records as source_plan_path. Boatstack never scans directories for plans, so --plan is required. The plan must live inside the repository so it stays committed and hash-current through build. Auto-plan and plan-gate write Markdown only: plan.md remains canonical, approval.md records explicit human acceptance, and autonomy.md records invocation-scoped policy authority for $boatstack run --to plan|verified|pr. Repository facts are DISCOVERED, agent suggestions are PROPOSED, human responses are ANSWERED, and eligible autonomous choices are RESOLVED_BY_POLICY. Every material proposal remains blocking. At build, confirm the host can edit product code before activation. A rejected mode transition creates no machine artifacts or lock. Activation compiles machine artifacts and binds either human approval or the exact autonomy receipt 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 eb85659d2..ffcf9bbd3 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -35,11 +35,34 @@ type NextStatus struct { PRURL string `json:"pr_url,omitempty"` HeadBranch string `json:"head_branch,omitempty"` ParentDelivery string `json:"parent_delivery,omitempty"` + RunTarget RunTarget `json:"run_target,omitempty"` + PolicyDecisions int `json:"policy_decisions,omitempty"` // VisualPublication surfaces an owed evidence attachment of a published // PR ("visual_pending" or "manual_required"); empty otherwise. VisualPublication string `json:"visual_publication,omitempty"` } +func decorateAutonomyStatus(repo string, status NextStatus) NextStatus { + if status.Feature == "" { + return status + } + path := filepath.Join(repo, ".product-loop", "features", status.Feature, "autonomy.md") + value, err := loadJSONObject(path, "autonomy receipt", autonomyMarkerStart, autonomyMarkerEnd, true) + if err != nil { + return status + } + status.RunTarget = RunTarget(stringValue(value["target"])) + if decisions, ok := objectSlice(value["decisions"]); ok { + status.PolicyDecisions = len(decisions) + } + if status.RunTarget == RunTargetVerified && (status.ObservedStage == StatusReviewPassed || status.ObservedStage == "PR_PREVIEW") { + status.ObservedStage = "VERIFIED_TARGET_REACHED" + status.NextOperation = "none" + status.Reason = "The autonomous run reached its build, test, and review target without publishing." + } + return status +} + func blockedNextStatus(stage, operation, reason string, ambiguity ...string) NextStatus { return NextStatus{ SchemaVersion: nextStatusSchemaVersion, VerificationStatus: "BLOCKED", @@ -142,7 +165,7 @@ func nextForDelivery(repo, feature string) (NextStatus, error) { default: return NextStatus{}, fmt.Errorf("managed delivery slice %s has unsupported status %q", slice.ID, slice.Status) } - return status, nil + return decorateAutonomyStatus(repo, status), nil } func nextForPublished(repo string, state DeliveryState) NextStatus { @@ -295,7 +318,7 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { base.ObservedStage = "NOT_INITIALIZED" base.NextOperation = "init" base.Reason = "This repository has no Boatstack project installation to inspect." - return base, nil + return decorateAutonomyStatus(repo, base), nil } config, _, configErr := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) if configErr != nil { @@ -391,7 +414,26 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { directory := filepath.Join(repo, ".product-loop", "features", feature) base.VerificationStatus = "VERIFIED" base.Feature = feature - if !config.Workflow.HumanPlanApproval { + policyReady := !config.Workflow.HumanPlanApproval + autonomyPath := filepath.Join(directory, "autonomy.md") + if fileExists(autonomyPath) { + check, checkErr := CheckPlan(filepath.Join(directory, "plan.md")) + if checkErr != nil { + return blockedNextStatus("INVALID_STATE", "auto-plan", "The autonomous run plan is no longer valid: "+checkErr.Error(), feature), nil + } + autonomy, autonomyErr := CheckAutonomyReceiptForPlanning(autonomyPath, check, repo, RunTargetPlan) + if autonomyErr != nil { + return blockedNextStatus("INVALID_STATE", "auto-plan", "The autonomous run receipt is stale or invalid: "+autonomyErr.Error(), feature), nil + } + if autonomy.Target == RunTargetPlan { + base.ObservedStage = "PLAN_READY" + base.NextOperation = "none" + base.Reason = "The autonomous run reached its reviewable-plan target." + return decorateAutonomyStatus(repo, base), nil + } + policyReady = true + } + if policyReady { base.ObservedStage = "POLICY_READY" base.NextOperation = "build" base.Reason = "The saved feature is ready for fingerprinted policy activation without a human approval receipt." @@ -414,7 +456,7 @@ func ResolveNext(repoPath, explicitFeature string) (NextStatus, error) { base.NextOperation = "plan-gate" base.Reason = "The saved feature plan has not been approved." } - return base, nil + return decorateAutonomyStatus(repo, base), nil } completed, err := completedManagedStates(repo) @@ -483,6 +525,9 @@ func FormatNextStatus(status NextStatus) string { if status.Feature != "" { parts = append(parts, "Feature: "+status.Feature) } + if status.RunTarget != "" { + parts = append(parts, "Run target: "+string(status.RunTarget), fmt.Sprintf("Policy decisions: %d", status.PolicyDecisions)) + } if status.ActiveSlice != "" { if status.TotalSlices > 1 { parts = append(parts, fmt.Sprintf("Active slice: %s (PR %d of %d)", status.ActiveSlice, status.SliceIndex, status.TotalSlices)) 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 879867383..176d3aa77 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan.go @@ -439,6 +439,9 @@ func ValidatePlan(plan map[string]any, opts *ValidatePlanOptions) error { if err := validatePRVisualEvidence(plan); err != nil { return err } + if err := validatePlanAutonomy(plan, opts); err != nil { + return err + } if err := requireConfiguredPRVisualEvidenceDecision(plan, opts); err != nil { return err } @@ -944,6 +947,9 @@ type ApprovalOptions struct { BaselineDiffSHA256 string BaselineChangedPaths []string Readiness ReadinessReceipt + AutonomyPath string + AutonomyFingerprint string + RunTarget RunTarget } type ApprovalReceipt struct { @@ -1069,6 +1075,7 @@ type ActivationOptions struct { OutDir string OutputPath string SourceCommit string + AutonomyPath string } func ActivatePlan(options ActivationOptions) error { @@ -1095,7 +1102,17 @@ func ActivatePlan(options ActivationOptions) error { structuredPlanStatus := "POLICY_ACTIVATED" receipt := ApprovalReceipt{} baseline := PlanningBaseline{} - if config.Workflow.HumanPlanApproval { + autonomy := AutonomyReceipt{} + if strings.TrimSpace(options.AutonomyPath) != "" { + autonomy, err = CheckAutonomyReceipt(options.AutonomyPath, check, repo, RunTargetVerified, "") + if err != nil { + return err + } + baseline, err = productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.AutonomyPath, options.OutputPath) + if err != nil { + return err + } + } else if config.Workflow.HumanPlanApproval { authorizationMode = "human" structuredPlanStatus = "HUMAN_APPROVED" if strings.TrimSpace(options.ApprovalPath) == "" { @@ -1133,6 +1150,9 @@ func ActivatePlan(options ActivationOptions) error { BaselineDiffSHA256: baseline.DiffSHA256, BaselineChangedPaths: baseline.ChangedPaths, Readiness: receipt.Readiness, + AutonomyPath: options.AutonomyPath, + AutonomyFingerprint: autonomy.Fingerprint, + RunTarget: autonomy.Target, } if version, _ := check.Plan["schema_version"].(float64); version >= 3 && authorizationMode == "policy" { approval.Readiness, err = CheckPlanReadiness(options.PlanPath) @@ -1182,7 +1202,7 @@ func ActivatePlan(options ActivationOptions) error { // Baseline drift guard runs immediately before the atomic promote so a product // change concurrent with approval cannot be sealed into the lock. The compiled // output directory and the lock path are excluded from the baseline. - currentBaseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.OutputPath, options.OutDir) + currentBaseline, err := productBaseline(repo, options.PlanPath, check.SourcePlanPath, check.SpecPath, options.ApprovalPath, options.AutonomyPath, options.OutputPath, options.OutDir) if err != nil { return err } @@ -1324,6 +1344,16 @@ func buildApprovalLock(options ApprovalOptions, tasksSHA256 string) ([]byte, err "baseline_diff_sha256": options.BaselineDiffSHA256, "baseline_changed_paths": baselinePaths, } + if options.AutonomyPath != "" { + autonomyHash, err := SHA256File(options.AutonomyPath) + if err != nil { + return nil, fmt.Errorf("autonomy receipt is missing or unreadable: %w", err) + } + lock["autonomy_path"] = options.AutonomyPath + lock["autonomy_sha256"] = autonomyHash + lock["autonomy_fingerprint"] = options.AutonomyFingerprint + lock["run_target"] = options.RunTarget + } if options.Readiness.Fingerprint != "" { lock["schema_version"] = 3 lock["readiness_fingerprint"] = options.Readiness.Fingerprint @@ -1399,6 +1429,12 @@ func CheckApprovalLock(options ApprovalOptions) error { if expectedMode == "policy" && schemaVersion == 1 { mismatches = append(mismatches, "authorization_mode") } + if options.AutonomyPath != "" { + hash, hashErr := SHA256File(options.AutonomyPath) + if hashErr != nil || stringValue(lock["autonomy_sha256"]) != hash || stringValue(lock["autonomy_fingerprint"]) != options.AutonomyFingerprint || stringValue(lock["run_target"]) != string(options.RunTarget) { + mismatches = append(mismatches, "autonomy") + } + } if options.BaselineDiffSHA256 != "" && stringValue(lock["baseline_diff_sha256"]) != options.BaselineDiffSHA256 { mismatches = append(mismatches, "baseline_diff") } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan_validation.go b/labs/12-product-engineering-loop/product-engineering-loop/plan_validation.go index 461e05cc2..ef4b47d2c 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan_validation.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan_validation.go @@ -138,6 +138,22 @@ func validateArchitectureGrounding(plan map[string]any, opts *ValidatePlanOption return nil } +func validatePlanAutonomy(plan map[string]any, opts *ValidatePlanOptions) error { + if _, present := plan["autonomy_decisions"]; !present { + return nil + } + var evidence map[string]EvidenceRecord + if opts != nil && opts.PlanPath != "" { + var err error + evidence, err = LoadEvidenceLedger(filepath.Join(filepath.Dir(opts.PlanPath), "evidence.json")) + if err != nil { + return err + } + } + _, err := validateAutonomyDecisions(plan, evidence) + return err +} + func validateSystemicBoundaries(plan map[string]any) error { boundaries, ok := objectSlice(plan["systemic_boundaries"]) if !ok || len(boundaries) == 0 { 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 16dbb49ff..b2c1484c4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go @@ -256,6 +256,7 @@ type PRPublishOptions struct { PreviewPath string ExpectedFingerprint string Action string + AutonomyPath string VisualPublisher PRVisualEvidencePublisher } @@ -571,17 +572,29 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro return nil, nil, fmt.Errorf("managed PR requires a valid Boatstack project configuration: %w", configErr) } authorizationMode := "policy" + autonomyPath := filepath.Join(directory, "autonomy.md") + autonomy := AutonomyReceipt{} if config.Workflow.HumanPlanApproval { authorizationMode = "human" } + if fileExists(autonomyPath) { + autonomy, err = CheckAutonomyReceipt(autonomyPath, check, repo, RunTargetVerified, "") + if err != nil { + return nil, nil, fmt.Errorf("managed PR autonomy receipt is stale: %w", err) + } + authorizationMode = "policy" + } tasksPath := featureArtifactPath(directory, filepath.Join("compiled", "tasks.json"), "tasks.json") if err := CheckApprovalLock(ApprovalOptions{ - SourcePlanPath: check.SourcePlanPath, - SpecPath: check.SpecPath, - PlanPath: planPath, - TasksPath: tasksPath, - AuthorizationMode: authorizationMode, - OutputPath: lockPath, + SourcePlanPath: check.SourcePlanPath, + SpecPath: check.SpecPath, + PlanPath: planPath, + TasksPath: tasksPath, + AuthorizationMode: authorizationMode, + OutputPath: lockPath, + AutonomyPath: autonomyPathIf(fileExists(autonomyPath), autonomyPath), + AutonomyFingerprint: autonomy.Fingerprint, + RunTarget: autonomy.Target, }); err != nil { return nil, nil, fmt.Errorf("managed PR requires a current build lock: %w", err) } @@ -626,9 +639,12 @@ func managedPRSources(repo, feature string) ([]PRSource, map[string]string, erro {"plan_lock", lockPath}, {"evidence", evidencePath}, } - if config.Workflow.HumanPlanApproval { + if authorizationMode == "human" { paths = append(paths, struct{ kind, path string }{"approval", approvalPath}) } + if fileExists(autonomyPath) { + paths = append(paths, struct{ kind, path string }{"autonomy", autonomyPath}) + } for _, optional := range []struct{ kind, name string }{ {"questions", "questions.md"}, {"gaps", "gaps.md"}, {"test_plan", "test-plan.md"}, } { @@ -1179,6 +1195,19 @@ func PublishPR(options PRPublishOptions) (string, error) { if options.Action != "open" && options.Action != "update" { return "", fmt.Errorf("publication action must be open or update") } + if strings.TrimSpace(options.AutonomyPath) != "" { + if context.Feature == "" { + return "", fmt.Errorf("autonomous publication requires a managed feature") + } + planPath := filepath.Join(repo, ".product-loop", "features", context.Feature, "plan.md") + check, checkErr := CheckPlan(planPath) + if checkErr != nil { + return "", checkErr + } + if _, checkErr = CheckAutonomyReceipt(options.AutonomyPath, check, repo, RunTargetPR, options.Action); checkErr != nil { + return "", checkErr + } + } if context.PRVisualEvidencePolicy == "require" && context.PRVisualEvidenceStatus != "PASS" { finding := SafetyFinding{ Category: "workflow-visual-evidence-missing", diff --git a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md index f1c1b86c1..f62d1d820 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md +++ b/labs/12-product-engineering-loop/product-engineering-loop/references/artifacts.md @@ -12,6 +12,7 @@ Artifacts separate facts, decisions, unknowns, incompleteness, and evidence. Com | ADR | Accepted durable architecture decision and rationale | A meaningful architecture choice is accepted | | Markdown plan | Human-readable plan plus its one marked structured block; canonical before and during build | A spec is resolved enough to propose tasks and checks | | Approval receipt | Named human, timestamp, and fingerprint in Markdown; not executable state | The exact draft is explicitly approved in Plan mode | +| Autonomy receipt | Invocation-scoped target, policy decisions, repository/branch identity, evidence, and plan fingerprint in Markdown | An explicit goal-driven run selects `plan`, `verified`, or `pr` | | Compiled tasks | Deterministic dependency graph generated from the approved Markdown plan | Build activation succeeds | | Journey oracle manifest | Fingerprinted typed journey oracles compiled from the plan-level decision | Build activation succeeds | | Journey results | PASS/FAIL and evidence bound to the oracle manifest, head commit, and diff | Before a relevant journey reaches test or review gate | @@ -133,6 +134,7 @@ clone, `external` outside the repository (Detached Supervision). | generated-lock | committed-generated | checkout | init, update, export | | planning-artifacts | committed-planning | checkout | planning-write | | approval-receipt | committed-planning | checkout | record-approval | +| autonomy-receipt | committed-planning | checkout | record-autonomy | | plan-lock | committed-planning | checkout | activate-plan | | compiled-artifacts | committed-planning | checkout | activate-plan | | pr-preview | committed-planning | checkout | ship-gate, publish-pr | 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 a528e1989..23b23d052 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 @@ -144,7 +144,7 @@ The read-only `next` status query is the one exception, because a status questio ### Foreground run coordinator -`run` is an opt-in foreground coordinator over the existing operations, not a second state machine. It first resolves the read-only repository state, enters `auto-plan` when the host supplies the plan path (`--plan`), asks for that plan when none is supplied, returns **Feature complete** without requiring a remote only for completed work, and stops on unverified or blocked state. Before the first delivery-stage mutation it runs the versioned Git preflight, which fetches `origin`, requires the fetched remote base, verifies that the current named branch contains that base, rejects a behind or diverged upstream, and enforces any active slice branch constraints. Planning and approval remain local and do not require a remote. It never merges, rebases, switches or creates constrained branches, discards changes, force-pushes, merges a PR, or deploys. +`run` is an opt-in foreground coordinator over the existing operations, not a second state machine. It accepts `--to plan|verified|pr`; when the request names no target, the host asks once. The target is recorded in a fingerprinted `autonomy.md` receipt bound to the plan, repository, branch, eligible policy decisions, and, for `pr`, one open or update action. `plan` stops at a valid reviewable plan. `verified` uses policy activation and stops after test and review gates. `pr` continues through exact preview validation and one normal publication without a second confirmation. Receipt drift fails closed. Human-driven runs without an autonomy receipt retain the existing approval and publication confirmations. The coordinator never merges, rebases, switches or creates constrained branches, discards changes, force-pushes, merges a PR, or deploys. After preflight, resolve the repository-backed next operation, execute exactly that canonical operation, verify the resulting state, and resolve again through all declared delivery slices. When the resolved block names only past deliveries, the coordinator may offer to ignore a named past delivery (adding its slug to `workflow.ignored_deliveries`) only after explicit user confirmation; any new, unlisted ambiguous delivery still pauses. Pause for `a`, a material product answer, and `o` or `u`; after the valid state-scoped reply, continue in the current host session. The invocation does not replace either human authorization. Automatically record and repair same-intent test or review failures for at most three complete repair-and-gate cycles per active slice per invocation. Stop immediately for requirement amendments, ambiguous or stale state, unsafe capability, unsupported recovery, branch mismatch, or exhausted repairs. Store no durable run/autopilot mode; re-invocation reconstructs progress from canonical repository state. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/safety.go b/labs/12-product-engineering-loop/product-engineering-loop/safety.go index 5354a7be4..f4f8cf969 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/safety.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/safety.go @@ -218,8 +218,8 @@ var stageMutationVerbs = map[string][]string{ // there — declaring the row keeps the admission tables total over every // stage the solution set can emit (guard-never-prescribes-what-it-would-deny). "NOT_INITIALIZED": {"init"}, - "DRAFT_PLAN": {"planning-write", "record-approval"}, - "INVALID_STATE": {"planning-write", "record-approval"}, + "DRAFT_PLAN": {"planning-write", "record-approval", "record-autonomy"}, + "INVALID_STATE": {"planning-write", "record-approval", "record-autonomy"}, "APPROVED": {"activate-plan", "workspace-cut"}, "POLICY_READY": {"activate-plan", "workspace-cut"}, "NOT_STARTED": {"planning-write"}, diff --git a/labs/12-product-engineering-loop/product-engineering-loop/statemap.go b/labs/12-product-engineering-loop/product-engineering-loop/statemap.go index e11806c1e..af96ebef2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/statemap.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/statemap.go @@ -112,6 +112,11 @@ func StateRegistry() []StateEntry { OwnerVerbs: []string{"record-approval"}, Sample: generatedSample("features", "sample-feature", "approval.md"), }, + { + Name: "autonomy-receipt", Class: ClassCommittedPlanning, Partition: "checkout", + OwnerVerbs: []string{"record-autonomy"}, + Sample: generatedSample("features", "sample-feature", "autonomy.md"), + }, { Name: "plan-lock", Class: ClassCommittedPlanning, Partition: "checkout", OwnerVerbs: []string{"activate-plan"},