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
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func DefaultConfig() Config {
MaxStoryComplexity: 5,
DesignApproach: "ddd-tdd",
EmitScribeStory: true,
EmitIntegrationStory: true,
},
Billing: BillingConfig{
DefaultRate: 150.0,
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/google_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/engine/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
49 changes: 49 additions & 0 deletions internal/engine/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions internal/engine/planner_hallucination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions internal/engine/planner_sanitize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions internal/engine/planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions internal/engine/planner_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
Loading