diff --git a/internal/config/config.go b/internal/config/config.go index 21cbe73..5fde05b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -68,6 +68,13 @@ type PlanningConfig struct { // (greenfield-aware, edits only within vxd:scribe markers on existing // READMEs) plus links the generated docs. Clients can opt out per project. EmitScribeStory bool `yaml:"emit_scribe_story"` + // EmitIntegrationStory, when true (default), makes the planner append a + // final "integration" story (before the scribe) that depends on all code + // stories, wires every component into the application entry point, bridges + // interface mismatches with adapters, and writes an end-to-end smoke test + // that boots the app and asserts the documented surface responds. Closes the + // systemic gap where per-story unit tests pass but the whole never composes. + EmitIntegrationStory bool `yaml:"emit_integration_story"` } // WorkspaceConfig holds workspace-level settings. diff --git a/internal/config/loader.go b/internal/config/loader.go index 4fa4e3d..9cbcd6f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -58,6 +58,7 @@ func DefaultConfig() Config { MaxStoryComplexity: 5, DesignApproach: "ddd-tdd", EmitScribeStory: true, + EmitIntegrationStory: true, }, Billing: BillingConfig{ DefaultRate: 150.0, diff --git a/internal/engine/google_integration_test.go b/internal/engine/google_integration_test.go index 08e39db..cbcc6f7 100644 --- a/internal/engine/google_integration_test.go +++ b/internal/engine/google_integration_test.go @@ -126,6 +126,7 @@ func TestGoogleAI_FullPipeline_PlanDispatchReviewQAMerge(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false // Wire both planner and reviewer to Google AI (for this test) googleClient := llm.NewRetryClient( @@ -341,6 +342,7 @@ func TestGoogleAI_FallbackMidPipeline(t *testing.T) { os.WriteFile(filepath.Join(repoDir, "go.mod"), []byte("module fallback-e2e"), 0644) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false // --- Phase 1: Plan (succeeds via Google) --- planner := engine.NewPlanner(fallbackClient, cfg, es, ps) diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index bc6b15a..7be5ac7 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -84,6 +84,7 @@ func TestIntegration_PlannerToDispatcher(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, es, ps) // --- Phase 1: Plan --- @@ -210,6 +211,7 @@ func TestIntegration_FullPipeline_PlanDispatchReviewQAMerge(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false replayClient := llm.NewReplayClient( llm.CompletionResponse{Content: plannerResponse, Model: "claude-opus-4"}, llm.CompletionResponse{Content: reviewResponse, Model: "claude-sonnet-4"}, @@ -395,6 +397,7 @@ func TestIntegration_MultiStoryPipeline(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false cfg.Planning.MaxStoryComplexity = 13 planner := engine.NewPlanner(client, cfg, es, ps) planResult, err := planner.Plan(context.Background(), "r-multi", "Build multi-story feature", repoDir) @@ -480,6 +483,7 @@ func TestIntegration_PlannerEventPersistence(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, es, ps) _, err := planner.Plan(context.Background(), "r-persis", "Persist test", repoDir) @@ -580,6 +584,7 @@ func TestIntegration_DependencyStorageAndDAGReconstruction(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false cfg.Planning.MaxStoryComplexity = 13 planner := engine.NewPlanner(client, cfg, es, ps) diff --git a/internal/engine/planner.go b/internal/engine/planner.go index e5a9a50..1ee2ba6 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -306,6 +306,22 @@ architecture and conventions when planning stories.`, profileContext) } } + // Integration story: a final code story that depends on every other code + // story, wires all independently-built components into the application + // entry point, reconciles interface mismatches with adapters, and writes a + // smoke test that BOOTS the app and asserts the documented surface actually + // responds. This closes the systemic gap where per-story unit tests pass + // (against mocks) but the whole never composes — unwired handlers, no auth, + // incompatible interfaces. The smoke test runs in QA / the pre-merge gate, + // so a non-functional whole fails the build instead of "completing". + if persist && p.config.Planning.EmitIntegrationStory && len(stories) > 0 { + deps := make([]string, 0, len(stories)) + for _, s := range stories { + deps = append(deps, s.ID) + } + stories = append(stories, buildIntegrationStory(prefix, requirement, deps)) + } + // README Scribe: append a final story that documents what was built. It // depends on every other story so it runs last (after all code is merged), // owns README.md, and is greenfield-aware. Skipped for ephemeral estimates @@ -478,6 +494,39 @@ const scribeStorySuffix = "scribe-readme" // other story (deps are already prefixed), owns README.md, and instructs the // agent to be greenfield-aware and confine edits on an existing README to the // vxd:scribe markers so hand-written content is never clobbered. +// buildIntegrationStory constructs the final integration story. It runs after +// every code story (depends on all of them) and is responsible for making the +// independently-built components actually compose into a working application. +func buildIntegrationStory(prefix, requirement string, deps []string) PlannedStory { + desc := fmt.Sprintf(`Integrate everything the other stories built into ONE working application for this requirement: %s + +The other stories each built and unit-tested a component in isolation (often against mocks). Your job is to make the WHOLE thing actually run end-to-end. This is the most important story — a build that passes unit tests but does not run is a failure. + +Do ALL of the following: +- Wire every component into the application entry point (e.g. main.go / app.py / server / index.ts / CLI root). Every handler, route, command, page, and middleware that a story built MUST be reachable from the real entry point. Audit for dangling wires: a feature whose unit test passes but that is never registered in the entry point is a bug. +- Apply cross-cutting middleware that stories built but could not wire themselves (authentication, logging, CORS, rate limiting) at the entry point. If an auth/API-key middleware exists, it MUST actually guard the documented protected routes. +- Reconcile interface mismatches between components with thin adapters. Independently-built stories often declare slightly different interfaces (e.g. one returns []Conflict, another *ValidationResult); add the adapter so the real implementation — not a mock — is wired in. Do NOT leave a production path depending on a test-only mock. +- Add a SMOKE TEST that boots the application and exercises the documented surface end-to-end: for a server, start it and assert each documented endpoint responds with the expected status (NOT 404) and that protected routes reject missing credentials; for a CLI, run the documented commands and assert real output; for a UI, render the primary flow. The smoke test must FAIL if a feature is unreachable or unwired. +- Fix any wiring/compile/type errors this integration surfaces so the full app builds and all tests (including your smoke test) pass. + +Make reasonable, conventional choices for any route paths or wiring details the requirement leaves unspecified, and note them briefly in code comments.`, requirement) + + return PlannedStory{ + ID: prefix + "-integrate", + Title: "Integrate all components into a working app + end-to-end smoke test", + Description: desc, + AcceptanceCriteria: FlexibleString("Every documented feature is reachable from the real application entry point (no dangling/unwired handlers, commands, or pages); cross-cutting middleware (auth etc.) actually guards the documented routes; interface mismatches between components are bridged with adapters so the production path uses real implementations, not mocks; a smoke test boots the app and asserts the documented surface responds end-to-end (protected routes reject missing credentials, documented endpoints do not 404) and that smoke test passes along with the full build/test suite."), + Complexity: 5, + DependsOn: deps, + // No declared owned_files: integration legitimately touches the entry + // point (owned by the skeleton story) and adds adapter/smoke-test files. + // It depends on every story, so it runs last with no parallel conflict; + // the edge-aware overlap check permits the sequenced entry-point edit. + OwnedFiles: []string{}, + WaveHint: "sequential", + } +} + func buildScribeStory(prefix, requirement string, deps []string) PlannedStory { desc := fmt.Sprintf(`Document the project to software-factory standard for what this requirement delivered: %s diff --git a/internal/engine/planner_hallucination_test.go b/internal/engine/planner_hallucination_test.go index ad33c2e..9f88c03 100644 --- a/internal/engine/planner_hallucination_test.go +++ b/internal/engine/planner_hallucination_test.go @@ -95,6 +95,7 @@ func TestPlanner_AcceptsValidDependencies(t *testing.T) { } cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false p := engine.NewPlanner(llm.NewReplayClient(resp), cfg, es, ps) result, err := p.Plan(context.Background(), "req-valid", "Build feature", t.TempDir()) diff --git a/internal/engine/planner_sanitize_test.go b/internal/engine/planner_sanitize_test.go index 8e84dfc..9dfbf6e 100644 --- a/internal/engine/planner_sanitize_test.go +++ b/internal/engine/planner_sanitize_test.go @@ -45,6 +45,7 @@ func TestPlanner_AllowsCleanRequirement(t *testing.T) { } cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false p := NewPlanner(llm.NewReplayClient(resp), cfg, es, ps) result, err := p.Plan(context.Background(), "req-003", diff --git a/internal/engine/planner_test.go b/internal/engine/planner_test.go index 5319540..573c32f 100644 --- a/internal/engine/planner_test.go +++ b/internal/engine/planner_test.go @@ -42,6 +42,7 @@ func TestPlanner_Plan(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-001", "Add user authentication", dir) @@ -166,6 +167,7 @@ func TestPlanner_EmitsScribeStory(t *testing.T) { defer projStore.Close() cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = emit + cfg.Planning.EmitIntegrationStory = false // isolate scribe in this test planner := engine.NewPlanner(llm.NewReplayClient(llm.CompletionResponse{Content: resp}), cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-001", "Build a thing", dir) if err != nil { @@ -226,6 +228,7 @@ func TestPlanner_PromptIncludesEngineeringStandards(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: resp}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false es, _ := state.NewFileStore(filepath.Join(dir, "e.jsonl")) defer es.Close() ps, _ := state.NewSQLiteStore(":memory:") @@ -243,6 +246,47 @@ func TestPlanner_PromptIncludesEngineeringStandards(t *testing.T) { } } +// The integration story must be emitted (depending on all code stories) and +// carry the wiring + smoke-test instructions that close the compose gap. +func TestPlanner_EmitsIntegrationStory(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + + resp := `[ + {"id":"s-001","title":"A","description":"d","acceptance_criteria":"ac","complexity":3,"depends_on":[]}, + {"id":"s-002","title":"B","description":"d","acceptance_criteria":"ac","complexity":3,"depends_on":["s-001"]} + ]` + es, _ := state.NewFileStore(filepath.Join(dir, "e.jsonl")) + defer es.Close() + ps, _ := state.NewSQLiteStore(":memory:") + defer ps.Close() + cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false // isolate the integration story + planner := engine.NewPlanner(llm.NewReplayClient(llm.CompletionResponse{Content: resp}), cfg, es, ps) + result, err := planner.Plan(context.Background(), "r-001", "Build a web API", dir) + if err != nil { + t.Fatalf("plan: %v", err) + } + + // 2 code stories + 1 integration = 3; integration is last and depends on both. + if len(result.Stories) != 3 { + t.Fatalf("expected 3 stories (2 + integration), got %d", len(result.Stories)) + } + integ := result.Stories[len(result.Stories)-1] + if integ.ID != "r-001-integrate" { + t.Fatalf("expected integration story id r-001-integrate, got %q", integ.ID) + } + if len(integ.DependsOn) != 2 { + t.Errorf("integration story should depend on all code stories, got %v", integ.DependsOn) + } + brief := integ.Description + " " + string(integ.AcceptanceCriteria) + for _, must := range []string{"entry point", "smoke test", "adapter", "404", "mock"} { + if !strings.Contains(brief, must) { + t.Errorf("integration brief missing %q", must) + } + } +} + func TestPlanner_CycleDetection(t *testing.T) { dir := t.TempDir() _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) @@ -358,6 +402,7 @@ func TestPlan_ParsesOwnedFiles(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-002", "Add API layer", dir) @@ -411,6 +456,7 @@ func TestPlan_RejectsExcessiveComplexity(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false cfg.Planning.MaxStoryComplexity = 5 planner := engine.NewPlanner(client, cfg, eventStore, projStore) @@ -444,6 +490,7 @@ func TestPlan_RejectsFileOverlap(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) // Overlapping files should be auto-sequenced instead of rejected. @@ -494,6 +541,7 @@ func TestRePlan_CreatesReplacementStories(t *testing.T) { cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) stories, err := planner.RePlan(context.Background(), "s-001", "r-001", "Story failed 3 times: test compilation error in handler.go") diff --git a/internal/engine/planner_validation_test.go b/internal/engine/planner_validation_test.go index f6a734c..e438bf6 100644 --- a/internal/engine/planner_validation_test.go +++ b/internal/engine/planner_validation_test.go @@ -38,6 +38,7 @@ func TestPlan_RejectsEmptyStoryList(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: "[]"}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) _, err := planner.Plan(context.Background(), "r-empty", "Build nothing", dir) @@ -72,6 +73,7 @@ func TestPlan_RejectsStoryWithEmptyID(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) if _, err := planner.Plan(context.Background(), "r-emptyid", "Has a blank story", dir); err == nil {