diff --git a/CLAUDE.md b/CLAUDE.md index c2eac8e..07db809 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,7 @@ Tier 4: Pause (human intervention required) - `STORY_REWRITTEN` — manager rewrote story description/acceptance criteria - `STORY_SPLIT` — tech lead decomposed into child stories - `STORY_SLA_BREACHED` — story exceeded per-complexity duration limit (configurable via `sla.max_minutes_per_complexity`) +- `REQ_BLOCKED` — completion gate could not get the composed mainline green after its auto-fix budget; requirement status → `blocked` instead of `completed` (resume with `--godmode` after addressing `.vxd-fix-gaps.md`) ### Event Sourcing - **Source of truth**: `events.jsonl` (append-only, fsync'd) @@ -125,6 +126,8 @@ qa: value: "PASS" - kind: file_exists path: coverage.html + disable_completion_gate: false # default false = gate ON (verify composed mainline before REQ_COMPLETED) + completion_fix_cycles: 2 # auto-fix attempts vs a red mainline before REQ_BLOCKED (0→2, negative→hard gate) billing: default_rate: 150.0 currency: USD @@ -419,6 +422,15 @@ The doc loop ships the FULL software-factory documentation set, not just README - Every backstop is best-effort — a model failure logs and skips, never blocking requirement completion. The **scribe story** (`buildScribeStory`) instructs the agent to produce the whole set up front (its `OwnedFiles` + acceptance criteria now include `docs/adr` + `docs/README.md`); these backstops guarantee it ships even when the agent doesn't. - Tests: `factory_docs_test.go` (docs index determinism, humanize, training skip/generate, ADR parse/render/slug/skip/generate), the upgraded `TestGenerateDocumentation_ProducesSVGDiagrams` (README + 2 SVGs + training + ADRs + index all produced and committed), `planner_test.go::TestPlanner_EmitsScribeStory` (standards baked into the brief). **NXD port pending.** +### Requirement-completion verification gate (completion_gate.go, 2026-06-26) +Closes the long-standing caveat: **vxd reported `REQ_COMPLETED` on code that did not compile.** Per-story QA runs in isolated worktrees and cannot see cross-story drift (an unwired composition root, a missing interface method, an import mismatch). The composed mainline was verified by `RunVerificationLoop`, but the result was *advisory* — gaps were written to `.vxd-fix-gaps.md` and logged, then `REQ_COMPLETED` fired anyway. This is the bug that shipped pulsereview "merged" with its `/reviews`+`/digest` endpoints 404 (composition root never assembled). +- **Where:** `internal/engine/completion_gate.go`. `CompletionGate.Run(ctx, reqID, repoDir) bool` runs in the requirement-completion path (`monitor_dispatch.go dispatchNextWave`, after `pullBaseAfterMerge` + `cleanupDanglingBranches`). Wired via `Monitor.SetCompletionGate` in `resume.go` next to `SetDocGenerator`/`SetTechLeadFixer` (`TestResume_WiresCompletionGate` guards the wire). Skipped in dry-run and when `qa.disable_completion_gate=true`. +- **Order matters:** the local checkout is pulled to the composed mainline (`pullBaseAfterMerge`) **before** the gate verifies, so cycle 1 verifies the true merged tree, not a stale pre-VXD checkout (this reordering is itself part of the fix). +- **Loop:** verify (`RunVerificationLoop` → `ShouldRunFixCycle`) → if green, emit `REQ_COMPLETED`. If red, run up to `completion_fix_cycles` (default 2) auto-fix cycles: dispatch a godmode fix agent (the same skip-permissions `llmClient` already used by the doc generator; runs `claude -p` in cwd, edits + commits + pushes the reconciliation), pull, re-verify. First green cycle → `REQ_COMPLETED`. Cycles exhausted → emit **`REQ_BLOCKED`** (new event → projects requirement status `"blocked"` in `sqlite.go`), leave `.vxd-fix-gaps.md`, and log `vxd resume --godmode` guidance. +- **Graceful degradation as a safety property:** a nil client (no godmode / no LLM) makes the gate a **hard gate** — verify once, block on red, no auto-fix. The dangerous failure mode (silently completing on red) is impossible regardless of wiring, because the gate and the auto-fix are separate concerns. `completion_fix_cycles` < 0 forces hard-gate even with a client. +- **Config:** `qa.disable_completion_gate` (default false = ON), `qa.completion_fix_cycles` (0→2, negative→hard gate; `completionFixCycles` in resume.go pins the mapping). +- **Tests:** `completion_gate_test.go` (green-first→no-fix, red→green→auto-fix once, stays-red→block after maxCycles, nil-client→hard-gate, writes gaps file, `emitRequirementOutcome`→`blocked` status against real stores via injectable `verify`/`pull` seams), `projection_test.go::TestProject_ReqBlocked`, `resume_helpers_test.go::TestCompletionFixCycles`, `resume_wiring_test.go::TestResume_WiresCompletionGate`. **NXD port pending.** + ### Model ID Compatibility - **Use undated aliases, not dated snapshots.** Current defaults: `claude-opus-4-8` (tech_lead), `claude-sonnet-4-6` (senior/qa/manager), `claude-haiku-4-5` (cheapest). All three are verified working on the Claude CLI subscription tier. - **Default execution tiers are all-Anthropic (2026-06-24 fix).** `DefaultConfig` previously set junior/intermediate/supervisor to `{google, gemma-4-27b-it}` — a model that 404s on the Google AI API (it does not exist on `v1beta`). Every low-complexity story spawned a gemini agent that died in ~10s producing no code, then limped forward by escalating to senior. Defaults are now `{anthropic, claude-haiku-4-5}` so a fresh install works with only the Claude CLI configured (no Google AI key/quota). `TestDefaultConfig_NoInvalidJuniorModel` pins this. **A model 404 in the agent runtime surfaces as "agent produced no code changes," NOT as a model error — if a whole tier silently produces nothing, validate the model ID with `gemini -m -p OK` / `claude --model -p OK` first.** diff --git a/README.md b/README.md index 980c510..d69c872 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ Run `vxd init` to generate `vxd.yaml` with sensible defaults, then customize: | `merge` | Auto-merge toggle, base branch, PR body template, and human review mode (`auto`/`plan_only`/`manual`) | `auto_merge: true`, `base_branch: main`, `review_mode: auto` | | `runtimes` | Map of named CLI runtime definitions — command, args, supported models, and idle/permission detection patterns | Includes built-in entries for `claude-code`, `codex`, `gemini`, `swe-agent`; each supports optional `runner: docker\|ssh` | | `billing` | Hourly consulting rate, currency, Fibonacci-to-hours range mapping, and LLM cost accounting mode | `default_rate: 150.0`, `currency: USD`, `llm_costs.mode: subscription` | -| `qa` | Declarative success criteria evaluated after each story (output_contains, file_exists, file_contains, exit_code_zero, etc.) | No criteria by default; standard lint/build/test always run | +| `qa` | Declarative success criteria evaluated after each story (output_contains, file_exists, file_contains, exit_code_zero, etc.); `disable_pre_merge_verify` (turn off the per-story pre-merge build/test gate); and the requirement-completion gate — `disable_completion_gate` (turn off) + `completion_fix_cycles` (auto-fix attempts against a red composed mainline before blocking; `0`→default 2, negative→hard gate). The completion gate verifies the merged mainline and emits `REQ_BLOCKED` instead of `REQ_COMPLETED` when it cannot make the build/tests green. | No criteria by default; standard lint/build/test always run; `disable_pre_merge_verify: false`, `disable_completion_gate: false`, `completion_fix_cycles: 2` | | `sla` | Per-Fibonacci-point maximum story duration in minutes; `auto_escalate` promotes breached stories to the next tier | `1pt→60m`, `2pt→120m`, `3pt→240m`, `5pt→480m`, `8pt→960m`, `13pt→1920m`; `auto_escalate: false` | | `secrets` | Secrets provider: `env` (default, reads from environment) or `vault` (HashiCorp Vault KV v2) | `provider: env`; Vault settings: `vault_mount: secret`, `vault_path: vxd` | | `notify` | Outbound Slack webhook URL and per-event triggers (`notify_on_sla`, `notify_on_complete`) | Disabled by default (empty `slack_webhook_url`) | diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 8436030..c10c883 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -497,6 +497,23 @@ func runResume(cmd *cobra.Command, args []string) error { )) } + // Enable the requirement-completion verification gate: after all stories + // merge, verify the composed mainline (build + tests) and only emit + // REQ_COMPLETED when it is green — auto-fixing a red build for a bounded + // number of cycles (godmode client required to apply fixes), else emit + // REQ_BLOCKED. This closes the gap where a requirement was reported complete + // on code that does not compile. Skipped in dry-run (no real toolchain) and + // when explicitly disabled via qa.disable_completion_gate. + if !dryRun && !s.Config.QA.DisableCompletionGate { + fixCycles := completionFixCycles(s.Config.QA.CompletionFixCycles) + senior := s.Config.Models.Senior + monitor.SetCompletionGate(engine.NewCompletionGate( + llmClient, senior.Model, senior.MaxTokens, fixCycles, + s.Config.Merge.BaseBranch, s.Events, s.Proj, + )) + log.Printf("[resume] completion gate enabled (auto-fix cycles=%d)", fixCycles) + } + rc := &engine.RunContext{ ReqID: reqID, PlannedStories: plannedStories, @@ -656,6 +673,21 @@ func buildQAConfig(cfg config.Config, projectDir, repoDir string) engine.QAConfi return qaCfg } +// completionFixCycles maps the configured qa.completion_fix_cycles value to the +// number of auto-fix cycles the completion gate should run: 0 selects the +// default of 2; a negative value disables auto-fix (hard gate — verify once, +// block on red); a positive value passes through verbatim. +func completionFixCycles(configured int) int { + switch { + case configured == 0: + return 2 + case configured < 0: + return 0 + default: + return configured + } +} + // resolveReviewerClient picks the client + model config for the post-execution // code reviewer. When Models.Reviewer specifies a provider it is built // independently (e.g. codex/gpt-5.5) — the reviewer is never spawned as a diff --git a/internal/cli/resume_helpers_test.go b/internal/cli/resume_helpers_test.go index cf799bb..2b81c70 100644 --- a/internal/cli/resume_helpers_test.go +++ b/internal/cli/resume_helpers_test.go @@ -140,3 +140,23 @@ func TestRunResume_FlagsParse(t *testing.T) { } } + +func TestCompletionFixCycles(t *testing.T) { + cases := []struct { + name string + input int + expected int + }{ + {"zero uses default of 2", 0, 2}, + {"positive value passes through", 3, 3}, + {"one passes through", 1, 1}, + {"negative disables auto-fix (hard gate)", -1, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := completionFixCycles(tc.input); got != tc.expected { + t.Errorf("completionFixCycles(%d) = %d, want %d", tc.input, got, tc.expected) + } + }) + } +} diff --git a/internal/cli/resume_wiring_test.go b/internal/cli/resume_wiring_test.go index 98ecca1..75790fc 100644 --- a/internal/cli/resume_wiring_test.go +++ b/internal/cli/resume_wiring_test.go @@ -25,3 +25,21 @@ func TestResume_WiresTechLeadFixer(t *testing.T) { } } } + +// TestResume_WiresCompletionGate guards the requirement-completion gate against +// the same dead-wire class: the gate blocks REQ_COMPLETED on a red composed +// mainline, but only if runResume actually constructs and attaches it. This +// scans the resume source to confirm the gate is built and wired. +func TestResume_WiresCompletionGate(t *testing.T) { + src, err := os.ReadFile("resume.go") + if err != nil { + t.Fatalf("read resume.go: %v", err) + } + code := string(src) + + for _, want := range []string{"NewCompletionGate(", "SetCompletionGate("} { + if !strings.Contains(code, want) { + t.Errorf("resume.go must wire the completion gate: missing %q", want) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5fde05b..91b0b4c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -200,6 +200,18 @@ type QAConfig struct { // a story that turns a green base branch red (keeping main always-green). // Default (false) = gate ON. It never blocks when the base is already red. DisablePreMergeVerify bool `yaml:"disable_pre_merge_verify,omitempty"` + // DisableCompletionGate turns OFF the requirement-completion verification + // gate. The gate verifies the composed mainline (build + tests) after all + // stories merge and only emits REQ_COMPLETED when it is green — otherwise it + // auto-fixes a red build (see CompletionFixCycles) and, failing that, emits + // REQ_BLOCKED. Default (false) = gate ON. When disabled, the legacy advisory + // verification runs and the requirement always completes. + DisableCompletionGate bool `yaml:"disable_completion_gate,omitempty"` + // CompletionFixCycles is the number of automatic fix cycles the completion + // gate runs against a red composed mainline before blocking. 0 uses the + // default of 2. Set to a negative value to disable auto-fix (hard gate only: + // verify once, block on red). + CompletionFixCycles int `yaml:"completion_fix_cycles,omitempty"` } // SuccessCriterion defines a declarative QA check. diff --git a/internal/engine/completion_gate.go b/internal/engine/completion_gate.go new file mode 100644 index 0000000..c2fd7a1 --- /dev/null +++ b/internal/engine/completion_gate.go @@ -0,0 +1,182 @@ +package engine + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tzone85/vortex-dispatch/internal/llm" + "github.com/tzone85/vortex-dispatch/internal/state" +) + +// verifyFunc runs a verification cycle against repoDir and returns the result. +// It is a seam so tests can script red/green sequences without a real toolchain. +type verifyFunc func(ctx context.Context, repoDir string, cycle int) VerificationResult + +// completionFixTimeout bounds a single auto-fix agent invocation. +const completionFixTimeout = 15 * time.Minute + +// CompletionGate guards the REQ_COMPLETED signal. When every story has merged, +// it verifies the composed mainline (build + tests + artifacts) and — when the +// build is red — runs a bounded auto-fix loop: dispatch a fix agent, re-verify, +// repeat up to maxCycles. The requirement is only safe to mark complete when +// verification passes; otherwise the caller emits REQ_BLOCKED. +// +// This closes the long-standing gap where per-story QA (run in isolated +// worktrees) could not see cross-story drift, so a requirement was reported +// complete on code that does not compile. +type CompletionGate struct { + client llm.Client // godmode agent that applies fixes; nil ⇒ hard gate only + model string + maxTokens int + maxCycles int + baseBranch string + eventStore state.EventStore + projStore state.ProjectionStore + + // Seams (default to real implementations; overridden in tests). + verify verifyFunc + pull func(repoDir, baseBranch string) +} + +// NewCompletionGate constructs a gate. maxCycles is the number of auto-fix +// attempts before giving up; 0 makes the gate a pure pass/block check with no +// auto-fix. A nil client also degrades the gate to hard-gate behaviour. +func NewCompletionGate( + client llm.Client, + model string, + maxTokens, maxCycles int, + baseBranch string, + es state.EventStore, + ps state.ProjectionStore, +) *CompletionGate { + if baseBranch == "" { + baseBranch = "main" + } + return &CompletionGate{ + client: client, + model: model, + maxTokens: maxTokens, + maxCycles: maxCycles, + baseBranch: baseBranch, + eventStore: es, + projStore: ps, + verify: func(ctx context.Context, repoDir string, cycle int) VerificationResult { + return RunVerificationLoop(ctx, repoDir, cycle) + }, + pull: func(repoDir, baseBranch string) { + pullBaseAfterMerge(repoDir, baseBranch) + }, + } +} + +// Run verifies the composed mainline and auto-fixes a red build up to maxCycles +// times. It returns true when verification is green (safe to emit +// REQ_COMPLETED) and false when the mainline remains red after exhausting the +// auto-fix budget (caller should emit REQ_BLOCKED). +func (g *CompletionGate) Run(ctx context.Context, reqID, repoDir string) bool { + cycle := 1 + res := g.verify(ctx, repoDir, cycle) + if !ShouldRunFixCycle(res) { + log.Printf("[gate] %s: verification clean on first pass — completion permitted", reqID) + return true + } + + for attempt := 1; attempt <= g.maxCycles; attempt++ { + g.recordRedCycle(reqID, repoDir, res) + + if g.client == nil { + log.Printf("[gate] %s: no auto-fix client configured — hard-gating on red build", reqID) + break + } + + log.Printf("[gate] %s: auto-fix cycle %d/%d — dispatching fix agent for %d gap(s)", + reqID, attempt, g.maxCycles, len(res.Gaps)) + if err := g.applyFix(ctx, repoDir, res); err != nil { + log.Printf("[gate] %s: auto-fix cycle %d failed to dispatch: %v", reqID, attempt, err) + break + } + + g.pull(repoDir, g.baseBranch) + + cycle++ + res = g.verify(ctx, repoDir, cycle) + if !ShouldRunFixCycle(res) { + log.Printf("[gate] %s: verification clean after auto-fix cycle %d — completion permitted", + reqID, attempt) + return true + } + } + + log.Printf("[gate] %s: mainline still red after %d auto-fix cycle(s) — BLOCKING completion", + reqID, g.maxCycles) + return false +} + +// recordRedCycle persists the gap requirement to .vxd-fix-gaps.md for operator +// transparency. Best-effort: a write failure is logged, never fatal. +func (g *CompletionGate) recordRedCycle(reqID, repoDir string, res VerificationResult) { + fixReq := GapsToRequirement(res.Gaps, filepath.Base(repoDir)) + if fixReq == "" { + return + } + fixPath := filepath.Join(repoDir, ".vxd-fix-gaps.md") + if err := os.WriteFile(fixPath, []byte(fixReq), 0o600); err != nil { + log.Printf("[gate] %s: failed to write %s: %v", reqID, fixPath, err) + } +} + +// applyFix dispatches a single synchronous fix-agent run. The agent runs in +// godmode (skip-permissions) in the project's working directory, so it can +// read the codebase, edit files, run the build/tests, and commit + push the +// reconciliation to the base branch. +func (g *CompletionGate) applyFix(ctx context.Context, repoDir string, res VerificationResult) error { + fixCtx, cancel := context.WithTimeout(ctx, completionFixTimeout) + defer cancel() + + prompt := g.buildFixPrompt(repoDir, res) + _, err := g.client.Complete(fixCtx, llm.CompletionRequest{ + Model: g.model, + MaxTokens: g.maxTokens, + System: "You are a Tech Lead repairing a multi-story integration on the main branch. " + + "The composed codebase does not build or its tests fail. Make the minimal changes " + + "needed to turn the build and tests green, then commit and push to the base branch.", + Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, + }) + return err +} + +// buildFixPrompt describes the failing build/tests and the exact remediation +// contract (fix → build → test → commit → push). +func (g *CompletionGate) buildFixPrompt(repoDir string, res VerificationResult) string { + var sb strings.Builder + sb.WriteString("The main branch of this repository is the composed result of several merged stories ") + sb.WriteString("and is currently failing verification.\n\n") + + fmt.Fprintf(&sb, "Build passes: %v\n", res.BuildPasses) + fmt.Fprintf(&sb, "Tests: %d passing / %d failing / %d total\n\n", res.TestsPassing, res.TestsFailing, res.TestsTotal) + + if len(res.Gaps) > 0 { + sb.WriteString("Gaps detected:\n") + for _, gap := range res.Gaps { + fmt.Fprintf(&sb, " - [%s/%s] %s: %s\n", gap.Category, gap.Severity, gap.File, gap.Detail) + } + sb.WriteString("\n") + } + + sb.WriteString("Working directory: ") + sb.WriteString(repoDir) + sb.WriteString("\n\nDo the following, in order:\n") + sb.WriteString("1. Investigate the failing build/tests (read the affected files and error output).\n") + sb.WriteString("2. Apply the MINIMAL change that reconciles the cross-story break — typically a missing ") + sb.WriteString("interface method, an unwired entry point, an import mismatch, or a composition root that ") + sb.WriteString("was never assembled. Do not rewrite working code.\n") + sb.WriteString("3. Run the project's build and test commands and confirm they pass.\n") + fmt.Fprintf(&sb, "4. Commit the fix with a clear message and push it to the '%s' branch.\n", g.baseBranch) + sb.WriteString("Do NOT ask clarifying questions. Do NOT produce JSON. Apply the fix directly.") + return sb.String() +} diff --git a/internal/engine/completion_gate_test.go b/internal/engine/completion_gate_test.go new file mode 100644 index 0000000..6938579 --- /dev/null +++ b/internal/engine/completion_gate_test.go @@ -0,0 +1,223 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/tzone85/vortex-dispatch/internal/llm" + "github.com/tzone85/vortex-dispatch/internal/state" +) + +// fakeFixClient records how many fix-agent invocations the gate made and +// returns a canned response so applyFix succeeds without spawning a real agent. +type fakeFixClient struct { + mu sync.Mutex + calls int + last llm.CompletionRequest +} + +func (c *fakeFixClient) Complete(_ context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + c.last = req + return llm.CompletionResponse{Content: "applied the fix"}, nil +} + +func (c *fakeFixClient) callCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls +} + +func green() VerificationResult { + return VerificationResult{BuildPasses: true, TestsPassing: 3, TestsTotal: 3} +} + +func red() VerificationResult { + return VerificationResult{BuildPasses: false, Gaps: []VerificationGap{ + {Category: "build", Severity: "critical", File: "main.go", Detail: "does not compile"}, + }} +} + +// scriptedVerify returns each result in sequence, repeating the last forever. +func scriptedVerify(results ...VerificationResult) (verifyFunc, *int) { + calls := 0 + fn := func(_ context.Context, _ string, _ int) VerificationResult { + r := results[min(calls, len(results)-1)] + calls++ + return r + } + return fn, &calls +} + +func newTestGate(t *testing.T, client llm.Client, maxCycles int, verify verifyFunc) (*CompletionGate, string) { + t.Helper() + repoDir := t.TempDir() + g := NewCompletionGate(client, "test-model", 1000, maxCycles, "main", nil, nil) + g.verify = verify + g.pull = func(_, _ string) {} // no-op pull in tests + return g, repoDir +} + +func TestCompletionGate_GreenFirstPass_NoFix(t *testing.T) { + client := &fakeFixClient{} + verify, vCalls := scriptedVerify(green()) + g, repoDir := newTestGate(t, client, 2, verify) + + passed := g.Run(context.Background(), "REQ-1", repoDir) + + if !passed { + t.Fatal("expected gate to pass on a green first verification") + } + if client.callCount() != 0 { + t.Errorf("expected no fix-agent calls on green, got %d", client.callCount()) + } + if *vCalls != 1 { + t.Errorf("expected exactly 1 verification, got %d", *vCalls) + } +} + +func TestCompletionGate_RedThenGreen_AutoFixes(t *testing.T) { + client := &fakeFixClient{} + verify, vCalls := scriptedVerify(red(), green()) + g, repoDir := newTestGate(t, client, 2, verify) + + passed := g.Run(context.Background(), "REQ-2", repoDir) + + if !passed { + t.Fatal("expected gate to pass after one successful auto-fix") + } + if client.callCount() != 1 { + t.Errorf("expected exactly 1 fix-agent call, got %d", client.callCount()) + } + if *vCalls != 2 { + t.Errorf("expected 2 verifications (initial + post-fix), got %d", *vCalls) + } +} + +func TestCompletionGate_StaysRed_Blocks(t *testing.T) { + client := &fakeFixClient{} + verify, _ := scriptedVerify(red()) // always red + g, repoDir := newTestGate(t, client, 2, verify) + + passed := g.Run(context.Background(), "REQ-3", repoDir) + + if passed { + t.Fatal("expected gate to block when verification never goes green") + } + if client.callCount() != 2 { + t.Errorf("expected fix-agent invoked maxCycles=2 times, got %d", client.callCount()) + } +} + +func TestCompletionGate_NilClient_DegradesToHardGate(t *testing.T) { + verify, _ := scriptedVerify(red()) + g, repoDir := newTestGate(t, nil, 2, verify) // no godmode client wired + + passed := g.Run(context.Background(), "REQ-4", repoDir) + + if passed { + t.Fatal("expected hard gate to block on red with no auto-fix client") + } +} + +func TestCompletionGate_WritesGapsFileOnRed(t *testing.T) { + client := &fakeFixClient{} + verify, _ := scriptedVerify(red()) + g, repoDir := newTestGate(t, client, 1, verify) + + g.Run(context.Background(), "REQ-5", repoDir) + + if _, err := os.Stat(filepath.Join(repoDir, ".vxd-fix-gaps.md")); err != nil { + t.Errorf("expected .vxd-fix-gaps.md to be written for operator transparency: %v", err) + } +} + +// writeGoModule writes a minimal buildable/unbuildable Go module into dir. +func writeGoModule(t *testing.T, dir, mainBody string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module gatecheck\n\ngo 1.21\n"), 0o600); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainBody), 0o600); err != nil { + t.Fatalf("write main.go: %v", err) + } +} + +// TestCompletionGate_RealVerify_BlocksBrokenGoModule drives the gate's REAL +// default verification (an actual `go build`) — not the scripted seam — against +// a module that does not compile, with no auto-fix client. The gate must block. +// This proves the real RunVerificationLoop → ShouldRunFixCycle → gate-decision +// path integrates on a real filesystem. +func TestCompletionGate_RealVerify_BlocksBrokenGoModule(t *testing.T) { + if testing.Short() { + t.Skip("skipping real-build verification in -short mode") + } + repoDir := t.TempDir() + writeGoModule(t, repoDir, "package main\n\nfunc main() {\n\tvar x int = \"not an int\"\n\t_ = x\n}\n") + + // nil client ⇒ hard gate (verify once, no auto-fix). Real verify seam. + g := NewCompletionGate(nil, "", 0, 0, "main", nil, nil) + g.pull = func(_, _ string) {} + + if g.Run(context.Background(), "REQ-REAL-RED", repoDir) { + t.Fatal("expected gate to BLOCK a composed mainline that does not compile") + } +} + +// TestCompletionGate_RealVerify_PassesHealthyGoModule is the positive control: +// a module that builds and has no failing tests passes the real verification. +func TestCompletionGate_RealVerify_PassesHealthyGoModule(t *testing.T) { + if testing.Short() { + t.Skip("skipping real-build verification in -short mode") + } + repoDir := t.TempDir() + writeGoModule(t, repoDir, "package main\n\nfunc main() {\n\tprintln(\"ok\")\n}\n") + // README present so the doc gap (medium) is moot; build is the gating signal. + if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("# gatecheck\n"), 0o600); err != nil { + t.Fatalf("write README: %v", err) + } + + g := NewCompletionGate(nil, "", 0, 0, "main", nil, nil) + g.pull = func(_, _ string) {} + + if !g.Run(context.Background(), "REQ-REAL-GREEN", repoDir) { + t.Fatal("expected gate to PASS a composed mainline that builds cleanly") + } +} + +// TestEmitRequirementOutcome_Blocked proves the monitor's terminal-event helper +// drives a real event + projection store: emitting REQ_BLOCKED transitions the +// requirement to "blocked" status (the gate's negative outcome), not "completed". +func TestEmitRequirementOutcome_Blocked(t *testing.T) { + dir := t.TempDir() + es, err := state.NewFileStore(filepath.Join(dir, "events.jsonl")) + if err != nil { + t.Fatalf("event store: %v", err) + } + defer es.Close() + ps, err := state.NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("proj store: %v", err) + } + defer ps.Close() + + if err := ps.Project(state.NewEvent(state.EventReqSubmitted, "test", "", map[string]any{"id": "REQ-G1", "title": "Gate"})); err != nil { + t.Fatalf("seed requirement: %v", err) + } + + m := &Monitor{eventStore: es, projStore: ps} + m.emitRequirementOutcome("REQ-G1", state.EventReqBlocked, "REQ_BLOCKED") + + req, err := ps.GetRequirement("REQ-G1") + if err != nil { + t.Fatalf("get requirement: %v", err) + } + if req.Status != "blocked" { + t.Errorf("expected status 'blocked' after REQ_BLOCKED, got %q", req.Status) + } +} diff --git a/internal/engine/integration_build.go b/internal/engine/integration_build.go index 19c6346..b85255c 100644 --- a/internal/engine/integration_build.go +++ b/internal/engine/integration_build.go @@ -19,6 +19,7 @@ const ( projectGo projectNode projectRust + projectPython ) // integrationBuildTimeout is the maximum time allowed for a post-merge build. @@ -35,53 +36,118 @@ func detectProjectKind(repoDir string) projectKind { return projectRust case fileExists(filepath.Join(repoDir, "package.json")): return projectNode + case fileExists(filepath.Join(repoDir, "pyproject.toml")), + fileExists(filepath.Join(repoDir, "setup.py")), + fileExists(filepath.Join(repoDir, "setup.cfg")), + fileExists(filepath.Join(repoDir, "requirements.txt")): + // Python has no compile step; the integration gate is the test suite, + // which is exactly what catches composition gaps (routes declared but + // not wired, app factory not assembled) that import/compile cleanly. + return projectPython default: return projectUnknown } } -// runIntegrationBuild runs the project's build command against repoDir and -// returns combined stderr+stdout on failure, or nil on success. +// integrationCommand is a PURE function (no I/O beyond reading repo markers +// already classified by the caller) that selects the post-merge verification +// command for a project kind. It returns (argv, true) when a command should +// run, or (nil, false) when the kind has no runnable verification (skip). // // Detection rules (first match wins): // - go.mod → go build ./... // - Cargo.toml → cargo build -// - package.json → npm run build (only if a "build" script exists) +// - package.json → npm run build (only if a "build" script exists) +// - pyproject/... → python3 -m pytest -q (only if the project ships tests) // -// When no build system is recognised the function returns nil (best-effort). -func runIntegrationBuild(repoDir string) error { - kind := detectProjectKind(repoDir) - - ctx, cancel := context.WithTimeout(context.Background(), integrationBuildTimeout) - defer cancel() - - var cmd *exec.Cmd +// Keeping this separate from execution makes command selection unit-testable +// without invoking the toolchain. +func integrationCommand(kind projectKind, repoDir string) ([]string, bool) { switch kind { case projectGo: - cmd = exec.CommandContext(ctx, "go", "build", "./...") + return []string{"go", "build", "./..."}, true case projectRust: - cmd = exec.CommandContext(ctx, "cargo", "build") + return []string{"cargo", "build"}, true case projectNode: if !hasNPMBuildScript(repoDir) { - return nil // no "build" script — skip + return nil, false // no "build" script — skip + } + return []string{"npm", "run", "build"}, true + case projectPython: + if !hasPythonTests(repoDir) { + return nil, false // no test suite to run — skip } - cmd = exec.CommandContext(ctx, "npm", "run", "build") + return []string{"python3", "-m", "pytest", "-q"}, true default: - return nil // unrecognised build system — best-effort no-op + return nil, false // unrecognised build system — best-effort no-op + } +} + +// runIntegrationBuild runs the project's post-merge verification command against +// repoDir and returns combined stderr+stdout on failure, or nil on success. +// +// When no verification command is applicable the function returns nil +// (best-effort). For Python the verification is the test suite: a FastAPI/Flask +// app whose routes are declared but never wired imports cleanly, so only an +// assembled-app test — required by the planner's acceptance criteria — fails +// here, which dispatches a TechLeadFixer integration-fix story. +func runIntegrationBuild(repoDir string) error { + kind := detectProjectKind(repoDir) + + argv, ok := integrationCommand(kind, repoDir) + if !ok { + return nil } + ctx, cancel := context.WithTimeout(context.Background(), integrationBuildTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Dir = repoDir out, err := cmd.CombinedOutput() if err != nil { output := bytes.TrimSpace(out) if len(output) == 0 { - return fmt.Errorf("build command exited with no output: %w", err) + return fmt.Errorf("verification command %v exited with no output: %w", argv, err) } return fmt.Errorf("%s", output) } return nil } +// hasPythonTests reports whether repoDir ships a runnable test suite, so the +// integration gate only invokes pytest when there is something to run (mirrors +// the Node "build script must exist" guard). True when a tests/ directory +// exists, pytest is configured in pyproject.toml/setup.cfg, or a top-level +// test_*.py / *_test.py file is present. +func hasPythonTests(repoDir string) bool { + if info, err := os.Stat(filepath.Join(repoDir, "tests")); err == nil && info.IsDir() { + return true + } + for _, cfg := range []string{"pyproject.toml", "setup.cfg", "pytest.ini", "tox.ini"} { + if data, err := os.ReadFile(filepath.Join(repoDir, cfg)); err == nil { + if strings.Contains(string(data), "pytest") { + return true + } + } + } + entries, err := os.ReadDir(repoDir) + if err != nil { + return false + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".py") && + (strings.HasPrefix(name, "test_") || strings.HasSuffix(name, "_test.py")) { + return true + } + } + return false +} + // hasNPMBuildScript reads package.json and checks for a "build" script entry // without importing a full JSON parser — a simple substring check suffices. func hasNPMBuildScript(repoDir string) bool { diff --git a/internal/engine/integration_build_test.go b/internal/engine/integration_build_test.go index 08ae058..da5f7df 100644 --- a/internal/engine/integration_build_test.go +++ b/internal/engine/integration_build_test.go @@ -3,6 +3,7 @@ package engine import ( "os" "path/filepath" + "strings" "testing" ) @@ -99,3 +100,80 @@ func TestIntegrationBuild_GoProjectFailsOnBrokenCode(t *testing.T) { t.Error("expected non-empty error message from build failure") } } + +// TestIntegrationBuild_DetectsPythonProject pins the gap that let a Python +// FastAPI build (pulsereview) merge with endpoints declared-but-unwired and +// receive NO post-merge integration verification at all: Python was not a +// recognised project kind, so detectProjectKind returned projectUnknown and the +// gate was a silent no-op. Each canonical Python marker must now classify. +func TestIntegrationBuild_DetectsPythonProject(t *testing.T) { + for _, marker := range []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"} { + t.Run(marker, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, marker), []byte("# marker\n"), 0o644); err != nil { + t.Fatal(err) + } + if kind := detectProjectKind(dir); kind != projectPython { + t.Errorf("marker %s: expected projectPython, got %v", marker, kind) + } + }) + } +} + +// TestIntegrationCommand_PythonRunsTestsWhenPresent proves the recurrence guard: +// a Python project that ships a test suite is verified post-merge by running +// pytest. Combined with the planner's assembled-app acceptance criteria, a +// future "route declared but not wired" defect fails that suite here and +// dispatches an integration-fix story — instead of merging green as before. +func TestIntegrationCommand_PythonRunsTestsWhenPresent(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "pyproject.toml"), []byte("[project]\nname='x'\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "tests"), 0o755); err != nil { + t.Fatal(err) + } + + argv, ok := integrationCommand(detectProjectKind(dir), dir) + if !ok { + t.Fatal("expected a verification command for a Python project with tests/, got skip") + } + if got := strings.Join(argv, " "); !strings.Contains(got, "pytest") { + t.Errorf("expected pytest verification command, got %q", got) + } +} + +// TestIntegrationCommand_PythonWithoutTestsSkips mirrors the Node "no build +// script → skip" guard: a Python project with no discoverable test suite must +// not invoke pytest (which would error with "no tests ran" and produce a false +// integration-fix). +func TestIntegrationCommand_PythonWithoutTestsSkips(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("fastapi\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, ok := integrationCommand(detectProjectKind(dir), dir); ok { + t.Error("expected skip (no tests) for a Python project without a test suite") + } +} + +// TestIntegrationCommand_GoAndNodeUnchanged guards against a regression in the +// existing command selection while refactoring detection into a pure helper. +func TestIntegrationCommand_GoAndNodeUnchanged(t *testing.T) { + goDir := t.TempDir() + if err := os.WriteFile(filepath.Join(goDir, "go.mod"), []byte("module x\n"), 0o644); err != nil { + t.Fatal(err) + } + if argv, ok := integrationCommand(detectProjectKind(goDir), goDir); !ok || strings.Join(argv, " ") != "go build ./..." { + t.Errorf("go: got %v ok=%v", argv, ok) + } + + nodeDir := t.TempDir() + if err := os.WriteFile(filepath.Join(nodeDir, "package.json"), []byte(`{"scripts":{"build":"tsc"}}`), 0o644); err != nil { + t.Fatal(err) + } + if argv, ok := integrationCommand(detectProjectKind(nodeDir), nodeDir); !ok || strings.Join(argv, " ") != "npm run build" { + t.Errorf("node: got %v ok=%v", argv, ok) + } +} diff --git a/internal/engine/monitor.go b/internal/engine/monitor.go index 6e74faa..8943678 100644 --- a/internal/engine/monitor.go +++ b/internal/engine/monitor.go @@ -97,6 +97,11 @@ type Monitor struct { // techLeadFixer dispatches a focused fix story when the post-merge // integration build on main fails. Nil disables the feature. techLeadFixer *TechLeadFixer + + // completionGate verifies the composed mainline (build + tests) before a + // requirement is marked complete, auto-fixing a red build up to a bounded + // number of cycles. Nil falls back to the legacy advisory verification. + completionGate *CompletionGate } // SetNotifier configures the outbound webhook notifier (Slack, Discord, etc.). @@ -212,6 +217,14 @@ func (m *Monitor) SetTechLeadFixer(f *TechLeadFixer) { m.techLeadFixer = f } +// SetCompletionGate wires the requirement-completion verification gate. When +// set, the monitor verifies the composed mainline before emitting +// REQ_COMPLETED and only completes the requirement when the build/tests are +// green (auto-fixing a red build up to the gate's cycle budget first). +func (m *Monitor) SetCompletionGate(g *CompletionGate) { + m.completionGate = g +} + // RunContext carries the state needed for auto-resume across waves. type RunContext struct { ReqID string diff --git a/internal/engine/monitor_dispatch.go b/internal/engine/monitor_dispatch.go index 93a958d..e3058bd 100644 --- a/internal/engine/monitor_dispatch.go +++ b/internal/engine/monitor_dispatch.go @@ -57,20 +57,46 @@ func (m *Monitor) dispatchNextWave(ctx context.Context, rc *RunContext, repoDir generateDocumentation(ctx, repoDir, reqTitle, storyTitles, m.docClient, m.docModel) } - // Run verification loop (Cycle 1): check build, tests, hallucinations, artifacts. repoDir := "." if wd, err := os.Getwd(); err == nil { repoDir = wd } - verifyResult := RunVerificationLoop(ctx, repoDir, 1) + // Pull merged changes into the local checkout FIRST so verification + // runs against the true composed mainline (all merged PRs), not a + // stale pre-VXD checkout. Without this, the repo's local files lag + // behind origin and the gate would verify the wrong tree. + pullBaseAfterMerge(repoDir, m.config.Merge.BaseBranch) + + // Leave the workspace neat: remove dangling branches (and their open + // PRs) from stories that never merged. Merged branches are already gone. + m.cleanupDanglingBranches(rc.ReqID, repoDir) + + // Completion gate: verify the composed mainline (build + tests) and + // auto-fix a red build up to a bounded number of cycles. Only emit + // REQ_COMPLETED when verification is green; otherwise emit REQ_BLOCKED + // so a requirement is never reported complete on code that does not + // compile. Falls back to the legacy advisory verification when no gate + // is wired (e.g. dry-run or no LLM client). + if m.completionGate != nil { + if m.completionGate.Run(ctx, rc.ReqID, repoDir) { + m.emitRequirementOutcome(rc.ReqID, state.EventReqCompleted, "REQ_COMPLETED") + } else { + log.Printf("[gate] %s: completion blocked — see .vxd-fix-gaps.md; run 'vxd resume %s --godmode' after addressing the gaps", rc.ReqID, rc.ReqID) + m.emitRequirementOutcome(rc.ReqID, state.EventReqBlocked, "REQ_BLOCKED") + } + return nil + } + + // Legacy advisory verification (no gate wired): check build/tests and + // write a fix-gaps file, but complete the requirement regardless. + verifyResult := RunVerificationLoop(ctx, repoDir, 1) if ShouldRunFixCycle(verifyResult) { log.Printf("[verify] cycle 1 found %d gaps — generating fix requirement", len(verifyResult.Gaps)) fixReq := GapsToRequirement(verifyResult.Gaps, filepath.Base(repoDir)) if fixReq != "" { - // Write the fix requirement for manual or auto re-dispatch fixPath := filepath.Join(repoDir, ".vxd-fix-gaps.md") - if err := os.WriteFile(fixPath, []byte(fixReq), 0644); err != nil { + if err := os.WriteFile(fixPath, []byte(fixReq), 0o600); err != nil { log.Printf("[verify] failed to write fix requirement to %s: %v", fixPath, err) } else { log.Printf("[verify] fix requirement written to %s", fixPath) @@ -81,25 +107,7 @@ func (m *Monitor) dispatchNextWave(ctx context.Context, rc *RunContext, repoDir log.Printf("[verify] cycle 1 clean — no critical gaps found") } - // Pull merged changes into the local checkout so the repo - // reflects all merged PRs. Without this, local files are stale - // and tools that read the repo see pre-VXD state. - // Note: repoDir here is the shadowed local (line 977), which - // resolves to cwd — the actual project root where VXD was invoked. - pullBaseAfterMerge(repoDir, m.config.Merge.BaseBranch) - - // Leave the workspace neat: remove dangling branches (and their open - // PRs) from stories that never merged. Merged branches are already gone. - m.cleanupDanglingBranches(rc.ReqID, repoDir) - - // Mark requirement complete. - compEvt := state.NewEvent(state.EventReqCompleted, "monitor", "", map[string]any{"id": rc.ReqID}) - if appErr := m.eventStore.Append(compEvt); appErr != nil { - log.Printf("[pipeline] append REQ_COMPLETED for %s: %v", rc.ReqID, appErr) - } - if projErr := m.projStore.Project(compEvt); projErr != nil { - log.Printf("[pipeline] project REQ_COMPLETED for %s: %v", rc.ReqID, projErr) - } + m.emitRequirementOutcome(rc.ReqID, state.EventReqCompleted, "REQ_COMPLETED") return nil } @@ -266,5 +274,18 @@ func IsStoryComplete(status string) bool { return state.IsStoryComplete(status) } +// emitRequirementOutcome appends and projects a terminal requirement event +// (REQ_COMPLETED or REQ_BLOCKED), logging any store error with context. label +// is the human-readable event name used in log lines. +func (m *Monitor) emitRequirementOutcome(reqID string, evtType state.EventType, label string) { + evt := state.NewEvent(evtType, "monitor", "", map[string]any{"id": reqID}) + if appErr := m.eventStore.Append(evt); appErr != nil { + log.Printf("[pipeline] append %s for %s: %v", label, reqID, appErr) + } + if projErr := m.projStore.Project(evt); projErr != nil { + log.Printf("[pipeline] project %s for %s: %v", label, reqID, projErr) + } +} + // simulateDryRunChanges writes a placeholder file and commits it so the // post-execution pipeline has a non-empty diff to work with. Without this, diff --git a/internal/engine/planner.go b/internal/engine/planner.go index 815c466..9d1b1db 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -159,6 +159,7 @@ ENGINEERING STANDARDS — every code story's description AND acceptance_criteria - Security: for any web/HTML/API/templating surface, prevent XSS (escape/encode all output, sanitize HTML) and injection (parameterized queries, no string-built SQL/commands); never reflect unsanitized input into responses or markup. State the specific protection in the acceptance criteria. - SOLID + clean architecture: single-responsibility units, depend on interfaces not concretions, keep core/domain logic free of I/O (hexagonal core/shell split); files focused and small. - Proper wiring: everything built must be reachable from a real entry point (CLI command, HTTP route, public export) — no dead/unreferenced code. Acceptance criteria must assert the wiring (e.g. "route registered and returns 200", "command appears in --help"). +- Assembled-app verification (HTTP/API surfaces): a story delivering an endpoint is NOT done when unit tests pass with injected/overridden/mocked dependencies — that hides the most common production failure (route declared but never registered, a dependency stub never overridden by the composition root, two endpoints holding separate stores). Such a story's acceptance criteria MUST include an integration test that boots the REAL app via its production entry point / app factory with NO dependency overrides, asserts the endpoint is registered at its exact spec path (responds, not 404), and asserts data written through one endpoint is visible through another (shared store, not per-route state). - Error handling: handle errors explicitly at every level; no silent failures; user-facing messages must not leak internals/stack traces. - Tests: every code story includes tests covering the happy path AND at least one failure/edge path. - Docs as you go: stories that add a public surface document it (in docs/ or the relevant module doc) so the final README/training can link real content. diff --git a/internal/engine/planner_test.go b/internal/engine/planner_test.go index 9861da5..86fa116 100644 --- a/internal/engine/planner_test.go +++ b/internal/engine/planner_test.go @@ -248,6 +248,45 @@ func TestPlanner_PromptIncludesEngineeringStandards(t *testing.T) { } } +// TestPlanner_PromptMandatesAssembledAppEndpointTests pins the recurrence guard +// for the compose-gap class observed on pulsereview: endpoint stories shipped +// green because their unit tests used dependency overrides, while the assembled +// app left routes 404 / dependencies unwired. The planner prompt MUST instruct +// that endpoint/surface stories require an assembled-app integration test (no +// overrides, route not 404, cross-endpoint shared state) — so such a test +// exists, runs in QA + the post-merge gate, and fails when wiring is missing. +func TestPlanner_PromptMandatesAssembledAppEndpointTests(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":[]}]` + 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:") + defer ps.Close() + planner := engine.NewPlanner(client, cfg, es, ps) + if _, err := planner.Plan(context.Background(), "r-001", "Build a web API", dir); err != nil { + t.Fatalf("plan: %v", err) + } + + prompt := client.CallAt(0).Messages[0].Content + for _, must := range []string{ + "Assembled-app verification", + "NO dependency overrides", + "not 404", + "app factory", + "visible through another", + } { + if !strings.Contains(prompt, must) { + t.Errorf("decomposition prompt missing assembled-app mandate %q", must) + } + } +} + // 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) { diff --git a/internal/state/events.go b/internal/state/events.go index 35cca5e..eebb967 100644 --- a/internal/state/events.go +++ b/internal/state/events.go @@ -19,6 +19,7 @@ const ( EventReqPaused EventType = "REQ_PAUSED" EventReqResumed EventType = "REQ_RESUMED" EventReqCompleted EventType = "REQ_COMPLETED" + EventReqBlocked EventType = "REQ_BLOCKED" EventReqEstimated EventType = "REQ_ESTIMATED" // Story lifecycle events. diff --git a/internal/state/projection_test.go b/internal/state/projection_test.go index c83afed..7551061 100644 --- a/internal/state/projection_test.go +++ b/internal/state/projection_test.go @@ -218,6 +218,23 @@ func TestProject_ReqAnalyzed(t *testing.T) { } } +func TestProject_ReqBlocked(t *testing.T) { + dir := t.TempDir() + s, err := NewSQLiteStore(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("create store: %v", err) + } + defer s.Close() + + s.Project(NewEvent(EventReqSubmitted, "", "", map[string]any{"id": "REQ-BL1", "title": "Blocked"})) + s.Project(NewEvent(EventReqBlocked, "", "", map[string]any{"id": "REQ-BL1"})) + + req, _ := s.GetRequirement("REQ-BL1") + if req.Status != "blocked" { + t.Errorf("expected blocked, got %s", req.Status) + } +} + func TestProject_ReqCompleted(t *testing.T) { dir := t.TempDir() s, err := NewSQLiteStore(filepath.Join(dir, "test.db")) diff --git a/internal/state/sqlite.go b/internal/state/sqlite.go index c6d2214..1d05e38 100644 --- a/internal/state/sqlite.go +++ b/internal/state/sqlite.go @@ -199,6 +199,8 @@ func (s *SQLiteStore) Project(evt Event) error { return s.updateReqStatus(payload, "planned") case EventReqCompleted: return s.updateReqStatus(payload, "completed") + case EventReqBlocked: + return s.updateReqStatus(payload, "blocked") case EventReqEstimated: return s.projectReqEstimated(payload) case EventPlanRejected: