Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Prevent E2E architecture grounding failures during auto-plan

Added explicit ZCA projection validation to ensure architecture facts like missing routes are verified by host evidence. The schema has been updated and a new execution boundary state, `PLAN_INVALID`, immediately exits repair loops caused by false planning premises.
25 changes: 14 additions & 11 deletions labs/12-product-engineering-loop/product-engineering-loop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,20 @@ Shortcuts do not bypass fingerprints, committed-diff checks, evidence, authentic
1. Treat the supplied plan as an initial proposal, not approved truth. Record its path as `source_plan_path` in the structured plan.
2. Write the bounded outcome definition before proposing architecture.
3. Separate facts, decisions, unknowns, and safely deferrable gaps.
4. Answer discoverable code questions by inspection.
5. Ask the developer only questions whose answers materially change behavior, contracts, risk, or acceptance. Ask 1-3 concise questions at a time and give each 2-3 mutually exclusive choices with compact inline-code keys (`1a`, `1b`, `1c`, then `2a`, `2b`, and so on). Suffix exactly one choice per question with `(Recommended)`, explain the impact, and end with one reply hint naming the keys or `r` for all recommendations. Use this format with structured question tools and plain text alike, then return `WAITING_FOR_INPUT`.
6. Treat a standalone `r` as explicit human acceptance only when every displayed question has exactly one recommendation. Echo the selected question-to-answer mapping before recording each as `ANSWERED`; otherwise ask again without choosing. An authoritative repository fact is `DISCOVERED`, an agent suggestion or inferred choice is `PROPOSED`, and only an explicit human response is `ANSWERED`. Every material proposal remains in `plan.md` as a `blocking_questions` ID until the human answers it. Never use labels such as “answered by plan default.”
7. Create the feature spec: problem, users, outcomes, non-goals, acceptance criteria, invariants, interfaces, failure behavior, observability, rollout, and rollback. Translate every accepted claim into an observable condition with a defensible oracle.
8. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it.
9. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative.
10. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`.
11. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names.
12. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Put its authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan <feature>/plan.md`; this command is read-only. If the host blocks its ordinary Markdown writer, pass the document to `boatstack-helper planning-write --repo . --feature <feature> --artifact <known-name>` on stdin. Never use arbitrary shell redirection to evade a host write boundary.
13. Keep implementation tasks separate from publication authority. Internal phases remain tasks inside one delivery slice. When the accepted outcome explicitly requires multiple PRs, declare ordered `delivery_slices`; assign every task exactly once and give each slice its own optional base/head branch contract. Plan approval approves this structure but never authorizes a push or PR.
14. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`.
4. Before proposing implementation tasks, inspect the repository and verify any assumptions about API routes, data access, UI components, authentication, server actions, streams, jobs, and external services. Do not guess application architecture.
5. Express verified architectural information as typed `architecture_facts`. Each architecture fact must reference evidence IDs produced by Boatstack repository inspection. Do not create or invent evidence IDs. Reading one arbitrary repository file does not ground an unrelated architectural claim.
6. When an architectural question cannot be verified, record it in `architecture_unknowns`. Do not create an implementation task that depends on an unresolved architecture unknown. Create a bounded discovery task instead.
7. Every architecture-sensitive task must reference the facts it depends on through `requires_facts`.
8. Ask the developer only questions whose answers materially change behavior, contracts, risk, or acceptance. Ask 1-3 concise questions at a time and give each 2-3 mutually exclusive choices with compact inline-code keys (`1a`, `1b`, `1c`, then `2a`, `2b`, and so on). Suffix exactly one choice per question with `(Recommended)`, explain the impact, and end with one reply hint naming the keys or `r` for all recommendations. Use this format with structured question tools and plain text alike, then return `WAITING_FOR_INPUT`.
9. Treat a standalone `r` as explicit human acceptance only when every displayed question has exactly one recommendation. Echo the selected question-to-answer mapping before recording each as `ANSWERED`; otherwise ask again without choosing. An authoritative repository fact is `DISCOVERED`, an agent suggestion or inferred choice is `PROPOSED`, and only an explicit human response is `ANSWERED`. Every material proposal remains in `plan.md` as a `blocking_questions` ID until the human answers it. Never use labels such as “answered by plan default.”
10. Create the feature spec: problem, users, outcomes, non-goals, acceptance criteria, invariants, interfaces, failure behavior, observability, rollout, and rollback. Translate every accepted claim into an observable condition with a defensible oracle.
11. Run product, design, engineering, and developer-experience reviews only when applicable. If gstack is installed, its review skills can implement these lenses; do not require it.
12. If Spec Kit is installed, use its constitution/specify/clarify/plan/tasks/analyze/checklist flow as an artifact generator. The canonical artifact contract remains authoritative.
13. For every planned validation, record the exact `criteria` it can support plus `run`, `origin`, `oracle`, and `independence`. Commands, automated tests, external checks, and named human review procedures are all valid forms, but an ambiguous claim without a threshold/rubric and authorized decision remains `BLOCKED`.
14. For every external write, record `affected_paths` plus side-effect kind, immutable target identity, reversibility, failure policy, and `destructive: false`. Reject ambiguous reset rollback or target names.
15. Write only Markdown feature artifacts, including the canonical structured `plan.md`. Put its authoritative JSON inside the marked Boatstack block and run `boatstack-helper check-plan --plan <feature>/plan.md`; this command is read-only. If the host blocks its ordinary Markdown writer, pass the document to `boatstack-helper planning-write --repo . --feature <feature> --artifact <known-name>` on stdin. Never use arbitrary shell redirection to evade a host write boundary.
16. Keep implementation tasks separate from publication authority. Internal phases remain tasks inside one delivery slice. When the accepted outcome explicitly requires multiple PRs, declare ordered `delivery_slices`; assign every task exactly once and give each slice its own optional base/head branch contract. Plan approval approves this structure but never authorizes a push or PR.
17. End with a **draft**, never an implied approval. Do not generate executable task state, JSON artifacts, locks, or implementation changes from `auto-plan`.

Do not treat an ADR as general project context. ADRs record accepted durable decisions. Use a question ledger for unknowns and a gap ledger for known divergence.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<!-- boatstack-plan:v1 -->
```json
{
"schema_version": 1,
"schema_version": 2,
"feature_id": "<stable-feature-id>",
"source_plan_path": "source-plan.md",
"spec_path": "feature-spec.md",
Expand All @@ -24,11 +24,28 @@
"text": "<observable accepted behavior>"
}
],
"architecture_facts": [
{
"id": "fact_example_route_absent",
"kind": "route_absent",
"subject": "/api/clients",
"evidence_ids": ["ev_001"],
"summary": "No /api/clients route was found in the repository."
}
],
"architecture_unknowns": [
{
"id": "unknown_example",
"question": "How are chat responses delivered to the browser?",
"blocks": ["T-1"]
}
],
"tasks": [
{
"id": "T-1",
"title": "<bounded implementation operation>",
"depends_on": [],
"requires_facts": ["fact_example_route_absent"],
"acceptance_criteria": ["AC-1"],
"affected_paths": ["<repository path or glob>"],
"side_effects": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ func recordChangeCommand(arguments []string) int {
flags.StringVar(&options.Expected, "expected", "", "approved or requested expected behavior")
flags.StringVar(&options.Actual, "actual", "", "observed behavior")
flags.StringVar(&options.Evidence, "evidence", "", "bounded evidence or reproduction reference")
flags.StringVar(&options.Classification, "classification", "", "implementation_repair, verification_repair, review_repair, requirement_amendment, or needs_clarification")
flags.StringVar(&options.Classification, "classification", "", "implementation_repair, verification_repair, review_repair, requirement_amendment, needs_clarification, or plan_invalid")
if err := flags.Parse(arguments); err != nil {
return 2
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,10 @@ 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",
"needs_clarification": "",
}[classification]
if _, ok := map[string]bool{"implementation_repair": true, "verification_repair": true, "review_repair": true, "requirement_amendment": true, "needs_clarification": true}[classification]; !ok {
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 {
return ChangeObservation{}, DeliveryState{}, fmt.Errorf("unsupported change classification %q", classification)
}
if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" {
Expand Down Expand Up @@ -400,6 +401,9 @@ func RecordChangeObservation(options ChangeObservationOptions) (ChangeObservatio
if classification == "needs_clarification" {
state.ResumeStage = ""
}
} else if classification == "plan_invalid" {
state.Mode = "PLAN_INVALID"
state.ResumeStage = "AUTO_PLAN"
} else {
state.Mode = "REWORK"
slice := &state.Slices[state.ActiveIndex]
Expand Down Expand Up @@ -573,7 +577,7 @@ func RecordDeliveryGate(options DeliveryGateOptions) (DeliveryGateReceipt, error
if err := checkDeliveryPlanLock(repo, options.Feature, state); err != nil {
return DeliveryGateReceipt{}, err
}
if state.Mode == "AMENDMENT_REQUIRED" {
if state.Mode == "AMENDMENT_REQUIRED" || state.Mode == "PLAN_INVALID" {
return DeliveryGateReceipt{}, fmt.Errorf("delivery requires an approved plan amendment before gates may continue")
}
slice, err := activeDeliverySlice(state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ func twoSlicePlan() map[string]any {

func TestDeliverySlicesPartitionTasksAndRejectForwardDependencies(t *testing.T) {
plan := twoSlicePlan()
if err := ValidatePlan(plan); err != nil {
if err := ValidatePlan(plan, nil); err != nil {
t.Fatalf("valid two-slice plan rejected: %v", err)
}
plan["delivery_slices"].([]any)[1].(map[string]any)["task_ids"] = []any{"T-1", "T-2"}
if err := ValidatePlan(plan); err == nil || !strings.Contains(err.Error(), "assigned") {
if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "assigned") {
t.Fatalf("duplicate task assignment did not block: %v", err)
}
plan = twoSlicePlan()
plan["tasks"].([]any)[0].(map[string]any)["depends_on"] = []any{"T-2"}
plan["tasks"].([]any)[1].(map[string]any)["depends_on"] = []any{}
if err := ValidatePlan(plan); err == nil || !strings.Contains(err.Error(), "future slice") {
if err := ValidatePlan(plan, nil); err == nil || !strings.Contains(err.Error(), "future slice") {
t.Fatalf("forward delivery dependency did not block: %v", err)
}
}
Expand Down
112 changes: 112 additions & 0 deletions labs/12-product-engineering-loop/product-engineering-loop/evidence.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package boatstack

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)

// EvidenceRecord represents a host-generated observation of the repository state.
type EvidenceRecord struct {
ID string `json:"id"`
Operation string `json:"operation"`
Path string `json:"path,omitempty"`
Query string `json:"query,omitempty"`
Matches []string `json:"matches,omitempty"`
Anchors []string `json:"anchors,omitempty"`
RepositoryRevision string `json:"repository_revision"`
CreatedBy string `json:"created_by"`
}

// LoadEvidenceLedger loads the evidence records from the specified path.
func LoadEvidenceLedger(path string) (map[string]EvidenceRecord, error) {
value, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]EvidenceRecord), nil
}
return nil, fmt.Errorf("failed to read evidence ledger: %w", err)
}

var records []EvidenceRecord
if err := json.Unmarshal(value, &records); err != nil {
return nil, fmt.Errorf("failed to parse evidence ledger: %w", err)
}

ledger := make(map[string]EvidenceRecord)
for _, record := range records {
ledger[record.ID] = record
}
return ledger, nil
}

// ValidateEvidencePath ensures an evidence path is repository-relative and safe.
func ValidateEvidencePath(repoRoot string, evPath string) error {
repoAbsolute, err := filepath.Abs(repoRoot)
if err != nil {
return fmt.Errorf("failed to resolve repository root: %w", err)
}

if filepath.IsAbs(evPath) {
return fmt.Errorf("evidence path must be relative, got absolute: %s", evPath)
}

cleanPath := filepath.Clean(evPath)
if strings.HasPrefix(cleanPath, "..") || cleanPath == "." {
return fmt.Errorf("evidence path cannot traverse outside repository: %s", evPath)
}

targetPath := filepath.Join(repoAbsolute, cleanPath)

// Check if path resolves outside repo (e.g. through symlinks)
evalPath, err := filepath.EvalSymlinks(targetPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("evidence path does not exist: %s", evPath)
}
return fmt.Errorf("failed to evaluate evidence path: %w", err)
}

if !strings.HasPrefix(evalPath, repoAbsolute) {
return fmt.Errorf("evidence path resolves outside repository: %s", evPath)
}

info, err := os.Stat(evalPath)
if err != nil {
return fmt.Errorf("failed to stat evidence path: %w", err)
}

if !info.Mode().IsRegular() {
return fmt.Errorf("evidence path is not a regular file: %s", evPath)
}

return nil
}

// CheckFileAnchors verifies that all expected anchors are present in the file content.
func CheckFileAnchors(filePath string, anchors []string) error {
if len(anchors) == 0 {
return nil
}

content, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file for anchor check: %w", err)
}
contentStr := string(content)

var missing []string
for _, anchor := range anchors {
if !strings.Contains(contentStr, anchor) {
missing = append(missing, anchor)
}
}

if len(missing) > 0 {
return fmt.Errorf("file missing expected anchors: %v", missing)
}

return nil
}
32 changes: 25 additions & 7 deletions labs/12-product-engineering-loop/product-engineering-loop/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,12 @@ func CheckPlan(planPath string) (PlanCheck, error) {
if err != nil {
return PlanCheck{}, err
}
if err := ValidatePlan(plan); err != nil {
repoRoot, _ := ResolveRepository(filepath.Dir(planPath))
opts := &ValidatePlanOptions{
PlanPath: planPath,
RepoRoot: repoRoot,
}
if err := ValidatePlan(plan, opts); err != nil {
return PlanCheck{}, err
}
sourcePlan, err := SourcePlanForStructuredPlan(planPath)
Expand Down Expand Up @@ -396,10 +401,18 @@ func checkApprovalSourcePlan(options ApprovalOptions) error {
return CheckSourcePlan(expected)
}

func ValidatePlan(plan map[string]any) error {
if plan["schema_version"] != float64(1) {
return fmt.Errorf("schema_version must be 1")
func ValidatePlan(plan map[string]any, opts *ValidatePlanOptions) error {
version, ok := plan["schema_version"].(float64)
if !ok || (version != float64(1) && version != float64(2)) {
return fmt.Errorf("schema_version must be 1 or 2")
}

if version == float64(2) {
if err := validateArchitectureGrounding(plan, opts); err != nil {
return err
}
}

if stringValue(plan["feature_id"]) == "" {
return fmt.Errorf("feature_id is required")
}
Expand Down Expand Up @@ -628,8 +641,8 @@ func validateTaskSafety(task map[string]any) error {
return nil
}

func CompilePlan(plan map[string]any) (map[string]any, map[string]any, string, error) {
if err := ValidatePlan(plan); err != nil {
func CompilePlan(plan map[string]any, opts *ValidatePlanOptions) (map[string]any, map[string]any, string, error) {
if err := ValidatePlan(plan, opts); err != nil {
return nil, nil, "", err
}
criteria, _ := objectSlice(plan["acceptance_criteria"])
Expand Down Expand Up @@ -727,7 +740,12 @@ func CompilePlanFiles(planPath, outDir string) error {
if err := CheckSourcePlan(sourcePlan); err != nil {
return err
}
tasks, matrix, evidence, err := CompilePlan(plan)
repoRoot, _ := ResolveRepository(filepath.Dir(planPath))
opts := &ValidatePlanOptions{
PlanPath: planPath,
RepoRoot: repoRoot,
}
tasks, matrix, evidence, err := CompilePlan(plan, opts)
if err != nil {
return err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func TestSourcePlanDiscoveryUsesOneBoundedCandidateAndBlocksAmbiguity(t *testing
func TestCompilerRequiresSourcePlanPath(t *testing.T) {
plan := validPlan()
delete(plan, "source_plan_path")
_, _, _, err := CompilePlan(plan)
_, _, _, err := CompilePlan(plan, nil)
if err == nil || !strings.Contains(err.Error(), "source_plan_path") {
t.Fatalf("expected missing source plan path failure, got %v", err)
}
Expand All @@ -349,7 +349,7 @@ func TestCompilerRejectsValidationWithoutOracleProvenance(t *testing.T) {
plan := validPlan()
task := plan["tasks"].([]any)[0].(map[string]any)
task["validation"] = []any{map[string]any{"criteria": []any{"AC-1"}, "run": "go test ./..."}}
_, _, _, err := CompilePlan(plan)
_, _, _, err := CompilePlan(plan, nil)
if err == nil || !strings.Contains(err.Error(), "origin, oracle, and independence") {
t.Fatalf("expected validation provenance failure, got %v", err)
}
Expand All @@ -373,7 +373,7 @@ func TestValidationsOnlySupportTheirMappedCriteria(t *testing.T) {
"origin": "AC-2", "oracle": "second oracle", "independence": "external",
},
}
_, matrix, _, err := CompilePlan(plan)
_, matrix, _, err := CompilePlan(plan, nil)
if err != nil {
t.Fatal(err)
}
Expand All @@ -399,14 +399,14 @@ func TestCompilerBlocksUncoveredCriterion(t *testing.T) {
plan := validPlan()
criteria := plan["acceptance_criteria"].([]any)
plan["acceptance_criteria"] = append(criteria, map[string]any{"id": "AC-2", "text": "uncovered"})
_, _, _, err := CompilePlan(plan)
_, _, _, err := CompilePlan(plan, nil)
if err == nil || !strings.Contains(err.Error(), "uncovered acceptance criteria") {
t.Fatalf("expected uncovered criterion failure, got %v", err)
}
}

func TestCompiledTaskGraphPreservesTaskFields(t *testing.T) {
tasks, _, _, err := CompilePlan(validPlan())
tasks, _, _, err := CompilePlan(validPlan(), nil)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading