diff --git a/.firecrawl/devin-2-pricing.md b/.firecrawl/devin-2-pricing.md new file mode 100644 index 0000000..4b2fda0 --- /dev/null +++ b/.firecrawl/devin-2-pricing.md @@ -0,0 +1,203 @@ +[Carl Franzen](https://venturebeat.com/author/carlfranzen) + +12:11 pm, PT, April 3, 2025 + + + + +[Add to Google Preferred Source](https://www.google.com/preferences/source?q=venturebeat.com "Add to Google Preferred Source") + +The Founders Fund-backed San Francisco startup Cognition AI (also known as Cognition Labs) [made a name for itself out of nowhere](https://venturebeat.com/ai/cognition-emerges-from-stealth-to-launch-ai-software-engineer-devin/) in early 2024 with the release of Devin, its AI-powered software engineer that could work alongside human developers and carry out tasks autonomously through natural language instructions given to them by a human dev through a prompt window or even separate third-party communication app Slack. + +But the AI dev landscape has advanced rapidly since then, with many other companies offering similar functionality and autonomous or semi-autonomous coding agents, including [GitHub Copilot](https://venturebeat.com/programming-development/github-is-making-its-ai-programming-copilot-free-for-vs-code-developers-with-limits/), [AWS Developer Q](https://venturebeat.com/ai/aws-launches-in-line-q-developer-ai-coding-assistant-to-take-on-microsofts-github-copilot/), [Codeium's Windsurf](https://codeium.com/windsurf), and [Cursor](https://www.cursor.com/en). + +Cognition itself has since its inception leveraged other models, namely OpenAI's GPT-4 and GPT-4o series, to power Devin. + +Now today, [Cognition is hitting back with Devin 2.0](https://cognition.ai/blog/devin-2), an updated version of its agent-native software development platform. It's unclear what foundation model is powering this version. + +The new release is now generally available and introduces a range of features aimed at making collaboration between developers and Devin’s autonomous agent smoother and more productive. + +Furthermore, in an age of economic uncertainty and [certain cost increases thanks to the new Trump tariffs](https://www.reuters.com/markets/companies-face-new-reality-trump-tariff-chaos-2025-04-02/), Cognition is also providing a welcome relief with a major price drop: [Devin 2.0 is available starting at $20 per month minimum](https://devin.ai/pricing) ($2.25 per "Agent Compute Unit", how Cognition measures the compute resources required to run Devin), whereas before, previous versions of the software started around $500 per month. + + + +Cognition AI pricing tiers for its Devin product. + +What else does Devin 2.0 offer? + +## Parallel Devins and a new cloud IDE + +Devin 2.0 builds on Cognition Labs’ earlier efforts to streamline software development by allowing users to work alongside autonomous agents. + +The latest version introduces an interactive, cloud-based IDE environment designed to allow users to spin up multiple Devins in parallel, effectively handling numerous tasks at once. Each Devin can work autonomously, with users having the option to step in at any point to review, edit, or guide progress. + +## Interactive planning and task scoping + +A key addition in Devin 2.0 is the introduction of Interactive Planning. This feature allows developers to begin with broad or incomplete ideas and collaborate with Devin to scope out a detailed task plan. + +Within seconds of starting a session, Devin automatically analyzes the codebase, identifies relevant files, and proposes an initial plan, even without specific guidance or instructions from the human user. + +Users can then review and adjust this plan to ensure alignment before allowing Devin to proceed with execution. + +## Exploring your enterprise's codebase + +Devin 2.0 also introduces Devin Search, a tool designed to help users understand and navigate their codebases more effectively. + +The search function enables developers to ask specific questions about their code, receiving detailed responses that cite relevant code snippets. + +For more complex queries that require deeper exploration, users can activate Deep Mode. + +Additionally, the new release includes Devin Wiki, a feature that automatically indexes repositories every few hours. + +Devin Wiki generates comprehensive documentation that includes architecture diagrams, source links, and other relevant details, offering developers an organized and continuously updated reference. + +## Efficiency gains and developer control + +Beyond new features, Cognition Labs reports that Devin 2.0 delivers improved efficiency. + +According to the company, the latest version completes over 83% more junior-level development tasks per Agent Compute Unit (ACU) compared to its predecessor, based on internal benchmarks. Beta users reportedly observed similar performance gains during testing. + +Users can interact with Devin 2.0 through a VSCode-inspired interface that allows for reviewing and editing Devin’s work, as well as running tests directly within the platform’s environment. This flexibility supports both hands-on and hands-off workflows, depending on user preference. + +## A boost from Devin 1.2 + +In early 2025, the [company released Devin 1.2](https://venturebeat.com/ai/devin-1-2-updated-ai-engineer-enhances-coding-smarter-in-context-reasoning-voice-integration/), which included enhancements focused on in-context reasoning and voice command integration. These improvements enabled Devin to better analyze code repositories, recognize patterns, and reuse existing code when appropriate. + +Users could also issue instructions via voice messages in Slack, streamlining how they interacted with the agent. + +Devin 1.2 also introduced features geared toward enterprise environments, such as machine snapshots to simplify login workflows and centralized admin controls for managing multiple Devin workspaces. + +Alongside these functional upgrades, Cognition Labs shifted to a usage-based billing model, allowing customers to pay for additional capacity beyond their subscription limits. + +## How Devin stacks up now to other AI coding agents and platforms + +While Devin’s early releases positioned the platform as an innovative solution for accelerating development workflows, early user feedback highlighted growing pains. + +Researchers and testers noted that the agent sometimes struggled with overly complex code, unnecessary abstractions, and inconsistent task performance. + +Nevertheless, Devin attracted interest from enterprise customers seeking to incorporate autonomous coding agents into their software development processes. + +The new features and capabilities of Devin 2.0 — and a much lower entry price — should be received warmly by devs, and may further uptake of Cognition's platform by them, even encouraging users to defect from other rival coding tools. + +But with GitHub Copilot, Codeium's Windsurf, and Amazon Q Developer among others all offering free versions of their AI coding assistants, Devin 2.0 faces an increasingly tough set of competitors in a white hot market. + +## More + +[\\ +\\ +Credit: VentureBeat made with Midjourney](https://venturebeat.com/technology/satya-nadella-warns-that-ai-could-hollow-out-entire-industries-echoing-the-damage-done-by-globalization) + +Microsoft CEO Satya Nadella [published a sweeping essay](https://x.com/satyanadella/status/2066182223213293753) on Sunday laying out what he describes as the defining economic challenge of the AI era: the risk that a handful of frontier models will absorb the expertise of entire industries and commoditize it, leaving businesses stripped of their competitive moats. + +[Michael Nuñez](https://venturebeat.com/author/michael_nunez) +June 15, 2026 + + +[\\ +\\ +Credit: VentureBeat made with OpenAI ChatGPT-Images-2.0](https://venturebeat.com/technology/when-deep-research-isnt-enough-for-your-business-sakana-ai-launches-ultra-deep-research-agent-for-100-page-reports-in-8-hours) + +Neither Sakana AI nor its external AI service providers will use customer data or inputs for model training or fine-tuning unless the client provides explicit opt-in consent. + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 15, 2026 + + +[\\ +\\ +VentureBeat made with ChatGPT-Images-2.0](https://venturebeat.com/technology/anthropic-blocks-all-public-access-to-claude-fable-5-mythos-5-following-us-government-order-what-enterprises-should-do) + +Enterprises can no longer afford — from an operational reliability standpoint — to run critical workflows on any single AI model or even provider. + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 13, 2026 + + +[\\ +\\ +Credit: Image generated by VentureBeat using FLUX-2-Pro](https://venturebeat.com/technology/kimi-k2-7-code-cuts-thinking-tokens-30-practitioners-say-benchmarks-dont-check-out) + +K2.7-Code authors code directly instead of wrapping libraries — more honest, but two kernels failed and the MoE result regressed from K2.6. + +[Sean Michael Kerner](https://venturebeat.com/author/sean-michael-kerner) +June 12, 2026 + + +[\\ +\\ +Credit: VentureBeat made with Google Nano Banana 2](https://venturebeat.com/technology/xiaomis-new-open-source-agentic-ai-coding-harness-mimo-code-beats-claude-code-at-ultra-long-200-step-tasks) + +The persistent memory system addresses a real and widely felt pain point in agentic development workflows — one that competitors are also racing to solve. + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 11, 2026 + + +[\\ +\\ +Credit: Image generated by VentureBeat with FLUX-2-Pro](https://venturebeat.com/technology/googles-diffusiongemma-generates-256-tokens-in-parallel-and-self-corrects-as-it-goes) + +It writes a full block of tokens at once instead of one at a time, and fixes its own errors mid-generation. Fast on consumer GPUs, weaker on open-ended tasks. + +[Sean Michael Kerner](https://venturebeat.com/author/sean-michael-kerner) +June 11, 2026 + + +[\\ +\\ +Credit: VentureBeat made with Google Nano Banana 2](https://venturebeat.com/technology/surprise-upset-gpt-5-5-beats-claude-fable-5-on-brutal-new-agents-last-exam-benchmark) + +The victory of GPT-5.5 aligns with recent third-party analysis suggesting that OpenAI's models are currently superior at strictly adhering to multi-part, complex prompts + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 10, 2026 + + +[\\ +\\ +Image credit: VentureBeat with ChatGPT](https://venturebeat.com/technology/researchers-say-they-trained-a-foundation-model-from-scratch-for-about-1-500) + +A 1B reasoning model trained for $1,500 matched far larger LLMs on key benchmarks, researchers say — no internet-scale data required. + +[Ben Dickson](https://venturebeat.com/author/ben-dickson-techtalks) +June 10, 2026 + + +[\\ +\\ +Credit: VentureBeat made with OpenAI ChatGPT-Images-2.0](https://venturebeat.com/technology/anthropic-ceo-calls-for-faa-style-regulation-of-powerful-ai-models-what-enterprises-should-know) + +To prepare for this shift, enterprises must first decouple their AI strategies from single-vendor dependencies. If a flagship model is suddenly blocked or recalled under the proposed FAA-style regulatory powers + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 10, 2026 + + +[\\ +\\ +Credit: VentureBeat made with OpenAI ChatGPT-Images-2.0](https://venturebeat.com/technology/apples-new-siri-ai-is-more-than-just-a-smarter-assistant-its-a-new-enterprise-app-layer) + +For enterprise technology leaders, it means Apple’s devices could soon include a native AI assistant that can act across business workflows + +[Carl Franzen](https://venturebeat.com/author/carlfranzen) +June 9, 2026 + + +[\\ +\\ +Credit: Image generated by VentureBeat with FLUX-2-Pro](https://venturebeat.com/technology/cohere-open-sources-a-coding-agent-that-runs-on-a-single-h100) + +A 30B open-source coding agent that runs on one H100 — with a verbosity tradeoff that compounds inference costs in high-volume pipelines. + +[Sean Michael Kerner](https://venturebeat.com/author/sean-michael-kerner) +June 9, 2026 + + +[\\ +\\ +Credit: Image generated by VentureBeat using FLUX-2-Pro](https://venturebeat.com/technology/on-device-ai-agents-hit-a-hard-memory-limit-apples-new-architecture-routes-around-it) + +Apple's flash-routing architecture puts 20B parameters on-device without touching DRAM. For enterprises locked out of cloud inference, that's a significant new option. + +[Sean Michael Kerner](https://venturebeat.com/author/sean-michael-kerner) +June 9, 2026 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e80841..dab4a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,10 +16,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' @@ -107,7 +107,7 @@ jobs: exit $FAIL - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage path: coverage.out @@ -117,15 +117,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' - name: Run golangci-lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 with: version: v2.12.2 @@ -137,10 +137,10 @@ jobs: os: [ubuntu-latest, macos-latest] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' @@ -148,7 +148,7 @@ jobs: run: go build -o nxd ./cmd/nxd - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: nxd-${{ matrix.os }} path: nxd @@ -161,7 +161,7 @@ jobs: # scripts/check-leaks.sh when adding new forbidden tokens. steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Run leak check run: bash scripts/check-leaks.sh @@ -173,7 +173,7 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Build container image run: docker build -t nxd:ci . @@ -190,10 +190,10 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' @@ -215,10 +215,10 @@ jobs: # alone can't because they stub the bridge. steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.11' @@ -241,10 +241,10 @@ jobs: continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' @@ -261,17 +261,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.26.4' - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 with: version: '~> v2' args: release --clean diff --git a/.github/workflows/nxd-automate.yml b/.github/workflows/nxd-automate.yml index d7ced27..d3677ba 100644 --- a/.github/workflows/nxd-automate.yml +++ b/.github/workflows/nxd-automate.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version: '1.23' diff --git a/.github/workflows/traffic-stats.yml b/.github/workflows/traffic-stats.yml index 8c305ce..78ea5d2 100644 --- a/.github/workflows/traffic-stats.yml +++ b/.github/workflows/traffic-stats.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: main diff --git a/CLAUDE.md b/CLAUDE.md index a414bd9..9ea8b4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,15 @@ make mempalace-check # smoke the MemPalace bridge end-to-end - Native Windows: all read-only commands work (`status`, `dashboard`, `doctor`, `config`, `events`, `metrics`, `report`, `projects`). Full agent pipeline (`req`/`resume`) needs tmux → run inside WSL2. - Platform-specific code lives in `_unix.go` / `_windows.go` build-tagged pairs: `internal/cli/req_*.go` (daemon detach), `internal/engine/lockfile_*.go` (advisory lock + process liveness), `internal/devdb/docker/host_*.go` (docker default host). Shell command exec goes through `internal/shellexec` (`sh -c` on Unix, `cmd.exe /C` on Windows, override with `NXD_SHELL`). +## Current State (2026-07-02) — factory completeness: docs subsystem, completion gate, frontend skill + +- **Requirement-completion gate** (`engine/completion_gate.go` + `engine/verification_loop.go`): REQ_COMPLETED is only emitted after the composed mainline verifies green (deps install, build, tests, hallucination/conflict-marker scan). A red mainline gets up to `qa.completion_fix_cycles` (default 2) auto-fix agent cycles; still red → **REQ_BLOCKED** (requirement status `blocked`), gaps written to `.nxd-fix-gaps.md`, resume with `--godmode` after addressing. Nil LLM client ⇒ hard gate (verify once, block on red — completing on a red build is impossible regardless of wiring). Config: `qa.disable_completion_gate` (default false = ON), `qa.completion_fix_cycles` (0→2, negative→hard gate). Wired in `resume.go`; the local checkout is pulled to the composed mainline (`pullBaseAfterMerge`, `engine/monitor_pull.go`) before the gate verifies. +- **Docs subsystem** (`engine/doc_generator.go` + `svg_docs.go` + `factory_docs.go` + `factory_docs_adr.go`): after all stories merge, generates/updates README.md and backstops the full documentation set — `docs/architecture.svg` + `docs/sequence.svg` as REAL rendered SVG (validated, Mermaid rejected, up to 3 retry attempts feeding the validation error back), `docs/training.md` (only if the agent didn't supply one), `docs/adr/` Architecture Decision Records (JSON-validated, one file per decision + index), and a fully deterministic `docs/README.md` index. Best-effort — a model failure logs and skips, never blocking completion. Wired via `Monitor.SetDocGenerator` in resume.go. +- **Planner factory stories** (default ON, `planning.emit_integration_story` / `planning.emit_scribe_story`): every persisted plan appends (a) an integration story that wires all components into the real entry point, bridges interface mismatches with adapters, and adds a boot-the-app smoke test — closing the compose gap where unit tests pass against mocks but the whole never runs; (b) a scribe story owning README.md + docs/ that authors the documentation set up front (greenfield-aware: existing READMEs only edited inside `` markers). Ephemeral estimates skip both. Tests asserting exact story counts must set both flags false. +- **Frontend design skill** (`agent/frontend.go` + `engine/detect.go`): UI-facing stories (owned-file extensions + whole-word keyword regex) get `agent.FrontendDesignBrief` injected into their goal prompt — token-first two-pass design planning, one signature element, named banned AI-default looks, WCAG accessibility floor, copy-as-design-material. Threaded through the CLI-runtime, native-runtime, AND retry prompt paths (`TestExecutor_WiresFrontendDetection`). The planner requires the first UI story to establish a design-token foundation. +- **Security agent deltas**: `RunScanners` now returns a fourth `failed` list — a scanner that ran but errored (crash/timeout/parse) is logged and reported as coverage LOST, never counted as a clean run; `Report.Failed` renders in the markdown summary. `KnownScanners()` + `InstallHint()` expose the registry. Coverage: internal/security at 98.3% via a fake-scanner harness (shell scripts on a controlled PATH emitting canned tool output) driving RunScanners end-to-end. +- **CI supply-chain**: all GitHub Actions pinned to full commit SHAs with version comments. + ## Current State (2026-06-26) — security agent (ported from VXD) Self-upskilling security agent, mirrored from vortex-dispatch (offline-friendly: scanners are local binaries, LLM layer uses the configured Ollama/cloud client). @@ -251,6 +260,9 @@ Architectural ceilings (cannot reach 95% without major refactor): ## Event Types +Completion-gate event (added 2026-07-02): +- `REQ_BLOCKED` — the 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 `.nxd-fix-gaps.md`) + Security agent events (added 2026-06-26): - `STORY_SECURITY_PASSED` / `STORY_SECURITY_FAILED` — per-story security gate result; a FAILED gate pauses the requirement (human decision) rather than escalating - `SECURITY_SCAN_COMPLETED` — a standalone `nxd security scan` finished (findings count, max severity) diff --git a/internal/agent/frontend.go b/internal/agent/frontend.go new file mode 100644 index 0000000..6051326 --- /dev/null +++ b/internal/agent/frontend.go @@ -0,0 +1,67 @@ +package agent + +// FrontendDesignBrief is nxd's frontend design skill: the standards block +// injected into the goal prompt of every UI-facing story (PromptContext. +// IsFrontend, detected in the engine from owned files + story text). +// +// Synthesized from Anthropic's frontend-design skill and current guidance on +// avoiding generic AI-generated design ("AI slop"): token-first planning, one +// signature element, named anti-pattern looks, real copy, and a non-negotiable +// accessibility floor. Content lives in one const so the planner, the goal +// prompt, and tests share a single source of truth. Keep it under the size +// budget pinned by TestFrontendDesignBrief_SizeBudget — it rides on every +// UI-story dispatch. +const FrontendDesignBrief = ` +## FRONTEND DESIGN — MANDATORY STANDARDS + +You are also the design lead for this UI. The client rejects anything that +looks templated. Make deliberate, opinionated choices specific to THIS +product, its audience, and this page's single job. + +### Two-pass process (plan tokens BEFORE code) +1. Write a compact design-token plan first, as a comment block or DESIGN.md: + - Palette: 4-6 named hex values — one dominant color creating atmosphere, + one sharp accent. Not evenly-distributed timid pastels. + - Type: 2+ roles — a characterful display face used with restraint, a + complementary body face (never the same family you'd pick for any other + project), optional utility face for data. + - Layout: one-sentence concept. Structure must encode something true about + the content (numbered markers only if the content really is a sequence). + - Signature: the ONE element this page will be remembered by. Spend your + boldness there; keep everything around it quiet and disciplined. +2. Critique the plan before coding: if any part is what you would produce for + ANY similar brief, it is a default, not a choice — revise it. Then write + the code deriving every color and type decision from the plan. Encode the + tokens once (CSS custom properties or the Tailwind theme), never ad-hoc + per component. + +### Banned defaults (these read as AI-generated) +- Fonts: Inter, Roboto, Open Sans, Lato, Arial, bare system-ui as the design. +- Purple/blue-purple gradients on white; emerald or acid-green single accent + on near-black; warm cream #F4F1EA + serif display + terracotta accent; + broadsheet hairlines with zero border-radius EVERYWHERE. All four are + legitimate only if the brief explicitly asks for them. +- The template page: gradient hero → vague centered headline → three feature + cards with icons → testimonials → footer. Uniform 16px-radius cards. +- Scattered animations. One orchestrated moment (a page-load sequence or a + scroll reveal) beats effects everywhere; extra motion reads as generated. + +### Quality floor (non-negotiable, never announced in the UI) +- Responsive down to 360px wide; no horizontal scroll, no overlapping text. +- Visible keyboard focus on every interactive element (focus-visible ring). +- prefers-reduced-motion respected: gate every animation on it. +- WCAG AA contrast: 4.5:1 body text, 3:1 large text and UI components. +- Touch targets at least 44x44px. Semantic HTML (nav/main/button, alt text, + labels tied to inputs) — a div with onClick is not a button. +- Empty, loading, and error states designed, not defaulted. +- Watch CSS specificity: section-level and element-level spacing rules that + cancel each other are the classic generated-CSS failure. + +### Copy is design material +Write real copy for THIS product — never lorem ipsum or vague marketing +lines. Buttons say exactly what happens ("Save changes", never "Submit"); +the same action keeps the same name through the whole flow. Errors state +what went wrong and how to fix it, without apologizing. Name things by what +the user controls ("notifications"), not how the system is built ("webhook +config"). Active voice, sentence case, no filler. +` diff --git a/internal/agent/frontend_test.go b/internal/agent/frontend_test.go new file mode 100644 index 0000000..95a0d96 --- /dev/null +++ b/internal/agent/frontend_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "strings" + "testing" +) + +// The frontend design brief is injected into the goal prompt only for +// UI-facing stories (ctx.IsFrontend). It is the vxd factory's design skill: +// token-first planning, one signature element, the named anti-pattern looks, +// and a non-negotiable accessibility floor. +func TestGoalPrompt_FrontendBriefInjectedWhenFlagSet(t *testing.T) { + ctx := PromptContext{ + StoryID: "s-1", + StoryTitle: "Build the landing page", + StoryDescription: "Marketing page for the product", + AcceptanceCriteria: "- page renders", + IsFrontend: true, + } + got := GoalPrompt(RoleSenior, ctx) + + for _, want := range []string{ + "FRONTEND DESIGN", // section header + "Signature", // one memorable element + "token", // token-first plan before code + "prefers-reduced-motion", // quality floor + "focus", // visible keyboard focus + "Inter", // named anti-pattern font + "purple", // named anti-pattern palette + "#F4F1EA", // named second-generation cliché + "WCAG", // contrast floor + "Submit", // copy rule: never label a button Submit + } { + if !strings.Contains(got, want) { + t.Errorf("frontend brief missing %q", want) + } + } +} + +func TestGoalPrompt_FrontendBriefAbsentForBackendStories(t *testing.T) { + ctx := PromptContext{ + StoryID: "s-2", + StoryTitle: "Create REST API endpoints", + StoryDescription: "Express routes", + AcceptanceCriteria: "- routes tested", + IsFrontend: false, + } + got := GoalPrompt(RoleSenior, ctx) + if strings.Contains(got, "FRONTEND DESIGN") { + t.Error("backend story must not carry the frontend design brief") + } +} + +// Retry dispatches go through RenderGoalWithAttempts — the brief must survive +// the retry path too, or the second attempt regresses to default design. +func TestRenderGoalWithAttempts_CarriesFrontendBrief(t *testing.T) { + ctx := TemplateContext{ + StoryID: "s-1", + StoryTitle: "Build the landing page", + StoryDescription: "Marketing page", + AcceptanceCriteria: "- page renders", + IsFrontend: true, + IsRetry: true, + RetryNumber: 2, + ReviewFeedback: "colors are generic", + PriorAttempts: []AttemptSummary{{Number: 1, Role: "senior", Outcome: "review_failed"}}, + } + got := RenderGoalWithAttempts(ctx) + if !strings.Contains(got, "FRONTEND DESIGN") { + t.Error("retry path must carry the frontend design brief") + } +} + +// The brief itself must stay within a sane token budget — it rides on every +// UI story dispatch. ~6k chars ≈ 1.5k tokens is the ceiling. +func TestFrontendDesignBrief_SizeBudget(t *testing.T) { + if n := len(FrontendDesignBrief); n > 6000 { + t.Errorf("FrontendDesignBrief is %d chars — trim it below 6000 (prompt budget)", n) + } + if n := len(FrontendDesignBrief); n < 1500 { + t.Errorf("FrontendDesignBrief is %d chars — suspiciously small, did the content get lost?", n) + } +} diff --git a/internal/agent/prompts.go b/internal/agent/prompts.go index 7c420d3..220e413 100644 --- a/internal/agent/prompts.go +++ b/internal/agent/prompts.go @@ -48,6 +48,7 @@ type PromptContext struct { IsBugFix bool IsRefactor bool IsInfrastructure bool + IsFrontend bool // true when the story builds/changes a user-facing web UI InvestigationReport string // formatted markdown, injected by planner PriorWorkContext string // MemPalace search results WaveBrief string // parallel stories in this wave @@ -173,6 +174,9 @@ The previous implementation was rejected. Fix these issues: if ctx.IsInfrastructure { goal += "\n\nMANDATORY INFRASTRUCTURE WORKFLOW:\n1. Check services: docker ps -a, lsof for LISTEN\n2. Check logs: docker logs --tail 50, journalctl\n3. Check config: env vars, .env, docker-compose.yml\n4. Check resources: df -h, memory\n5. Fix and verify with health checks" } + if ctx.IsFrontend { + goal += "\n" + FrontendDesignBrief + } if ctx.PriorWorkContext != "" { goal += "\n\n" + SanitizePromptField(ctx.PriorWorkContext) diff --git a/internal/agent/render.go b/internal/agent/render.go index 4c1430e..79270ae 100644 --- a/internal/agent/render.go +++ b/internal/agent/render.go @@ -33,6 +33,7 @@ type TemplateContext struct { IsExistingCodebase bool IsBugFix bool IsInfrastructure bool + IsFrontend bool // true when the story builds/changes a user-facing web UI IsRetry bool // true if this is not the first attempt RetryNumber int // which attempt this is (1-indexed) } @@ -78,6 +79,7 @@ func RenderGoalWithAttempts(ctx TemplateContext) string { IsExistingCodebase: ctx.IsExistingCodebase, IsBugFix: ctx.IsBugFix, IsInfrastructure: ctx.IsInfrastructure, + IsFrontend: ctx.IsFrontend, } // Route to the appropriate role based on complexity. diff --git a/internal/cli/resume.go b/internal/cli/resume.go index 995ce21..f830617 100644 --- a/internal/cli/resume.go +++ b/internal/cli/resume.go @@ -494,6 +494,27 @@ func runResume(cmd *cobra.Command, args []string) error { log.Printf("[resume] security gate enabled (block at %s+, auto-learn=%v)", gateSev, s.Config.Security.AutoLearn) } + // Enable auto-documentation: when all stories merge, the monitor + // generates/updates README.md + docs/ (SVG diagrams, training guide, + // ADRs, index) with the implemented features. + if llmClient != nil { + monitor.SetDocGenerator(llmClient, s.Config.Models.Senior.Model) + } + + // Requirement-completion verification gate: verify the composed mainline + // (build + tests) before REQ_COMPLETED, with a bounded auto-fix loop. A + // nil client degrades to a hard gate (verify once, block on red) — the + // dangerous failure mode (completing on a red build) stays impossible. + // Skipped in dry-run and when qa.disable_completion_gate is set. + if !dryRun && !s.Config.QA.DisableCompletionGate { + senior := s.Config.Models.Senior + monitor.SetCompletionGate(engine.NewCompletionGate( + llmClient, senior.Model, senior.MaxTokens, + completionFixCycles(s.Config.QA.CompletionFixCycles), + s.Config.Merge.BaseBranch, s.Events, s.Proj, + )) + } + rc := &engine.RunContext{ ReqID: reqID, PlannedStories: plannedStories, @@ -536,6 +557,21 @@ func runResume(cmd *cobra.Command, args []string) error { return nil } +// completionFixCycles maps the configured qa.completion_fix_cycles to the +// gate's auto-fix budget: 0 (unset) means the default of 2 cycles, a negative +// value forces a hard gate (verify once, no auto-fix), anything else is used +// verbatim. +func completionFixCycles(configured int) int { + switch { + case configured == 0: + return 2 + case configured < 0: + return 0 + default: + return configured + } +} + // newDevDBProvider constructs a Provider from config, or nil for null/empty. func newDevDBProvider(cfg config.Config) (devdb.Provider, error) { switch cfg.DevDB.Provider { diff --git a/internal/config/config.go b/internal/config/config.go index fdcd4fb..2bde669 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,26 +24,26 @@ type MemoryConfig struct { // Forward-compatibility rules: // - Equal version → load normally. // - YAML missing version → log a one-time hint that operators should pin -// it; load normally with current defaults. +// it; load normally with current defaults. // - YAML major < current → log a migration suggestion + load with -// shimmed defaults; the binary still works. +// shimmed defaults; the binary still works. // - YAML major > current → fail loudly: the operator is running an -// older NXD build than their config expects. +// older NXD build than their config expects. const CurrentSchemaVersion = "1.0" type Config struct { // Version pins the nxd.yaml schema version. Should match // CurrentSchemaVersion at build time. See the constant's docs above. - Version string `yaml:"version"` - Workspace WorkspaceConfig `yaml:"workspace"` - Models ModelsConfig `yaml:"models"` - Routing RoutingConfig `yaml:"routing"` - Monitor MonitorConfig `yaml:"monitor"` - Cleanup CleanupConfig `yaml:"cleanup"` - Merge MergeConfig `yaml:"merge"` - Planning PlanningConfig `yaml:"planning"` - Billing BillingConfig `yaml:"billing"` - Controller ControllerConfig `yaml:"controller"` + Version string `yaml:"version"` + Workspace WorkspaceConfig `yaml:"workspace"` + Models ModelsConfig `yaml:"models"` + Routing RoutingConfig `yaml:"routing"` + Monitor MonitorConfig `yaml:"monitor"` + Cleanup CleanupConfig `yaml:"cleanup"` + Merge MergeConfig `yaml:"merge"` + Planning PlanningConfig `yaml:"planning"` + Billing BillingConfig `yaml:"billing"` + Controller ControllerConfig `yaml:"controller"` Memory MemoryConfig `yaml:"memory"` Investigation InvestigationConfig `yaml:"investigation"` QA QAConfig `yaml:"qa"` @@ -79,8 +79,8 @@ type MethodologyConfig struct { // Provider == "" or "null" disables the feature; agents do not get DBs. // NXD is offline-first and does not support the "ghost" cloud provider. type DevDBConfig struct { - Provider string `yaml:"provider"` // "docker" | "null" - Template string `yaml:"template"` // source DB name for forks + Provider string `yaml:"provider"` // "docker" | "null" + Template string `yaml:"template"` // source DB name for forks OnFailure DevDBFailurePolicy `yaml:"on_failure"` Docker DevDBDockerConfig `yaml:"docker"` } @@ -106,15 +106,27 @@ type PlanningConfig struct { SequentialFilePatterns []string `yaml:"sequential_file_patterns"` MaxStoryComplexity int `yaml:"max_story_complexity"` Godmode bool `yaml:"godmode"` + // EmitIntegrationStory, when true (default), makes the planner append a + // final integration story that depends on every code story, wires all + // components into the real entry point, bridges interface mismatches, and + // adds an end-to-end smoke test — closing the compose gap where unit + // tests pass against mocks but the whole app never runs. + EmitIntegrationStory bool `yaml:"emit_integration_story"` + // EmitScribeStory, when true (default), makes the planner append a final + // documentation story that depends on every other story, owns README.md + + // docs/, and authors the full documentation set (training guide, SVG + // diagrams, ADRs, docs index). Greenfield-aware: edits to a pre-existing + // README stay inside the nxd:scribe markers. + EmitScribeStory bool `yaml:"emit_scribe_story"` } // WorkspaceConfig holds workspace-level settings. type WorkspaceConfig struct { - StateDir string `yaml:"state_dir"` - Backend string `yaml:"backend"` + StateDir string `yaml:"state_dir"` + Backend string `yaml:"backend"` // LogLevel selects slog level filter: debug | info | warn | error. // Empty means info. Honored by internal/nlog.Setup. - LogLevel string `yaml:"log_level"` + LogLevel string `yaml:"log_level"` // LogFormat selects output: text (human, default) | json (machine / // log aggregators). Honored by internal/nlog.Setup. LogFormat string `yaml:"log_format,omitempty"` @@ -219,7 +231,7 @@ type BillingConfig struct { // LLMCostConfig tracks LLM API costs. type LLMCostConfig struct { - Mode string `yaml:"mode"` + Mode string `yaml:"mode"` Rates map[string]TokenRate `yaml:"rates,omitempty"` } @@ -232,6 +244,14 @@ type TokenRate struct { // QAConfig holds quality-assurance settings including declarative success criteria. type QAConfig struct { SuccessCriteria []SuccessCriterion `yaml:"success_criteria"` + // DisableCompletionGate turns off the requirement-completion verification + // gate (verify the composed mainline before REQ_COMPLETED). Default false + // = gate ON: a red mainline blocks completion instead of shipping silently. + DisableCompletionGate bool `yaml:"disable_completion_gate"` + // CompletionFixCycles is the number of auto-fix attempts against a red + // mainline before the requirement is blocked (REQ_BLOCKED). 0 means the + // default (2); a negative value forces a hard gate (verify once, no fixes). + CompletionFixCycles int `yaml:"completion_fix_cycles"` } // SecurityConfig controls the security agent: the per-story pre-merge security @@ -300,17 +320,17 @@ type RuntimeDetection struct { // RuntimeConfig describes an external AI coding runtime. type RuntimeConfig struct { - Command string `yaml:"command"` - Args []string `yaml:"args"` - Models []string `yaml:"models"` - Detection RuntimeDetection `yaml:"detection"` - Native bool `yaml:"native,omitempty"` - MaxIterations int `yaml:"max_iterations,omitempty"` - CommandAllowlist []string `yaml:"command_allowlist,omitempty"` - Concurrency int `yaml:"concurrency,omitempty"` - Runner string `yaml:"runner,omitempty"` - Docker DockerRunnerConfig `yaml:"docker,omitempty"` - SSH SSHRunnerConfig `yaml:"ssh,omitempty"` + Command string `yaml:"command"` + Args []string `yaml:"args"` + Models []string `yaml:"models"` + Detection RuntimeDetection `yaml:"detection"` + Native bool `yaml:"native,omitempty"` + MaxIterations int `yaml:"max_iterations,omitempty"` + CommandAllowlist []string `yaml:"command_allowlist,omitempty"` + Concurrency int `yaml:"concurrency,omitempty"` + Runner string `yaml:"runner,omitempty"` + Docker DockerRunnerConfig `yaml:"docker,omitempty"` + SSH SSHRunnerConfig `yaml:"ssh,omitempty"` } // DockerRunnerConfig holds settings for the Docker execution target. diff --git a/internal/config/loader.go b/internal/config/loader.go index 0428848..0b738d3 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -73,6 +73,8 @@ func DefaultConfig() Config { Planning: PlanningConfig{ SequentialFilePatterns: []string{"package.json", "*.config.*", "src/core/*"}, MaxStoryComplexity: 5, + EmitIntegrationStory: true, + EmitScribeStory: true, }, Billing: BillingConfig{ DefaultRate: 150.0, diff --git a/internal/engine/completion_gate.go b/internal/engine/completion_gate.go new file mode 100644 index 0000000..796b199 --- /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/nexus-dispatch/internal/llm" + "github.com/tzone85/nexus-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 .nxd-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, ".nxd-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..3dacc2c --- /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/nexus-dispatch/internal/llm" + "github.com/tzone85/nexus-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, ".nxd-fix-gaps.md")); err != nil { + t.Errorf("expected .nxd-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 TestReqBlockedEvent_ProjectsBlockedStatus(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) + } + + emitEventOrLog(es, ps, + state.NewEvent(state.EventReqBlocked, "monitor", "", map[string]any{"id": "REQ-G1"})) + + 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/detect.go b/internal/engine/detect.go new file mode 100644 index 0000000..e5a9832 --- /dev/null +++ b/internal/engine/detect.go @@ -0,0 +1,45 @@ +package engine + +import ( + "path/filepath" + "regexp" + "strings" +) + +// frontendFileExts are file extensions that mark a story as UI-facing when they +// appear in its owned files. +var frontendFileExts = map[string]bool{ + ".tsx": true, ".jsx": true, ".vue": true, ".svelte": true, + ".css": true, ".scss": true, ".sass": true, ".less": true, + ".html": true, ".astro": true, +} + +// detectFrontend checks if the story builds or changes a user-facing web UI. +// This triggers the FrontendDesignBrief injection (agent.FrontendDesignBrief) +// so agents produce distinctive, accessible frontends instead of generic +// AI-default design. Detection combines owned-file extensions (strongest +// signal) with title/description keywords. +func detectFrontend(title, description string, ownedFiles []string) bool { + for _, f := range ownedFiles { + if frontendFileExts[strings.ToLower(filepath.Ext(f))] { + return true + } + } + return frontendKeywordRe.MatchString(strings.ToLower(title + " " + description)) +} + +// frontendKeywordRe matches UI vocabulary as whole words only — plain substring +// matching trips on "pagination" (page), "performance" (form), "review" (view). +// Deliberately absent: "html" (server-side HTML emails/reports are backend +// work; real UI stories carry .html in owned files or another keyword) and +// "responsive" ("responsive API gateway" means fast, not responsive design). +// Hoisted to package level so detection never recompiles it. +var frontendKeywordRe = regexp.MustCompile(`\b(` + + `frontend|front-end|ui|ux|user interface|` + + `landing page|page|screen|view|component|widget|` + + `dashboard|layout|styling|stylesheet|css|` + + `tailwind|react|vue|svelte|next\.js|nextjs|astro|` + + `design system|web app|webapp|website|` + + `form|modal|navbar|navigation bar|sidebar|button|` + + `theme|dark mode|typography` + + `)\b`) diff --git a/internal/engine/detect_test.go b/internal/engine/detect_test.go new file mode 100644 index 0000000..b528d9a --- /dev/null +++ b/internal/engine/detect_test.go @@ -0,0 +1,48 @@ +package engine + +import "testing" + +func TestDetectFrontend(t *testing.T) { + tests := []struct { + name string + title, desc string + ownedFiles []string + want bool + }{ + {"ui-keyword-title", "Build the landing page", "", nil, true}, + {"component-keyword", "Create reusable Button component", "", nil, true}, + {"dashboard-keyword", "Admin dashboard with charts", "", nil, true}, + {"frontend-in-desc", "", "Implement the frontend for task management", nil, true}, + {"tailwind-keyword", "Style the app", "Use Tailwind for the layout", nil, true}, + {"react-keyword", "Task list view", "React component rendering tasks", nil, true}, + {"owned-tsx", "Wire task state", "", []string{"src/App.tsx"}, true}, + {"owned-css", "Polish spacing", "", []string{"styles/main.css"}, true}, + {"owned-vue", "Item editor", "", []string{"src/Editor.vue"}, true}, + {"owned-svelte", "Item editor", "", []string{"src/Editor.svelte"}, true}, + {"owned-html", "Static page", "", []string{"public/index.html"}, true}, + {"backend-only", "Create REST API endpoints", "Express routes for tasks", nil, false}, + {"db-story", "Add database migrations", "Postgres schema for users", nil, false}, + {"go-files-only", "Implement parser", "", []string{"internal/parser/parser.go"}, false}, + {"cli-story", "Add --json flag to CLI", "", []string{"cmd/root.go"}, false}, + {"ssr", "Server-side rendering of the settings page", "", nil, true}, + // Substring traps: keywords must match whole words only. + {"pagination-is-not-page", "Add pagination to the tasks API", "cursor-based pagination in the repository layer", nil, false}, + {"performance-is-not-form", "Improve performance of the query planner", "", nil, false}, + {"review-is-not-view", "Code review automation for PRs", "LLM review of diffs", nil, false}, + {"format-is-not-form", "Format output as JSON", "", nil, false}, + {"build-is-not-ui", "Build the release pipeline", "artifact signing", nil, false}, + // Server-side HTML and "responsive" infrastructure are NOT UI work. + {"html-email-is-backend", "Generate HTML email report", "render the weekly digest as text/html", nil, false}, + {"responsive-gateway-is-backend", "Design a responsive API gateway", "low-latency request routing", nil, false}, + {"html-with-owned-file", "Static marketing site", "", []string{"public/index.html"}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectFrontend(tt.title, tt.desc, tt.ownedFiles) + if got != tt.want { + t.Errorf("detectFrontend(%q, %q, %v) = %v, want %v", tt.title, tt.desc, tt.ownedFiles, got, tt.want) + } + }) + } +} diff --git a/internal/engine/doc_generator.go b/internal/engine/doc_generator.go new file mode 100644 index 0000000..b627738 --- /dev/null +++ b/internal/engine/doc_generator.go @@ -0,0 +1,224 @@ +package engine + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +const nxdFooter = ` + +--- + +
+ Built with NXD (Nexus Dispatch) — offline-first autonomous AI software delivery +
+` + +// generateDocumentation creates or updates the README.md in the target repo +// after all stories are merged. Uses the LLM to generate documentation based +// on the actual codebase, requirement, and completed stories. +func generateDocumentation(ctx context.Context, repoDir string, reqTitle string, stories []string, client llm.Client, model string) { + log.Printf("[docs] generating documentation for %s", filepath.Base(repoDir)) + + readmePath := filepath.Join(repoDir, "README.md") + existingReadme, _ := os.ReadFile(readmePath) + hasReadme := len(existingReadme) > 0 + + // Get the file tree for context + fileTree := captureFileTree(repoDir) + + // Get package.json or go.mod for project info + projectInfo := "" + for _, name := range []string{"package.json", "go.mod", "pyproject.toml", "Cargo.toml"} { + data, err := os.ReadFile(filepath.Join(repoDir, name)) + if err == nil { + projectInfo = string(data) + if len(projectInfo) > 2000 { + projectInfo = projectInfo[:2000] + } + break + } + } + + var prompt string + if hasReadme { + prompt = fmt.Sprintf(`You are updating the README.md for a software project. + +EXISTING README: +%s + +REQUIREMENT THAT WAS JUST IMPLEMENTED: +%s + +STORIES COMPLETED: +%s + +FILE TREE: +%s + +Instructions: +1. Keep the existing README structure and content +2. Add a new section documenting the features that were just implemented +3. Update any outdated information (version, features list, etc.) +4. Do NOT remove existing content — only add and update +5. Output ONLY the complete updated README.md content, no commentary +6. Do NOT wrap in markdown code fences`, + truncateForPrompt(string(existingReadme), 4000), + reqTitle, + strings.Join(stories, "\n"), + truncateForPrompt(fileTree, 2000), + ) + } else { + prompt = fmt.Sprintf(`Create a professional README.md for this software project. + +PROJECT INFO: +%s + +REQUIREMENT: +%s + +FEATURES IMPLEMENTED: +%s + +FILE TREE: +%s + +Instructions: +1. Create a complete README with: title, description, features, installation, usage, tech stack, contributing, license +2. Use the file tree and project info to determine the tech stack accurately +3. Make it professional and inviting for open source contributors +4. Output ONLY the README.md content, no commentary +5. Do NOT wrap in markdown code fences`, + truncateForPrompt(projectInfo, 2000), + reqTitle, + strings.Join(stories, "\n"), + truncateForPrompt(fileTree, 2000), + ) + } + + // Call LLM to generate/update the README + resp, err := client.Complete(ctx, llm.CompletionRequest{ + Model: model, + Messages: []llm.Message{ + {Role: "user", Content: prompt}, + }, + MaxTokens: 4000, + }) + if err != nil { + log.Printf("[docs] LLM error generating README: %v", err) + return + } + + content := strings.TrimSpace(resp.Content) + if content == "" { + log.Printf("[docs] LLM returned empty README content, skipping") + return + } + + // Strip markdown fences if the LLM wrapped it anyway + content = stripMarkdownFences(content) + + // Factory rule: ship rendered SVG architecture + sequence diagrams (never + // Mermaid). Generate/repair them deterministically and link from the README. + diagrams := generateProjectDiagrams(ctx, repoDir, reqTitle, fileTree, projectInfo, client, model) + content = ensureReadmeReferencesDiagrams(content, diagrams) + + // Append NXD footer if not already present + if !strings.Contains(content, "Built by the") && !strings.Contains(content, "NXD Team") { + content += nxdFooter + } + + // Write the README + if err := os.WriteFile(readmePath, []byte(content), 0644); err != nil { + log.Printf("[docs] failed to write README: %v", err) + return + } + + // Factory documentation standard: fill any remaining shortfall — a + // getting-started training guide and Architecture Decision Records when the + // agent did not supply them, then a deterministic docs/ index over the lot. + ensureFactoryDocs(ctx, repoDir, reqTitle, fileTree, projectInfo, client, model) + + // Commit the documentation update (README + generated docs/ diagrams, ADRs, + // training guide, and index) + commitDocumentation(repoDir) + + action := "created" + if hasReadme { + action = "updated" + } + log.Printf("[docs] README.md %s for %s", action, filepath.Base(repoDir)) +} + +// commitDocumentation stages and commits the README plus generated docs/ +// (architecture.svg, sequence.svg, training.md). +func commitDocumentation(repoDir string) { + addArgs := []string{"add", "README.md"} + if st, err := os.Stat(filepath.Join(repoDir, "docs")); err == nil && st.IsDir() { + addArgs = append(addArgs, "docs") + } + addCmd := exec.Command("git", addArgs...) + addCmd.Dir = repoDir + if err := addCmd.Run(); err != nil { + log.Printf("[docs] git add %v failed: %v", addArgs, err) + return + } + + msg := fmt.Sprintf("docs: update README and SVG diagrams for implemented features\n\nAuto-generated by NXD on %s", time.Now().Format("2006-01-02")) + commitCmd := exec.Command("git", "commit", "-m", msg) + commitCmd.Dir = repoDir + // `git commit` returns non-zero for BOTH "nothing to commit" AND real + // failures (hook rejection, locked index, GPG signing failure). The + // old code blindly ignored both. Distinguish: check the porcelain + // status first; if nothing is staged, return silently. Otherwise the + // commit failure is real and must be logged so operators see why + // the README never made it to the remote. + staged, _ := exec.Command("git", "diff", "--cached", "--quiet").CombinedOutput() + _ = staged + stagedCmd := exec.Command("git", "diff", "--cached", "--name-only") + stagedCmd.Dir = repoDir + stagedOut, _ := stagedCmd.Output() + if strings.TrimSpace(string(stagedOut)) == "" { + // Nothing to commit — README was unchanged. Silent return is fine. + return + } + if out, err := commitCmd.CombinedOutput(); err != nil { + log.Printf("[docs] commit README failed: %v — %s", err, strings.TrimSpace(string(out))) + } +} + +// stripMarkdownFences removes ```markdown ... ``` wrapping from LLM output. +func stripMarkdownFences(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```markdown") { + s = strings.TrimPrefix(s, "```markdown") + s = strings.TrimSpace(s) + } else if strings.HasPrefix(s, "```md") { + s = strings.TrimPrefix(s, "```md") + s = strings.TrimSpace(s) + } else if strings.HasPrefix(s, "```") { + s = strings.TrimPrefix(s, "```") + s = strings.TrimSpace(s) + } + if strings.HasSuffix(s, "```") { + s = strings.TrimSuffix(s, "```") + s = strings.TrimSpace(s) + } + return s +} + +// truncateForPrompt limits text length for LLM prompts. +func truncateForPrompt(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "\n...(truncated)" +} diff --git a/internal/engine/doc_generator_test.go b/internal/engine/doc_generator_test.go new file mode 100644 index 0000000..4fa01c3 --- /dev/null +++ b/internal/engine/doc_generator_test.go @@ -0,0 +1,94 @@ +package engine + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +func gitInit(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "t@t.t"}, + {"config", "user.name", "t"}, + } { + c := exec.Command("git", args...) + c.Dir = dir + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v — %s", args, err, out) + } + } +} + +// generateDocumentation must, after writing the README, deterministically +// produce VALID SVG diagrams and link them — the factory documentation loop end +// to end. This is the wiring guard: if the diagram call is ever dropped from +// generateDocumentation, this fails. +func TestGenerateDocumentation_ProducesSVGDiagrams(t *testing.T) { + dir := t.TempDir() + gitInit(t, dir) + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module demo\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + + // The post-merge doc loop makes, in order: README, architecture.svg, + // sequence.svg, training.md, then the ADR list. The docs index is + // deterministic (no LLM call). + client := llm.NewReplayClient( + llm.CompletionResponse{Content: "# Demo\n\nA demo project."}, + llm.CompletionResponse{Content: validArchSVG}, + llm.CompletionResponse{Content: validArchSVG}, + llm.CompletionResponse{Content: "# Getting Started\n\nRun `go build` to compile, then `./demo` to run it. Expected output: a single result line confirming success."}, + llm.CompletionResponse{Content: `[{"title":"Layered design","context":"separation","decision":"domain/infra split","consequences":"testable"}]`}, + ) + + generateDocumentation(context.Background(), dir, "Build a demo", []string{"s-001: thing"}, client, "m") + + for _, rel := range []string{"docs/architecture.svg", "docs/sequence.svg"} { + data, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Fatalf("expected %s to exist: %v", rel, err) + } + if err := validateSVG(string(data)); err != nil { + t.Fatalf("%s not valid SVG: %v", rel, err) + } + } + + readme, err := os.ReadFile(filepath.Join(dir, "README.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(readme), "docs/architecture.svg") { + t.Fatal("README does not reference the architecture diagram") + } + + // The full factory documentation set must exist: training guide, at least + // one ADR + its index, and the docs index. + for _, rel := range []string{"docs/training.md", "docs/adr/README.md", "docs/README.md"} { + if _, err := os.Stat(filepath.Join(dir, rel)); err != nil { + t.Errorf("expected %s to be generated: %v", rel, err) + } + } + adrs, _ := filepath.Glob(filepath.Join(dir, "docs/adr/0*.md")) + if len(adrs) == 0 { + t.Error("expected at least one numbered ADR file") + } + idx, _ := os.ReadFile(filepath.Join(dir, "docs/README.md")) + if !strings.Contains(string(idx), "training.md") || !strings.Contains(string(idx), "adr/README.md") { + t.Errorf("docs index does not link the generated docs:\n%s", idx) + } + + // Everything must be committed, not left dirty. + st := exec.Command("git", "status", "--porcelain") + st.Dir = dir + out, _ := st.Output() + if strings.Contains(string(out), "docs/") { + t.Fatalf("docs left uncommitted: %s", out) + } +} diff --git a/internal/engine/executor.go b/internal/engine/executor.go index 60256dc..de63242 100644 --- a/internal/engine/executor.go +++ b/internal/engine/executor.go @@ -268,6 +268,7 @@ func (e *Executor) spawn(ctx context.Context, repoDir string, a Assignment, stor LintCommand: lintCmd, BuildCommand: buildCmd, TestCommand: testCmd, + IsFrontend: detectFrontend(story.Title, story.Description, story.OwnedFiles), } // Query MemPalace for prior work context. @@ -321,6 +322,7 @@ func (e *Executor) spawn(ctx context.Context, repoDir string, a Assignment, stor IsExistingCodebase: promptCtx.IsExistingCodebase, IsBugFix: promptCtx.IsBugFix, IsInfrastructure: promptCtx.IsInfrastructure, + IsFrontend: promptCtx.IsFrontend, IsRetry: true, RetryNumber: len(attempts) + 1, PriorAttempts: priorAttempts, @@ -520,6 +522,7 @@ func (e *Executor) spawnNative(ctx context.Context, repoDir string, a Assignment RepoPath: worktreePath, Complexity: story.Complexity, ReviewFeedback: e.latestReviewFeedback(a.StoryID), + IsFrontend: detectFrontend(story.Title, story.Description, story.OwnedFiles), } if e.mempalace != nil && e.mempalace.IsAvailable() { diff --git a/internal/engine/factory_docs.go b/internal/engine/factory_docs.go new file mode 100644 index 0000000..5db9a47 --- /dev/null +++ b/internal/engine/factory_docs.go @@ -0,0 +1,177 @@ +package engine + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +// Factory documentation standard. A completed requirement ships a full, +// consistent documentation set: +// +// - README.md — the entry point +// - docs/architecture.svg — rendered SVG (handled by svg_docs.go) +// - docs/sequence.svg — rendered SVG (handled by svg_docs.go) +// - docs/training.md — a getting-started tutorial +// - docs/adr/0001-*.md … — Architecture Decision Records +// - docs/adr/README.md — ADR index +// - docs/README.md — documentation index +// +// The scribe story instructs the coding agent to produce all of this, but agents +// are inconsistent. These post-merge backstops make the standard hold by +// construction: the docs index is generated deterministically from whatever +// docs/ contains, and the training guide + ADRs are generated by the doc model +// only when the agent did not already supply them. Every backstop is +// best-effort and never blocks requirement completion. + +// ensureFactoryDocs runs all comprehensive-documentation backstops. SVG diagrams +// and the README are handled separately (earlier) in generateDocumentation; this +// fills in training, ADRs, and the docs index. +func ensureFactoryDocs(ctx context.Context, repoDir, reqTitle, fileTree, projectInfo string, client llm.Client, model string) { + ensureTrainingGuide(ctx, repoDir, reqTitle, fileTree, projectInfo, client, model) + ensureADRs(ctx, repoDir, reqTitle, fileTree, projectInfo, client, model) + // The index is generated last so it reflects every doc the others produced. + ensureDocsIndex(repoDir) +} + +// --- Deterministic docs index ------------------------------------------------ + +// ensureDocsIndex (re)generates docs/README.md as an index of everything in +// docs/. Pure-deterministic, no LLM — so the index always exists and is current. +func ensureDocsIndex(repoDir string) { + docsDir := filepath.Join(repoDir, "docs") + entries, err := os.ReadDir(docsDir) + if err != nil { + return // no docs/ directory — nothing to index + } + + var diagrams, guides []string + hasADRs := false + for _, e := range entries { + name := e.Name() + if e.IsDir() { + if name == "adr" { + hasADRs = true + } + continue + } + if name == "README.md" { + continue // the index itself + } + switch { + case strings.HasSuffix(name, ".svg"): + diagrams = append(diagrams, name) + case strings.HasSuffix(name, ".md"): + guides = append(guides, name) + } + } + + index := buildDocsIndex(diagrams, guides, hasADRs) + if err := os.WriteFile(filepath.Join(docsDir, "README.md"), []byte(index), 0o644); err != nil { + log.Printf("[docs] write docs/README.md index failed: %v", err) + } +} + +// buildDocsIndex renders the docs index markdown. Pure function — unit-pinned. +func buildDocsIndex(diagrams, guides []string, hasADRs bool) string { + sort.Strings(diagrams) + sort.Strings(guides) + + var b strings.Builder + b.WriteString("# Documentation\n\n") + b.WriteString("Documentation index. Start with the [project README](../README.md).\n") + + if len(guides) > 0 { + b.WriteString("\n## Guides\n\n") + for _, g := range guides { + fmt.Fprintf(&b, "- [%s](%s)\n", humanizeDocName(g), g) + } + } + if len(diagrams) > 0 { + b.WriteString("\n## Diagrams\n\n") + for _, d := range diagrams { + fmt.Fprintf(&b, "- [%s](%s)\n", humanizeDocName(d), d) + } + } + if hasADRs { + b.WriteString("\n## Architecture Decision Records\n\n") + b.WriteString("- [ADR index](adr/README.md)\n") + } + return b.String() +} + +// humanizeDocName turns "training.md" / "architecture.svg" into "Training" / +// "Architecture" for index link text. +func humanizeDocName(name string) string { + base := strings.TrimSuffix(strings.TrimSuffix(name, ".md"), ".svg") + base = strings.NewReplacer("-", " ", "_", " ").Replace(base) + fields := strings.Fields(base) + for i, f := range fields { + if f == "" { + continue + } + fields[i] = strings.ToUpper(f[:1]) + f[1:] + } + return strings.Join(fields, " ") +} + +// --- Training guide backstop (LLM) ------------------------------------------- + +func ensureTrainingGuide(ctx context.Context, repoDir, reqTitle, fileTree, projectInfo string, client llm.Client, model string) { + trainingPath := filepath.Join(repoDir, "docs", "training.md") + if fileExistsNonEmpty(trainingPath) { + return // agent already wrote it + } + + prompt := fmt.Sprintf(`Write a "Getting Started" tutorial (docs/training.md) for this software project, accurate to the code. + +PROJECT: %s + +MANIFEST (truncated): +%s + +FILE TREE (truncated): +%s + +Requirements: +- A hands-on walkthrough that takes a new user from install/setup to a working result. +- Copy-pasteable commands and the expected output shape, grounded in what the project actually does (real commands/endpoints/flags — do not invent). +- Markdown only. Output ONLY the file content, no commentary, no code fences around the whole document.`, + reqTitle, truncateForPrompt(projectInfo, 1500), truncateForPrompt(fileTree, 1800)) + + resp, err := client.Complete(ctx, llm.CompletionRequest{ + Model: model, + Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, + MaxTokens: 4000, + }) + if err != nil { + log.Printf("[docs] training guide generation failed: %v", err) + return + } + content := stripMarkdownFences(strings.TrimSpace(resp.Content)) + if len(content) < 80 { + log.Printf("[docs] training guide too short (%d bytes), skipping", len(content)) + return + } + if err := os.MkdirAll(filepath.Dir(trainingPath), 0o755); err != nil { + log.Printf("[docs] mkdir docs for training failed: %v", err) + return + } + if err := os.WriteFile(trainingPath, []byte(content+"\n"), 0o644); err != nil { + log.Printf("[docs] write training.md failed: %v", err) + return + } + log.Printf("[docs] generated docs/training.md (%d bytes)", len(content)) +} + +// fileExistsNonEmpty reports whether path exists and has non-whitespace content. +func fileExistsNonEmpty(path string) bool { + data, err := os.ReadFile(path) + return err == nil && len(strings.TrimSpace(string(data))) > 0 +} diff --git a/internal/engine/factory_docs_adr.go b/internal/engine/factory_docs_adr.go new file mode 100644 index 0000000..4ccd864 --- /dev/null +++ b/internal/engine/factory_docs_adr.go @@ -0,0 +1,180 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +// adrRecord is one Architecture Decision Record as returned by the doc model. +type adrRecord struct { + Title string `json:"title"` + Context string `json:"context"` + Decision string `json:"decision"` + Consequences string `json:"consequences"` +} + +// ensureADRs generates docs/adr/ Architecture Decision Records (and their index) +// when the coding agent did not already supply them. Best-effort: any failure or +// empty/invalid model output logs and returns without writing partial files. +func ensureADRs(ctx context.Context, repoDir, reqTitle, fileTree, projectInfo string, client llm.Client, model string) { + adrDir := filepath.Join(repoDir, "docs", "adr") + if hasADRFiles(adrDir) { + return // agent already wrote ADRs + } + + prompt := fmt.Sprintf(`Identify the significant, hard-to-reverse ARCHITECTURE DECISIONS in this software project and record them as ADRs, grounded in the actual code (cite real package/module/type names, never invent). + +PROJECT: %s + +MANIFEST (truncated): +%s + +FILE TREE (truncated): +%s + +Return ONLY a JSON array (no prose, no code fence) of 3 to 6 objects, each: +{"title": "...", "context": "why this decision was needed", "decision": "what was decided", "consequences": "trade-offs and effects"} + +Each must be a REAL decision evident in the structure/stack/patterns (e.g. persistence choice, layering, offline-vs-network, auth model, a key algorithm, an error-handling contract). Title is a short noun phrase. No markdown inside the strings beyond plain sentences.`, + reqTitle, truncateForPrompt(projectInfo, 1500), truncateForPrompt(fileTree, 1800)) + + resp, err := client.Complete(ctx, llm.CompletionRequest{ + Model: model, + Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, + MaxTokens: 4000, + }) + if err != nil { + log.Printf("[docs] ADR generation failed: %v", err) + return + } + + adrs := parseADRs(resp.Content) + if len(adrs) == 0 { + log.Printf("[docs] ADR generation produced no usable records, skipping") + return + } + + if err := os.MkdirAll(adrDir, 0o755); err != nil { + log.Printf("[docs] mkdir docs/adr failed: %v", err) + return + } + for i, a := range adrs { + num := i + 1 + filename := fmt.Sprintf("%04d-%s.md", num, slugifyADR(a.Title)) + if err := os.WriteFile(filepath.Join(adrDir, filename), []byte(renderADR(num, a)), 0o644); err != nil { + log.Printf("[docs] write %s failed: %v", filename, err) + } + } + if err := os.WriteFile(filepath.Join(adrDir, "README.md"), []byte(renderADRIndex(adrs)), 0o644); err != nil { + log.Printf("[docs] write docs/adr/README.md failed: %v", err) + } + log.Printf("[docs] generated %d ADR(s) in docs/adr/", len(adrs)) +} + +// parseADRs extracts and validates the ADR array from a model response. Records +// missing a title or decision are dropped (an ADR with neither is not useful). +func parseADRs(raw string) []adrRecord { + jsonStr := extractJSON(raw) + if jsonStr == "" { + return nil + } + var records []adrRecord + if err := json.Unmarshal([]byte(jsonStr), &records); err != nil { + return nil + } + var valid []adrRecord + for _, r := range records { + r.Title = strings.TrimSpace(r.Title) + r.Decision = strings.TrimSpace(r.Decision) + if r.Title == "" || r.Decision == "" { + continue + } + valid = append(valid, r) + } + return valid +} + +// renderADR renders one ADR as a Markdown file following the standard sections. +func renderADR(num int, a adrRecord) string { + field := func(s, fallback string) string { + if strings.TrimSpace(s) == "" { + return fallback + } + return strings.TrimSpace(s) + } + return fmt.Sprintf(`# %d. %s + +- Status: Accepted + +## Context + +%s + +## Decision + +%s + +## Consequences + +%s +`, + num, a.Title, + field(a.Context, "_Not recorded._"), + field(a.Decision, "_Not recorded._"), + field(a.Consequences, "_Not recorded._")) +} + +// renderADRIndex renders the docs/adr/README.md index table. +func renderADRIndex(adrs []adrRecord) string { + var b strings.Builder + b.WriteString("# Architecture Decision Records\n\n") + b.WriteString("Significant, hard-to-reverse decisions for this project.\n\n") + b.WriteString("| ADR | Decision |\n|-----|----------|\n") + for i, a := range adrs { + num := i + 1 + link := fmt.Sprintf("%04d-%s.md", num, slugifyADR(a.Title)) + fmt.Fprintf(&b, "| [%04d](%s) | %s |\n", num, link, a.Title) + } + return b.String() +} + +// hasADRFiles reports whether docs/adr/ already contains at least one numbered +// ADR file (e.g. 0001-*.md), meaning the agent supplied them. +func hasADRFiles(adrDir string) bool { + entries, err := os.ReadDir(adrDir) + if err != nil { + return false + } + for _, e := range entries { + if !e.IsDir() && adrFilePattern.MatchString(e.Name()) { + return true + } + } + return false +} + +var adrFilePattern = regexp.MustCompile(`^\d{3,4}-.*\.md$`) + +// slugifyADR turns an ADR title into a filename-safe slug. +func slugifyADR(title string) string { + s := strings.ToLower(strings.TrimSpace(title)) + s = nonSlugChars.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if len(s) > 60 { + s = strings.Trim(s[:60], "-") + } + if s == "" { + return "decision" + } + return s +} + +var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) diff --git a/internal/engine/factory_docs_test.go b/internal/engine/factory_docs_test.go new file mode 100644 index 0000000..9102f0c --- /dev/null +++ b/internal/engine/factory_docs_test.go @@ -0,0 +1,201 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +func TestBuildDocsIndex(t *testing.T) { + idx := buildDocsIndex( + []string{"sequence.svg", "architecture.svg"}, // unsorted on purpose + []string{"training.md", "connectors.md"}, + true, + ) + for _, want := range []string{ + "# Documentation", + "../README.md", + "[Architecture](architecture.svg)", // sorted + humanized + "[Sequence](sequence.svg)", + "[Training](training.md)", + "[Connectors](connectors.md)", + "Architecture Decision Records", + "adr/README.md", + } { + if !strings.Contains(idx, want) { + t.Errorf("docs index missing %q\n---\n%s", want, idx) + } + } + // No ADRs → no ADR section. + if strings.Contains(buildDocsIndex(nil, []string{"x.md"}, false), "Architecture Decision Records") { + t.Error("ADR section should be absent when hasADRs is false") + } +} + +func TestEnsureDocsIndex_WritesFromDocsDir(t *testing.T) { + dir := t.TempDir() + docs := filepath.Join(dir, "docs") + if err := os.MkdirAll(filepath.Join(docs, "adr"), 0o755); err != nil { + t.Fatal(err) + } + write := func(rel, body string) { + if err := os.WriteFile(filepath.Join(docs, rel), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write("architecture.svg", "") + write("training.md", "# Training") + write("adr/0001-x.md", "# 1. X") + + ensureDocsIndex(dir) + + got, err := os.ReadFile(filepath.Join(docs, "README.md")) + if err != nil { + t.Fatalf("docs/README.md not written: %v", err) + } + s := string(got) + if !strings.Contains(s, "architecture.svg") || !strings.Contains(s, "training.md") || !strings.Contains(s, "adr/README.md") { + t.Errorf("index missing entries:\n%s", s) + } +} + +func TestEnsureDocsIndex_NoDocsDirIsNoop(t *testing.T) { + dir := t.TempDir() + ensureDocsIndex(dir) // must not panic or create anything + if _, err := os.Stat(filepath.Join(dir, "docs")); !os.IsNotExist(err) { + t.Error("ensureDocsIndex should not create docs/ when absent") + } +} + +func TestHumanizeDocName(t *testing.T) { + cases := map[string]string{ + "training.md": "Training", + "architecture.svg": "Architecture", + "getting-started.md": "Getting Started", + "data_model.md": "Data Model", + } + for in, want := range cases { + if got := humanizeDocName(in); got != want { + t.Errorf("humanizeDocName(%q) = %q, want %q", in, got, want) + } + } +} + +// --- ADRs -------------------------------------------------------------------- + +func TestParseADRs(t *testing.T) { + raw := `Here are the ADRs: +[ + {"title": "Pure-Go SQLite", "context": "no cgo", "decision": "use modernc", "consequences": "easy builds"}, + {"title": "", "decision": "dropped — no title"}, + {"title": "No decision", "context": "x"}, + {"title": "Offline first", "context": "trust", "decision": "no network", "consequences": "portable"} +]` + adrs := parseADRs(raw) + if len(adrs) != 2 { + t.Fatalf("expected 2 valid ADRs (title+decision required), got %d", len(adrs)) + } + if adrs[0].Title != "Pure-Go SQLite" || adrs[1].Title != "Offline first" { + t.Errorf("unexpected ADRs: %+v", adrs) + } +} + +func TestRenderADR_HasStandardSections(t *testing.T) { + md := renderADR(2, adrRecord{Title: "Event sourcing", Context: "audit", Decision: "append-only log", Consequences: "replayable"}) + for _, want := range []string{"# 2. Event sourcing", "Status: Accepted", "## Context", "audit", "## Decision", "append-only log", "## Consequences", "replayable"} { + if !strings.Contains(md, want) { + t.Errorf("ADR markdown missing %q\n%s", want, md) + } + } +} + +func TestSlugifyADR(t *testing.T) { + cases := map[string]string{ + "Pure-Go SQLite (no cgo)": "pure-go-sqlite-no-cgo", + " Offline First! ": "offline-first", + "": "decision", + } + for in, want := range cases { + if got := slugifyADR(in); got != want { + t.Errorf("slugifyADR(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEnsureADRs_SkipsWhenAgentSupplied(t *testing.T) { + dir := t.TempDir() + adrDir := filepath.Join(dir, "docs", "adr") + if err := os.MkdirAll(adrDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(adrDir, "0001-existing.md"), []byte("# 1. Existing"), 0o644); err != nil { + t.Fatal(err) + } + // Client with NO responses — if ensureADRs tried to call it, it would error; + // the skip means it never does. + client := llm.NewReplayClient() + ensureADRs(context.Background(), dir, "proj", "tree", "{}", client, "m") + if client.CallCount() != 0 { + t.Errorf("ensureADRs should not call the model when ADRs already exist (calls=%d)", client.CallCount()) + } +} + +func TestEnsureADRs_GeneratesWhenMissing(t *testing.T) { + dir := t.TempDir() + resp := llm.CompletionResponse{Content: `[ + {"title": "Layered architecture", "context": "separation", "decision": "domain/infra split", "consequences": "testable"}, + {"title": "Offline first", "context": "trust", "decision": "no network", "consequences": "portable"} +]`} + client := llm.NewReplayClient(resp) + ensureADRs(context.Background(), dir, "proj", "cmd/\ninternal/", "module x", client, "m") + + files, _ := os.ReadDir(filepath.Join(dir, "docs", "adr")) + var adrCount int + hasIndex := false + for _, f := range files { + if f.Name() == "README.md" { + hasIndex = true + } else if strings.HasSuffix(f.Name(), ".md") { + adrCount++ + } + } + if adrCount != 2 { + t.Errorf("expected 2 ADR files, got %d", adrCount) + } + if !hasIndex { + t.Error("expected docs/adr/README.md index") + } +} + +func TestEnsureTrainingGuide_SkipsWhenPresent(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "docs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs", "training.md"), []byte("# Existing guide"), 0o644); err != nil { + t.Fatal(err) + } + client := llm.NewReplayClient() // exhausted → would error if called + ensureTrainingGuide(context.Background(), dir, "p", "t", "{}", client, "m") + if client.CallCount() != 0 { + t.Errorf("should not generate when training.md exists (calls=%d)", client.CallCount()) + } +} + +func TestEnsureTrainingGuide_GeneratesWhenMissing(t *testing.T) { + dir := t.TempDir() + body := "# Getting Started\n\nInstall with `go build`. Run `./app`. Expected: it works and prints a result line." + client := llm.NewReplayClient(llm.CompletionResponse{Content: body}) + ensureTrainingGuide(context.Background(), dir, "p", "tree", "{}", client, "m") + got, err := os.ReadFile(filepath.Join(dir, "docs", "training.md")) + if err != nil { + t.Fatalf("training.md not written: %v", err) + } + if !strings.Contains(string(got), "Getting Started") { + t.Errorf("unexpected training content: %s", got) + } +} diff --git a/internal/engine/frontend_wiring_test.go b/internal/engine/frontend_wiring_test.go new file mode 100644 index 0000000..1e49074 --- /dev/null +++ b/internal/engine/frontend_wiring_test.go @@ -0,0 +1,28 @@ +package engine + +import ( + "os" + "strings" + "testing" +) + +// TestExecutor_WiresFrontendDetection guards against a dead-wire regression: +// the frontend design brief (agent.FrontendDesignBrief + PromptContext. +// IsFrontend) only fires if the executor actually calls detectFrontend and +// threads the flag into every prompt path — the CLI-runtime dispatch, the +// native-runtime dispatch, and retries via TemplateContext. The +// prompt-injection unit tests cannot catch the executor never setting the flag. +func TestExecutor_WiresFrontendDetection(t *testing.T) { + src, err := os.ReadFile("executor.go") + if err != nil { + t.Fatalf("read executor.go: %v", err) + } + code := string(src) + + if got := strings.Count(code, "detectFrontend("); got < 2 { + t.Errorf("executor.go must call detectFrontend on both the CLI and native dispatch paths, found %d call(s)", got) + } + if got := strings.Count(code, "IsFrontend:"); got < 3 { + t.Errorf("executor.go must thread IsFrontend into both PromptContexts and the retry TemplateContext, found %d assignment(s)", got) + } +} diff --git a/internal/engine/integration_test.go b/internal/engine/integration_test.go index 459ecb1..53eabe1 100644 --- a/internal/engine/integration_test.go +++ b/internal/engine/integration_test.go @@ -81,6 +81,8 @@ 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 --- @@ -208,6 +210,8 @@ 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"}, @@ -392,6 +396,8 @@ 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) @@ -479,6 +485,8 @@ 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) diff --git a/internal/engine/monitor.go b/internal/engine/monitor.go index f2dcb2b..f1c7514 100644 --- a/internal/engine/monitor.go +++ b/internal/engine/monitor.go @@ -79,6 +79,16 @@ type Monitor struct { // finding meets the gate severity. Nil disables the per-story security gate. securityGate *SecurityGate + // docClient and docModel are used by the documentation generator that + // creates/updates README.md + docs/ after all stories merge. Nil disables. + docClient llm.Client + docModel string + + // completionGate verifies the composed mainline (build + tests) before + // REQ_COMPLETED and runs a bounded auto-fix loop on a red build. Nil falls + // back to the legacy advisory verification (gaps logged, never blocking). + completionGate *CompletionGate + // dryRun causes the post-execution pipeline to simulate a successful // agent diff instead of checking the real worktree. dryRun bool @@ -179,6 +189,20 @@ func (m *Monitor) SetAutoResume(d *Dispatcher, e *Executor) { // SetSecurityGate wires the per-story security agent (scanners + LLM threat-model // review). A finding at or above the configured gate severity pauses the // requirement for a human decision rather than escalating. Nil disables it. +// SetDocGenerator enables automatic README/docs generation when all stories +// in a requirement have merged. +func (m *Monitor) SetDocGenerator(client llm.Client, model string) { + m.docClient = client + m.docModel = model +} + +// SetCompletionGate wires the requirement-completion verification gate. When +// set, REQ_COMPLETED is only emitted after the composed mainline verifies +// green; a red mainline that survives the auto-fix budget emits REQ_BLOCKED. +func (m *Monitor) SetCompletionGate(g *CompletionGate) { + m.completionGate = g +} + func (m *Monitor) SetSecurityGate(g *SecurityGate) { m.securityGate = g } @@ -1074,9 +1098,64 @@ func (m *Monitor) dispatchNextWave(ctx context.Context, rc *RunContext, repoDir if allDone { log.Printf("[auto-resume] all %d stories complete for requirement %s", len(stories), rc.ReqID) + + // Generate/update README + docs/ (SVG diagrams, training guide, ADRs, + // index) as the final step, before the tree is verified. + if m.docClient != nil { + storyTitles := make([]string, len(stories)) + for i, s := range stories { + storyTitles[i] = "- " + s.Title + } + reqTitle := rc.ReqID + if req, reqErr := m.projStore.GetRequirement(rc.ReqID); reqErr == nil { + reqTitle = req.Title + } + generateDocumentation(ctx, repoDir, reqTitle, storyTitles, m.docClient, m.docModel) + } + + // Pull merged changes into the local checkout FIRST so verification + // runs against the true composed mainline (all merged stories), not a + // stale checkout. Without this 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 path when no gate is wired. + if m.completionGate != nil { + if m.completionGate.Run(ctx, rc.ReqID, repoDir) { + emitEventOrLog(m.eventStore, m.projStore, + state.NewEvent(state.EventReqCompleted, "monitor", "", map[string]any{"id": rc.ReqID})) + } else { + log.Printf("[gate] %s: completion blocked — see .nxd-fix-gaps.md; run 'nxd resume %s --godmode' after addressing the gaps", rc.ReqID, rc.ReqID) + emitEventOrLog(m.eventStore, m.projStore, + state.NewEvent(state.EventReqBlocked, "monitor", "", map[string]any{"id": rc.ReqID})) + } + 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)) + if fixReq := GapsToRequirement(verifyResult.Gaps, filepath.Base(repoDir)); fixReq != "" { + fixPath := filepath.Join(repoDir, ".nxd-fix-gaps.md") + 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 — run 'nxd req --file .nxd-fix-gaps.md --godmode' to auto-fix", fixPath) + } + } + } else { + log.Printf("[verify] cycle 1 clean — no critical gaps found") + } + // Mark requirement complete. emitEventOrLog(m.eventStore, m.projStore, state.NewEvent(state.EventReqCompleted, "monitor", "", map[string]any{"id": rc.ReqID})) diff --git a/internal/engine/monitor_pull.go b/internal/engine/monitor_pull.go new file mode 100644 index 0000000..d19b11d --- /dev/null +++ b/internal/engine/monitor_pull.go @@ -0,0 +1,105 @@ +package engine + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// pullBaseAfterMerge fast-forwards the local checkout to the composed base +// branch after all stories merge, so subsequent tools (the completion gate, +// the doc generator, other agents) verify the true merged tree rather than a +// stale checkout. Best-effort throughout — a failed pull is logged, never +// fatal. +func pullBaseAfterMerge(repoDir, baseBranch string) { + if repoDir == "" { + return + } + + // Pre-clean NXD-only working-tree leftovers that would block ff-pull. + // These files may be untracked (written by NXD, never committed) or + // tracked+modified (e.g. from a prior partial run). Handle both cases: + // git clean -f