From 1c20eeead429cb882b9944da2bb2c7326a935d62 Mon Sep 17 00:00:00 2001 From: Thando Date: Thu, 2 Jul 2026 13:01:17 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontend):=20embedded=20frontend-desig?= =?UTF-8?q?n=20skill=20=E2=80=94=20UI=20stories=20get=20design-intent=20pl?= =?UTF-8?q?anning=20+=20a=20distinctive-design=20brief?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI-facing stories are detected (owned-file extensions + whole-word UI vocabulary) and their agents receive agent.FrontendDesignBrief: token-first two-pass planning with a self-critique step, one signature element, named banned defaults (Inter/Roboto, purple-gradient-on-white, cream #F4F1EA + serif + terracotta, acid-green-on-black, the template feature-card page, scattered animation), a WCAG accessibility floor (responsive to 360px, visible keyboard focus, prefers-reduced-motion, 44px targets, designed empty/loading/error states), and copy-as-design-material rules. - detectFrontend (engine/detect.go): extensions are the strongest signal; keyword regex uses word boundaries so pagination/performance/review never false-positive; 'html' and 'responsive' deliberately excluded (server-side HTML emails and 'responsive API gateway' are backend work). 24 table cases. - IsFrontend threads through BOTH dispatch paths (CLI runtime + native) and the retry TemplateContext; TestExecutor_WiresFrontendDetection guards the wires. - Planner decomposition prompt now requires the first UI story to establish a design-token foundation consumed by later UI stories, with the accessibility floor in UI acceptance criteria (TestPlanner_PromptIncludesFactoryStandards pins security + design blocks). - Brief size budget pinned at ≤6 KB (rides on every UI dispatch). --- .firecrawl/devin-2-pricing.md | 203 ++++++++++++++++++++++ internal/agent/frontend.go | 67 +++++++ internal/agent/frontend_test.go | 83 +++++++++ internal/agent/prompts.go | 4 + internal/agent/render.go | 2 + internal/engine/detect.go | 45 +++++ internal/engine/detect_test.go | 48 +++++ internal/engine/executor.go | 3 + internal/engine/frontend_wiring_test.go | 28 +++ internal/engine/planner.go | 1 + internal/engine/planner_standards_test.go | 54 ++++++ 11 files changed, 538 insertions(+) create mode 100644 .firecrawl/devin-2-pricing.md create mode 100644 internal/agent/frontend.go create mode 100644 internal/agent/frontend_test.go create mode 100644 internal/engine/detect.go create mode 100644 internal/engine/detect_test.go create mode 100644 internal/engine/frontend_wiring_test.go create mode 100644 internal/engine/planner_standards_test.go 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 + + +![VentureBeat made with Midjourney](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F76unO9ilZzlMBsa0Xk3C8S%2F1e46a82508a80ab08dfb0ce161d3a7cb%2Fcfr0z3n_surreal_image_of_researchers_with_laptops_gathered_arou_5e34f6c1-554a-4506-9900-9d52c1a30043.png%3Fw%3D1000%26q%3D100&w=3840&q=85) + +[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. + +![](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F6tALB1Dk361wdRVDnPOPdU%2Fcc5bdd51b109b3d21f377c4741e0e6b3%2FScreenshot-2025-04-03-at-3.13.50_PM.png%3Fw%3D1000%26q%3D100&w=3840&q=75) + +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 + +[![Nuneybits Vector art of golden tokens flooding funnel d3ce7ef3-0d50-4835-b3dd-de3ccfa6900e](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F331duWoehbByzdXcqAME9g%2Fc14f677c751a2ff2c9ddcbae149bc8e1%2FNuneybits_Vector_art_of_golden_tokens_flooding_funnel_d3ce7ef3-0d50-4835-b3dd-de3ccfa6900e.webp%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Marlin popping out of computer](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F5StCW6O4h5oCFhnr4NlI2v%2F1738378131f3115fdb0ecfa61fbfebe2%2FChatGPT_Image_Jun_15__2026__03_10_43_PM.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![USG raid on AI lab](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F2z5YLKjoeV6nzBjKELk7Yo%2F900a955fa4e5390ad6ea0921e64a7b85%2FChatGPT_Image_Jun_13__2026__08_09_56_AM.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Kimi gateway](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F1qgfxRq3zYmGo7J9jMkqCA%2F0d83440ff424b453d03efc45bb23cce1%2Fkimi-gateway-smk1.jpg%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![White robot stands in front of orange hallway of monitors](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F12q7BpuvmPejutBrIyn6sZ%2F404c467dfe2af6da6c3a0854eb32fc98%2FGemini_Generated_Image_fg84xafg84xafg84.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Diffusion understanding text](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F7ovrrf19CQHT2JVXAFiEip%2Fa9d0d1480eb2651175a20372894efa4d%2Fdiffusion-image-text.jpg%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![OpenAI climbs atop the pile of e-waste while Anthropic struggles lower down](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F5g96PMK4kOzVQ2whYWfVDW%2F168843358a4bd8c35031a71071d33f9d%2FGemini_Generated_Image_3ru7ln3ru7ln3ru7__1_.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![HRM](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F5f6OtV35BgTCDXmJgbnBa1%2Fc9938db9c42cfa72001085087e5dc003%2FHRM.jpg%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Dario Amodei reads papers surrounded by aircraft and computer visuals](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F54ZVTmwJStSWL9FqHNh6J4%2F956d750fb7590002b868f6804591d304%2FChatGPT_Image_Jun_10__2026__03_45_26_PM.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Apple products exploded view](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F6uthaZc1i2Q9MgfkivyFpf%2F7625484349f502786a87695e198930dc%2FChatGPT_Image_Jun_9__2026__05_32_55_PM.png%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![cohere-north-smk1](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F2hmLU69mEDG09CllK8wDeJ%2F43818a9c508618ce7dd7fc6ff006a0e5%2Fcohere-north-smk1.jpg%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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 + + +[![Foundation models in a smart phone](https://venturebeat.com/_next/image?url=https%3A%2F%2Fimages.ctfassets.net%2Fjdtwqhzvc2n1%2F3hpTQydwOHQ99Vln5T3uLo%2F14ac8c1a0953035ee0b9fe021f39e232%2FAI-in-phone-smk1.jpg%3Fw%3D1000%26q%3D100&w=3840&q=50)\\ +\\ +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/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/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/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/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/planner.go b/internal/engine/planner.go index b8e7779..46fded1 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -182,6 +182,7 @@ IMPORTANT: - Distribute work across different files to minimize merge conflicts between parallel agents. - Each file path MUST appear in exactly ONE story's owned_files — no overlapping file ownership between stories. - Security (OWASP Top 10 baseline — a security gate runs on every story before merge, so design for it): enforce access control server-side (deny by default, check ownership — no IDOR); use modern crypto and crypto/rand, never hardcode secrets (use env/secret manager); prevent injection (parameterized queries, no string-built SQL/shell, context-aware output encoding for XSS); validate inputs at trust boundaries; harden config (no debug in prod, no secrets/stack traces in responses or logs); audit dependencies for CVEs; verify auth tokens and rate-limit auth; avoid deserializing untrusted data; guard server-side fetches against SSRF. State the specific protection in the acceptance criteria for any web/HTML/API/templating/auth surface. +- Frontend design (any story that builds or changes a user-facing web UI — a full design brief is injected into those agents, so plan for it): the FIRST UI story must establish a design-token foundation (palette of 4-6 named colors with one dominant + one accent, a display+body typeface pairing that is NOT Inter/Roboto/system defaults, spacing scale — as CSS custom properties or the framework theme) and every later UI story consumes those tokens, never ad-hoc values. UI acceptance criteria MUST include the quality floor: responsive to 360px, visible keyboard focus, WCAG AA contrast, prefers-reduced-motion respected, and designed empty/loading/error states. Copy is real product copy, never lorem ipsum or "Submit". - Use explicit relative paths from the project root (e.g., "src/api/handler.go", not just "handler.go"). - Keep story complexity at or below %d. - For simple requirements (1-2 files), prefer fewer stories (1-2) over many small ones. diff --git a/internal/engine/planner_standards_test.go b/internal/engine/planner_standards_test.go new file mode 100644 index 0000000..31c7573 --- /dev/null +++ b/internal/engine/planner_standards_test.go @@ -0,0 +1,54 @@ +package engine_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/config" + "github.com/tzone85/nexus-dispatch/internal/engine" + "github.com/tzone85/nexus-dispatch/internal/llm" + "github.com/tzone85/nexus-dispatch/internal/state" +) + +// TestPlanner_PromptIncludesFactoryStandards pins the standards blocks in the +// decomposition prompt: the security baseline (a gate runs on every story) and +// the frontend design standards (token-first foundation story, no default +// fonts, accessibility quality floor in acceptance criteria). +func TestPlanner_PromptIncludesFactoryStandards(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + + 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() + + resp := `[{"id":"s-001","title":"A","description":"d","acceptance_criteria":"ac","complexity":3,"depends_on":[]}]` + client := llm.NewReplayClient(llm.CompletionResponse{Content: resp}) + planner := engine.NewPlanner(client, config.DefaultConfig(), es, ps) + + if _, err := planner.Plan(context.Background(), "r-001", "Build a web app", dir); err != nil { + t.Fatalf("plan: %v", err) + } + + prompt := client.CallAt(0).Messages[0].Content + for _, must := range []string{"OWASP", "SSRF"} { + if !strings.Contains(prompt, must) { + t.Errorf("decomposition prompt missing security standard %q", must) + } + } + for _, must := range []string{"design-token", "Inter", "WCAG", "prefers-reduced-motion", "360px"} { + if !strings.Contains(prompt, must) { + t.Errorf("decomposition prompt missing frontend design standard %q", must) + } + } +} From 0dfec530c2eb2bd5ff7b177173273fc42cc151a0 Mon Sep 17 00:00:00 2001 From: Thando Date: Thu, 2 Jul 2026 13:19:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20port=20factory-completeness=20set?= =?UTF-8?q?=20=E2=80=94=20completion=20gate,=20docs=20subsystem,=20planner?= =?UTF-8?q?=20factory=20stories,=20frontend=20skill,=20security=20deltas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five features, offline-first, with tests: 1. Requirement-completion verification gate (engine/completion_gate.go + verification_loop.go + monitor_pull.go): REQ_COMPLETED only after the composed mainline verifies green (deps, build, tests, hallucination + conflict-marker scan). Red mainline → bounded auto-fix cycles (qa.completion_fix_cycles, default 2) → REQ_BLOCKED (new event, projects status 'blocked') with gaps in .nxd-fix-gaps.md. Nil client ⇒ hard gate: silently completing on a red build is impossible regardless of wiring. The local checkout is pulled to the composed mainline before verification. 2. Docs subsystem (doc_generator.go + svg_docs.go + factory_docs.go + factory_docs_adr.go): README generate/update + real-SVG architecture and sequence diagrams (validated, Mermaid rejected, retry-with-feedback), training guide + ADRs backstopped only when the agent didn't supply them, deterministic docs/README.md index. Best-effort, never blocks completion. 3. Planner factory stories (default ON): integration story (wire everything into the real entry point + boot-the-app smoke test — closes the compose gap) and scribe story (README + training + SVGs + ADRs + docs index, greenfield-aware via nxd:scribe markers). Ephemeral estimates skip both; count-asserting tests set the flags false. 4. Frontend design skill (agent/frontend.go + engine/detect.go): UI stories detected via owned-file extensions + word-boundary keyword regex get the distinctive-design brief (token-first planning, signature element, banned AI-default looks, WCAG floor); threaded through CLI-runtime, native-runtime and retry prompt paths; planner requires a design-token foundation story. 5. Security deltas: RunScanners gains the failed-scanner classification (a tool that errored is coverage LOST, never a clean run) + Report.Failed; KnownScanners/InstallHint registry exports; fake-scanner harness tests drive RunScanners end-to-end (internal/security to 98.3% coverage); gate LLM paths tested via ReplayClient. All GitHub Actions pinned to commit SHAs (supply-chain, public repo). Also scrubbed stray sibling-project references from source comments. --- .github/workflows/ci.yml | 40 +-- .github/workflows/nxd-automate.yml | 4 +- .github/workflows/traffic-stats.yml | 2 +- CLAUDE.md | 12 + internal/cli/resume.go | 36 +++ internal/config/config.go | 80 ++++-- internal/config/loader.go | 2 + internal/engine/completion_gate.go | 182 ++++++++++++ internal/engine/completion_gate_test.go | 223 +++++++++++++++ internal/engine/doc_generator.go | 224 +++++++++++++++ internal/engine/doc_generator_test.go | 94 +++++++ internal/engine/factory_docs.go | 177 ++++++++++++ internal/engine/factory_docs_adr.go | 180 ++++++++++++ internal/engine/factory_docs_test.go | 201 ++++++++++++++ internal/engine/integration_test.go | 8 + internal/engine/monitor.go | 79 ++++++ internal/engine/monitor_pull.go | 105 +++++++ internal/engine/planner.go | 92 +++++++ internal/engine/planner_standards_test.go | 75 +++++ internal/engine/planner_test.go | 6 + internal/engine/security_gate.go | 18 +- internal/engine/security_gate_llm_test.go | 196 +++++++++++++ internal/engine/security_gate_test.go | 39 ++- internal/engine/svg_docs.go | 253 +++++++++++++++++ internal/engine/svg_docs_test.go | 160 +++++++++++ internal/engine/verification_loop.go | 320 ++++++++++++++++++++++ internal/engine/wiring_gemma_test.go | 2 + internal/engine/wiring_test.go | 16 +- internal/security/coverage_gaps_test.go | 241 ++++++++++++++++ internal/security/report.go | 8 +- internal/security/scanners.go | 57 +++- internal/security/scanners_exec_test.go | 269 ++++++++++++++++++ internal/state/events.go | 12 +- internal/state/sqlite.go | 3 + nxd.config.example.yaml | 4 + test/dryrun_test.go | 2 + 36 files changed, 3339 insertions(+), 83 deletions(-) create mode 100644 internal/engine/completion_gate.go create mode 100644 internal/engine/completion_gate_test.go create mode 100644 internal/engine/doc_generator.go create mode 100644 internal/engine/doc_generator_test.go create mode 100644 internal/engine/factory_docs.go create mode 100644 internal/engine/factory_docs_adr.go create mode 100644 internal/engine/factory_docs_test.go create mode 100644 internal/engine/monitor_pull.go create mode 100644 internal/engine/security_gate_llm_test.go create mode 100644 internal/engine/svg_docs.go create mode 100644 internal/engine/svg_docs_test.go create mode 100644 internal/engine/verification_loop.go create mode 100644 internal/security/coverage_gaps_test.go create mode 100644 internal/security/scanners_exec_test.go 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/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/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/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/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 — removes untracked files + // git checkout -- — discards tracked modifications (restores HEAD) + // Both commands are best-effort; errors are intentionally ignored. + for _, artifact := range []string{ + "WAVE_CONTEXT.md", + "REQUIREMENT.md", + ".nxd-fix-gaps.md", + } { + checkoutCmd := exec.Command("git", "-C", repoDir, "checkout", "--", artifact) + _ = checkoutCmd.Run() + cleanCmd := exec.Command("git", "-C", repoDir, "clean", "-f", artifact) + _ = cleanCmd.Run() + p := filepath.Join(repoDir, artifact) + if _, err := os.Stat(p); err == nil { + _ = os.Remove(p) // best-effort + } + } + + // Ensure gitignore covers NXD artifacts for the main repo (not just worktrees). + ensureGitignorePatterns(repoDir) + + branches := []string{baseBranch} + if baseBranch == "" { + branches = []string{"main", "master"} + } + for _, branch := range branches { + if branch == "" { + continue + } + cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/"+branch) + cmd.Dir = repoDir + if err := cmd.Run(); err == nil { + gitPullWithStash(repoDir, branch) + return + } + } + log.Printf("[auto-resume] could not detect base branch for pull") +} + +// gitPullWithStash performs a fast-forward pull of the given branch. +// If the working tree is dirty it stashes first, pulls, then pops. +// If the stash itself fails it skips the pull cleanly. Local-only repos +// (no origin remote — common offline) log the failed pull as non-fatal. +func gitPullWithStash(repoDir, branch string) { + // The dirty check MUST run in repoDir, not the daemon's CWD. + statusCmd := exec.Command("git", "status", "--porcelain") + statusCmd.Dir = repoDir + statusOut, err := statusCmd.Output() + statusText := "" + if err == nil { + statusText = strings.TrimSpace(string(statusOut)) + } + dirty := statusText != "" + + if dirty { + stash := exec.Command("git", "stash", "push", "-u", "-m", "nxd-pull-stash") + stash.Dir = repoDir + if stashErr := stash.Run(); stashErr != nil { + dirtyCount := len(strings.Split(statusText, "\n")) + log.Printf("[auto-resume] working tree dirty (%d files) — skipping %s pull; manual: cd %s && git pull --ff-only origin %s", + dirtyCount, branch, repoDir, branch) + return + } + log.Printf("[auto-resume] stashed dirty working tree before pulling %s", branch) + } + + pull := exec.Command("git", "pull", "--ff-only", "origin", branch) + pull.Dir = repoDir + if out, pullErr := pull.CombinedOutput(); pullErr != nil { + log.Printf("[auto-resume] pull %s non-fatal: %v — %s", branch, pullErr, strings.TrimSpace(string(out))) + } else { + log.Printf("[auto-resume] pulled latest %s into local checkout", branch) + } + + if dirty { + pop := exec.Command("git", "stash", "pop") + pop.Dir = repoDir + if popErr := pop.Run(); popErr != nil { + log.Printf("[auto-resume] stash pop after pull: %v (manual: cd %s && git stash pop)", popErr, repoDir) + } + } +} diff --git a/internal/engine/planner.go b/internal/engine/planner.go index 46fded1..98aaec4 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -393,6 +393,33 @@ architecture and conventions when planning stories.`, profileContext) } } + // Integration story: a final code story that depends on every other code + // story, wires all independently-built components into the application + // entry point, reconciles interface mismatches with adapters, and writes a + // smoke test that BOOTS the app and asserts the documented surface actually + // responds. This closes the systemic gap where per-story unit tests pass + // (against mocks) but the whole never composes — unwired handlers, no auth, + // incompatible interfaces. + if persist && p.config.Planning.EmitIntegrationStory && len(stories) > 0 { + deps := make([]string, 0, len(stories)) + for _, s := range stories { + deps = append(deps, s.ID) + } + stories = append(stories, buildIntegrationStory(prefix, requirement, deps)) + } + + // README Scribe: append a final story that documents what was built. It + // depends on every other story so it runs last (after all code is merged), + // owns README.md + docs/, and is greenfield-aware. Skipped for ephemeral + // estimates and when planning.emit_scribe_story is disabled. + if persist && p.config.Planning.EmitScribeStory && len(stories) > 0 { + deps := make([]string, 0, len(stories)) + for _, s := range stories { + deps = append(deps, s.ID) + } + stories = append(stories, buildScribeStory(prefix, requirement, deps)) + } + // Build dependency graph dag := graph.New() for _, s := range stories { @@ -602,3 +629,68 @@ func (p *Planner) emitAndProject(eventType state.EventType, agentID, storyID str } return p.projStore.Project(evt) } + +// scribeStorySuffix is the stable, un-prefixed id of the documentation story. +const scribeStorySuffix = "scribe-readme" + +// buildIntegrationStory constructs the final integration story. It runs after +// every code story (depends on all of them) and is responsible for making the +// independently-built components actually compose into a working application. +func buildIntegrationStory(prefix, requirement string, deps []string) PlannedStory { + desc := fmt.Sprintf(`Integrate everything the other stories built into ONE working application for this requirement: %s + +The other stories each built and unit-tested a component in isolation (often against mocks). Your job is to make the WHOLE thing actually run end-to-end. This is the most important story — a build that passes unit tests but does not run is a failure. + +Do ALL of the following: +- Wire every component into the application entry point (e.g. main.go / app.py / server / index.ts / CLI root). Every handler, route, command, page, and middleware that a story built MUST be reachable from the real entry point. Audit for dangling wires: a feature whose unit test passes but that is never registered in the entry point is a bug. +- Apply cross-cutting middleware that stories built but could not wire themselves (authentication, logging, CORS, rate limiting) at the entry point. If an auth/API-key middleware exists, it MUST actually guard the documented protected routes. +- Reconcile interface mismatches between components with thin adapters. Independently-built stories often declare slightly different interfaces; add the adapter so the real implementation — not a mock — is wired in. Do NOT leave a production path depending on a test-only mock. +- Add a SMOKE TEST that boots the application and exercises the documented surface end-to-end: for a server, start it and assert each documented endpoint responds with the expected status (NOT 404) and that protected routes reject missing credentials; for a CLI, run the documented commands and assert real output; for a UI, render the primary flow. The smoke test must FAIL if a feature is unreachable or unwired. +- Fix any wiring/compile/type errors this integration surfaces so the full app builds and all tests (including your smoke test) pass. + +Make reasonable, conventional choices for any route paths or wiring details the requirement leaves unspecified, and note them briefly in code comments.`, requirement) + + return PlannedStory{ + ID: prefix + "-integrate", + Title: "Integrate all components into a working app + end-to-end smoke test", + Description: desc, + AcceptanceCriteria: FlexibleString("Every documented feature is reachable from the real application entry point (no dangling/unwired handlers, commands, or pages); cross-cutting middleware (auth etc.) actually guards the documented routes; interface mismatches between components are bridged with adapters so the production path uses real implementations, not mocks; a smoke test boots the app and asserts the documented surface responds end-to-end (protected routes reject missing credentials, documented endpoints do not 404) and that smoke test passes along with the full build/test suite."), + Complexity: 5, + DependsOn: deps, + // No declared owned_files: integration legitimately touches the entry + // point (owned by the skeleton story) and adds adapter/smoke-test files. + // It depends on every story, so it runs last with no parallel conflict. + OwnedFiles: []string{}, + WaveHint: "sequential", + } +} + +// buildScribeStory constructs the final documentation story. It depends on +// every other story (deps are already prefixed), owns README.md + docs/, and +// instructs the agent to be greenfield-aware: author a full README on a stub, +// but confine edits on an existing README to the nxd:scribe markers so +// hand-written prose is never clobbered. +func buildScribeStory(prefix, requirement string, deps []string) PlannedStory { + desc := fmt.Sprintf(`Document the project to software-factory standard for what this requirement delivered: %s + +Write for a reader who is new to the project. Deliver the COMPLETE software-factory documentation set: +- README.md: explain what it is, how to install/run it, and how to use it — accurate to what was actually built and merged (do not invent features). It is the entry point: link to docs/ (the docs index), the training guide, and the Architecture Decision Records. +- docs/training.md: a "Getting Started" step-by-step hands-on walkthrough that takes a new user from zero to a working result, with copy-pasteable commands and expected output. +- docs/architecture.svg: an architecture diagram authored as a real rendered SVG file (valid XML). NOT Mermaid, NOT a code fence, NOT a .mmd file — an actual .svg. +- docs/sequence.svg: a sequence diagram of the primary user flow, also as a real rendered SVG file (valid XML). NOT Mermaid. +- docs/adr/0001-*.md … : Architecture Decision Records for the significant, hard-to-reverse decisions (persistence, layering, offline-vs-network, auth, a key algorithm, an error-handling contract, etc.), each grounded in the real code with Status/Context/Decision/Consequences. Add docs/adr/README.md as an index. +- docs/README.md: a documentation index linking the README, training guide, both SVGs, and the ADRs. +- Reference both SVGs from the README (e.g. via ![Architecture](docs/architecture.svg)) and link docs/ + docs/adr/. +- Greenfield-aware: if README.md is empty or a bare stub, author a complete README. If it already has substantial hand-written content, edit ONLY inside the markers `+"``"+` ... `+"``"+` (create that block at the end if absent) — never rewrite or delete existing prose outside the markers. The docs/ files are new and may be authored freely.`, requirement) + + return PlannedStory{ + ID: prefix + "-" + scribeStorySuffix, + Title: "Document the project: README + training + SVG diagrams + ADRs + docs index", + Description: desc, + AcceptanceCriteria: FlexibleString("README.md accurately documents the delivered functionality with install/run/usage instructions and links docs/, the training guide, and the ADRs; docs/training.md is a step-by-step Getting-Started tutorial with copy-pasteable commands; docs/architecture.svg and docs/sequence.svg exist as valid rendered SVG ( XML, NOT Mermaid/code-fence/.mmd) and are referenced from the README; docs/adr/ contains Architecture Decision Records (Status/Context/Decision/Consequences) plus an index, grounded in the real code; docs/README.md is a documentation index; on a pre-existing README, edits are confined to the nxd:scribe markers and existing content outside them is unchanged."), + Complexity: 5, + DependsOn: deps, + OwnedFiles: []string{"README.md", "docs/architecture.svg", "docs/sequence.svg", "docs/training.md", "docs/adr", "docs/README.md"}, + WaveHint: "sequential", + } +} diff --git a/internal/engine/planner_standards_test.go b/internal/engine/planner_standards_test.go index 31c7573..d80a75e 100644 --- a/internal/engine/planner_standards_test.go +++ b/internal/engine/planner_standards_test.go @@ -52,3 +52,78 @@ func TestPlanner_PromptIncludesFactoryStandards(t *testing.T) { } } } + +// TestPlanner_EmitsIntegrationAndScribeStories pins the two factory stories +// appended to every persisted plan: the integration story (wire everything +// into the real entry point + smoke test) and the scribe story (README + +// training + SVG diagrams + ADRs + docs index), both depending on every other +// story so they run last. +func TestPlanner_EmitsIntegrationAndScribeStories(t *testing.T) { + dir := t.TempDir() + _ = os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + + 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() + + resp := `[{"id":"s-001","title":"A","description":"d","acceptance_criteria":"ac","complexity":3,"depends_on":[]}]` + // Two responses: one for the persisted plan, one for the ephemeral estimate. + client := llm.NewReplayClient( + llm.CompletionResponse{Content: resp}, + llm.CompletionResponse{Content: resp}, + ) + planner := engine.NewPlanner(client, config.DefaultConfig(), es, ps) + + result, err := planner.Plan(context.Background(), "r-001", "Build a web app", dir) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(result.Stories) != 3 { + t.Fatalf("want code story + integration + scribe (3), got %d", len(result.Stories)) + } + + integrate := result.Stories[1] + if !strings.Contains(integrate.ID, "integrate") { + t.Errorf("second appended story should be the integration story, got %s", integrate.ID) + } + if !strings.Contains(integrate.Description, "SMOKE TEST") { + t.Error("integration story must demand an end-to-end smoke test") + } + + scribe := result.Stories[2] + if !strings.Contains(scribe.ID, "scribe-readme") { + t.Errorf("final story should be the scribe story, got %s", scribe.ID) + } + for _, want := range []string{"docs/architecture.svg", "docs/sequence.svg", "docs/training.md", "docs/README.md"} { + found := false + for _, f := range scribe.OwnedFiles { + if f == want { + found = true + } + } + if !found { + t.Errorf("scribe story must own %s, got %v", want, scribe.OwnedFiles) + } + } + // Both must depend on the code story so they run last. + for _, s := range []engine.PlannedStory{integrate, scribe} { + if len(s.DependsOn) == 0 { + t.Errorf("%s must depend on every other story", s.ID) + } + } + // Estimates (ephemeral plans) must NOT include the appended stories. + est, err := planner.PlanEphemeral(context.Background(), "est-1", "Build a web app", dir) + if err != nil { + t.Fatalf("ephemeral plan: %v", err) + } + if len(est.Stories) != 1 { + t.Errorf("ephemeral plan must not append factory stories, got %d", len(est.Stories)) + } +} diff --git a/internal/engine/planner_test.go b/internal/engine/planner_test.go index 5c6c220..265e5fa 100644 --- a/internal/engine/planner_test.go +++ b/internal/engine/planner_test.go @@ -40,6 +40,8 @@ func TestPlanner_Plan(t *testing.T) { }) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-001", "Add user authentication", dir) @@ -239,6 +241,8 @@ func TestPlan_ParsesOwnedFiles(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-002", "Add API layer", dir) @@ -324,6 +328,8 @@ func TestPlan_WarnsFileOverlapForParallel(t *testing.T) { client := llm.NewReplayClient(llm.CompletionResponse{Content: response}) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := engine.NewPlanner(client, cfg, eventStore, projStore) result, err := planner.Plan(context.Background(), "r-004", "Overlapping files", dir) diff --git a/internal/engine/security_gate.go b/internal/engine/security_gate.go index 1c7f3e4..d88433b 100644 --- a/internal/engine/security_gate.go +++ b/internal/engine/security_gate.go @@ -18,7 +18,7 @@ const securityReviewTimeout = 3 * time.Minute // scanFunc runs the deterministic scanners against a repo. It is a seam so tests // can supply canned findings instead of invoking real tools. -type scanFunc func(ctx context.Context, repoDir string) (findings []security.Finding, ran, skipped []security.ScannerKind) +type scanFunc func(ctx context.Context, repoDir string) (findings []security.Finding, ran, skipped, failed []security.ScannerKind) // SecurityGate is nxd's security agent. It combines deterministic SAST/secret/ // dependency scanners with an LLM threat-model review driven by a growable @@ -81,7 +81,10 @@ func (g *SecurityGate) ScanRepo(ctx context.Context, repoDir string) (security.R return security.Report{}, fmt.Errorf("load knowledge base: %w", err) } - findings, ran, skipped := g.scan(ctx, repoDir) + findings, ran, skipped, failed := g.scan(ctx, repoDir) + if len(failed) > 0 { + log.Printf("[security-gate] scan coverage lost: %d scanner(s) failed: %v", len(failed), failed) + } if g.client != nil { findings = append(findings, g.llmReview(ctx, repoDir, langs, kb)...) @@ -93,6 +96,7 @@ func (g *SecurityGate) ScanRepo(ctx context.Context, repoDir string) (security.R Languages: langs, ScannersRun: ran, Skipped: skipped, + Failed: failed, Findings: findings, KBVersion: kb.Version, } @@ -119,13 +123,19 @@ func (g *SecurityGate) ReviewStory(ctx context.Context, storyID, title, diff, re return false, "", fmt.Errorf("load knowledge base: %w", kbErr) } - findings, _, _ := g.scan(ctx, repoDir) + findings, _, _, failed := g.scan(ctx, repoDir) + if len(failed) > 0 { + // Coverage was lost for this story's gate. Per policy a scanner failure + // never blocks the merge, but it must be visible — silently passing the + // story would report it as secure when part of the scan never ran. + log.Printf("[security-gate] story %s: scan coverage lost, %d scanner(s) failed: %v", storyID, len(failed), failed) + } if g.client != nil { findings = append(findings, g.llmReviewDiff(ctx, title, diff, langs, kb)...) } findings = security.DedupeFindings(findings) - report := security.Report{RepoDir: repoDir, Languages: langs, Findings: findings, KBVersion: kb.Version} + report := security.Report{RepoDir: repoDir, Languages: langs, Failed: failed, Findings: findings, KBVersion: kb.Version} blocked := report.HasAtLeast(g.gateSeverity) if g.autoLearn { diff --git a/internal/engine/security_gate_llm_test.go b/internal/engine/security_gate_llm_test.go new file mode 100644 index 0000000..48ee2cf --- /dev/null +++ b/internal/engine/security_gate_llm_test.go @@ -0,0 +1,196 @@ +package engine + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/llm" + "github.com/tzone85/nexus-dispatch/internal/security" +) + +const llmCriticalFinding = `[{"severity":"critical","title":"SQL injection in user filter","file":"db/users.go","line":88,"rule_id":"CWE-89","detail":"query concatenates request input; use parameterised queries"}]` + +// ScanRepo with an LLM client merges the threat-model review's findings with +// the scanner findings — this drives llmReview + callLLM + parseLLMFindings. +func TestSecurityGate_ScanRepo_MergesLLMFindings(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + scannerFinding := security.Finding{Tool: "gosec", RuleID: "G101", Severity: security.SeverityHigh, File: "a.go", Line: 1, Title: "hardcoded cred", Source: "scanner"} + client := llm.NewReplayClient(llm.CompletionResponse{Content: llmCriticalFinding}) + g := newTestSecurityGate(t, client, kbPath, security.SeverityCritical, false, fakeScan(scannerFinding)) + + report, err := g.ScanRepo(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("ScanRepo: %v", err) + } + if report.Total() != 2 { + t.Fatalf("want scanner + llm findings merged (2), got %d: %+v", report.Total(), report.Findings) + } + var llmF *security.Finding + for i := range report.Findings { + if report.Findings[i].Source == "llm" { + llmF = &report.Findings[i] + } + } + if llmF == nil { + t.Fatal("LLM finding missing from report") + } + if llmF.Tool != "llm" || llmF.RuleID != "CWE-89" || llmF.Severity != security.SeverityCritical || llmF.Line != 88 { + t.Errorf("LLM finding mis-parsed: %+v", *llmF) + } +} + +// The LLM reviewing a story diff can block the story on its own — no scanner +// finding needed. Drives llmReviewDiff end-to-end through the block path. +func TestSecurityGate_ReviewStory_LLMDiffFindingBlocks(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + client := llm.NewReplayClient(llm.CompletionResponse{Content: llmCriticalFinding}) + g := newTestSecurityGate(t, client, kbPath, security.SeverityCritical, false, fakeScan()) + + passed, summary, err := g.ReviewStory(context.Background(), "s-1", "add user filter", "+ query := \"SELECT * FROM users WHERE name='\" + name + \"'\"", t.TempDir()) + if err != nil { + t.Fatalf("ReviewStory: %v", err) + } + if passed { + t.Fatal("critical LLM finding must block the story") + } + if !strings.Contains(summary, "SQL injection") { + t.Errorf("block summary must name the finding: %q", summary) + } + // The diff must have been sent to the model as data. + if client.CallCount() != 1 || !strings.Contains(client.CallAt(0).Messages[0].Content, "") { + t.Error("diff review prompt must wrap the diff in data tags") + } +} + +// An LLM failure must degrade to scanners-only, never abort the scan — the +// deterministic findings still make it into the report. +func TestSecurityGate_LLMFailureIsNonFatal(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + exhausted := llm.NewReplayClient() // zero responses ⇒ every call errors + scannerFinding := security.Finding{Tool: "gitleaks", RuleID: "aws", Severity: security.SeverityCritical, File: "x.env", Line: 3, Title: "AWS key", Source: "scanner"} + g := newTestSecurityGate(t, exhausted, kbPath, security.SeverityCritical, false, fakeScan(scannerFinding)) + + report, err := g.ScanRepo(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("LLM failure must not abort the scan: %v", err) + } + if report.Total() != 1 || report.Findings[0].Source != "scanner" { + t.Errorf("scanner findings must survive an LLM failure: %+v", report.Findings) + } +} + +// A prose-wrapped or garbage LLM response yields no findings rather than a +// crash or a phantom block. +func TestSecurityGate_ReviewStory_GarbageLLMResponsePasses(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + client := llm.NewReplayClient(llm.CompletionResponse{Content: "I could not review this code, sorry!"}) + g := newTestSecurityGate(t, client, kbPath, security.SeverityCritical, false, fakeScan()) + + passed, _, err := g.ReviewStory(context.Background(), "s-1", "t", "+ x", t.TempDir()) + if err != nil { + t.Fatalf("ReviewStory: %v", err) + } + if !passed { + t.Error("no parseable findings ⇒ story passes") + } +} + +// A corrupt knowledge base must abort both entry points loudly — silently +// falling back to the baseline would hide that learned rules were lost. +func TestSecurityGate_CorruptKBIsALoudError(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + if err := os.WriteFile(kbPath, []byte("{corrupt"), 0o600); err != nil { + t.Fatal(err) + } + g := newTestSecurityGate(t, nil, kbPath, security.SeverityCritical, false, fakeScan()) + + if _, err := g.ScanRepo(context.Background(), t.TempDir()); err == nil { + t.Error("ScanRepo must surface a corrupt KB") + } + if _, _, err := g.ReviewStory(context.Background(), "s-1", "t", "+x", t.TempDir()); err == nil { + t.Error("ReviewStory must surface a corrupt KB") + } +} + +// A KB that cannot be persisted must not break the scan — upskilling is +// best-effort; the findings and the report still stand. +func TestSecurityGate_UpskillPersistFailureIsNonFatal(t *testing.T) { + // A read-only KB file: LoadKnowledgeBase succeeds, Save's WriteFile fails. + kbPath := filepath.Join(t.TempDir(), "kb.json") + baseline, _ := json.Marshal(security.BaselineKnowledgeBase()) + if err := os.WriteFile(kbPath, baseline, 0o400); err != nil { + t.Fatal(err) + } + novel := security.Finding{Tool: "semgrep", RuleID: "custom.novel", Severity: security.SeverityHigh, File: "a.go", Line: 1, Title: "novel class", Source: "scanner"} + g := newTestSecurityGate(t, nil, kbPath, security.SeverityCritical, true, fakeScan(novel)) + + report, err := g.ScanRepo(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("persist failure must not abort the scan: %v", err) + } + if report.Total() != 1 { + t.Errorf("findings must survive a persist failure: %+v", report.Findings) + } +} + +func TestVulnClassID_FallbackChain(t *testing.T) { + cases := []struct { + name string + f security.Finding + want string + }{ + {"cwe-from-ruleid", security.Finding{RuleID: "CWE-89"}, "CWE-89"}, + {"cwe-from-detail", security.Finding{RuleID: "G304", Detail: "path traversal CWE-22 via input"}, "CWE-22"}, + {"category-when-no-cwe", security.Finding{RuleID: "", Category: "Insecure Design"}, "Insecure Design"}, + {"tool-rule-when-nothing-else", security.Finding{Tool: "semgrep", RuleID: "custom.rule"}, "semgrep:custom.rule"}, + {"empty-when-unclassifiable", security.Finding{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := vulnClassID(tc.f); got != tc.want { + t.Errorf("vulnClassID(%+v) = %q, want %q", tc.f, got, tc.want) + } + }) + } +} + +func TestCweOf_ExtractsAndBounds(t *testing.T) { + cases := []struct { + in security.Finding + want string + }{ + {security.Finding{RuleID: "CWE-798"}, "CWE-798"}, + {security.Finding{Detail: "maps to CWE-89: injection"}, "CWE-89"}, + {security.Finding{Category: "A03 CWE-79 XSS"}, "CWE-79"}, + {security.Finding{Detail: "CWE- with no digits"}, ""}, + {security.Finding{Title: "CWE-1 in title is ignored (title not scanned)"}, ""}, + } + for _, tc := range cases { + if got := cweOf(tc.in); got != tc.want { + t.Errorf("cweOf(%+v) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestParseLLMFindings_WrappedAndInvalid(t *testing.T) { + if got := parseLLMFindings([]byte("")); got != nil { + t.Errorf("empty response ⇒ nil, got %v", got) + } + if got := parseLLMFindings([]byte("no json here at all")); got != nil { + t.Errorf("prose-only response ⇒ nil, got %v", got) + } + // Fence-wrapped array must still parse (extractJSON handles the wrapping). + wrapped := "Here are my findings:\n```json\n" + llmCriticalFinding + "\n```\nLet me know." + got := parseLLMFindings([]byte(wrapped)) + if len(got) != 1 || got[0].RuleID != "CWE-89" || got[0].Source != "llm" { + t.Errorf("fence-wrapped findings must parse: %+v", got) + } + // A JSON object (not array) is a shape mismatch ⇒ nil, logged, no crash. + if got := parseLLMFindings([]byte(`{"severity":"high"}`)); got != nil { + t.Errorf("non-array JSON ⇒ nil, got %v", got) + } +} diff --git a/internal/engine/security_gate_test.go b/internal/engine/security_gate_test.go index cbaa602..450583a 100644 --- a/internal/engine/security_gate_test.go +++ b/internal/engine/security_gate_test.go @@ -36,14 +36,14 @@ func countEvents(t *testing.T, es state.EventStore, typ state.EventType) int { return len(evts) } -// fakeScan returns a seam that yields canned findings. -func fakeScan(findings ...security.Finding) func(context.Context, string) ([]security.Finding, []security.ScannerKind, []security.ScannerKind) { - return func(context.Context, string) ([]security.Finding, []security.ScannerKind, []security.ScannerKind) { - return findings, []security.ScannerKind{security.ScannerGosec}, []security.ScannerKind{security.ScannerSemgrep} +// fakeScan returns a seam that yields canned findings (no failed scanners). +func fakeScan(findings ...security.Finding) scanFunc { + return func(context.Context, string) ([]security.Finding, []security.ScannerKind, []security.ScannerKind, []security.ScannerKind) { + return findings, []security.ScannerKind{security.ScannerGosec}, []security.ScannerKind{security.ScannerSemgrep}, nil } } -func newTestSecurityGate(t *testing.T, client llm.Client, kbPath string, gateSev security.Severity, autoLearn bool, scan func(context.Context, string) ([]security.Finding, []security.ScannerKind, []security.ScannerKind)) *SecurityGate { +func newTestSecurityGate(t *testing.T, client llm.Client, kbPath string, gateSev security.Severity, autoLearn bool, scan scanFunc) *SecurityGate { es, ps := newSecurityTestStores(t) g := NewSecurityGate(client, "test-model", 1000, kbPath, gateSev, autoLearn, es, ps) g.scan = scan @@ -67,6 +67,35 @@ func TestSecurityGate_ScanRepo_AggregatesAndEmits(t *testing.T) { } } +// A scanner that ran but FAILED (timeout / crash / parse error) must be +// surfaced in the report's Failed list, never silently dropped or counted as a +// clean run. Otherwise a build whose scan partly failed looks scanned-clean. +func TestSecurityGate_ScanRepo_SurfacesFailedScanners(t *testing.T) { + kbPath := filepath.Join(t.TempDir(), "kb.json") + // Seam: gosec ran clean, semgrep was not installed, gitleaks ran but errored. + scan := func(context.Context, string) ([]security.Finding, []security.ScannerKind, []security.ScannerKind, []security.ScannerKind) { + return nil, + []security.ScannerKind{security.ScannerGosec}, + []security.ScannerKind{security.ScannerSemgrep}, + []security.ScannerKind{security.ScannerGitleaks} + } + g := newTestSecurityGate(t, nil, kbPath, security.SeverityHigh, false, scan) + + report, err := g.ScanRepo(context.Background(), t.TempDir()) + if err != nil { + t.Fatalf("ScanRepo: %v", err) + } + if len(report.Failed) != 1 || report.Failed[0] != security.ScannerGitleaks { + t.Errorf("expected gitleaks in report.Failed, got %v", report.Failed) + } + // A failed scanner must NOT be mislabeled as a clean run. + for _, k := range report.ScannersRun { + if k == security.ScannerGitleaks { + t.Errorf("failed scanner gitleaks must not appear in ScannersRun: %v", report.ScannersRun) + } + } +} + func TestSecurityGate_ReviewStory_BlocksOnCritical(t *testing.T) { kbPath := filepath.Join(t.TempDir(), "kb.json") crit := security.Finding{Tool: "gosec", RuleID: "G101", Severity: security.SeverityCritical, File: "a.go", Line: 2, Title: "hardcoded creds", Source: "scanner"} diff --git a/internal/engine/svg_docs.go b/internal/engine/svg_docs.go new file mode 100644 index 0000000..05a75d9 --- /dev/null +++ b/internal/engine/svg_docs.go @@ -0,0 +1,253 @@ +package engine + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +// Factory rule: architecture and sequence diagrams ship as REAL rendered SVG +// (valid XML that GitHub renders inline), never as Mermaid code fences or +// .mmd files. The scribe story instructs the coding agent to honour this, but +// agents frequently emit a ```mermaid block anyway. This post-merge loop is the +// deterministic backstop: it generates each diagram, validates it as well-formed +// SVG, and retries with the validation error fed back until it is valid — so a +// completed requirement always leaves renderable SVG diagrams, by construction. + +// svgMaxAttempts bounds the validate-and-retry loop per diagram. +const svgMaxAttempts = 3 + +// diagramSpec describes one SVG diagram the factory guarantees. +type diagramSpec struct { + filename string // path relative to repo root, e.g. "docs/architecture.svg" + kind string // "architecture" | "sequence" + brief string // what the diagram must depict +} + +// factoryDiagrams is the standard diagram set every project documents. +var factoryDiagrams = []diagramSpec{ + { + filename: "docs/architecture.svg", + kind: "architecture", + brief: "the system architecture: the main components/packages/services and how they depend on each other and on external systems", + }, + { + filename: "docs/sequence.svg", + kind: "sequence", + brief: "a sequence diagram of the primary end-to-end user flow, showing the actors/components as lifelines and the ordered messages between them", + }, +} + +// mermaidSignatures are tokens that betray a Mermaid diagram masquerading as a +// diagram file. Their presence means the model ignored the SVG requirement. +var mermaidSignatures = []string{ + "```", "graph td", "graph lr", "graph rl", "graph bt", + "flowchart", "sequencediagram", "classdiagram", "erdiagram", + "statediagram", "gantt", "journey", "mermaid", +} + +// validateSVG enforces the factory rule: content must be a single, well-formed +// SVG document (renders on GitHub), not a Mermaid fence or arbitrary text. It is +// a pure function — no I/O — so the boundary is unit-pinned. +func validateSVG(content string) error { + s := strings.TrimSpace(content) + if s == "" { + return fmt.Errorf("empty content") + } + + lower := strings.ToLower(s) + for _, sig := range mermaidSignatures { + if strings.Contains(lower, sig) { + return fmt.Errorf("contains non-SVG/Mermaid signature %q — diagrams must be rendered SVG, not Mermaid or fenced code", sig) + } + } + + if !strings.HasPrefix(s, "") { + return fmt.Errorf("missing root element") + } + if !strings.Contains(lower, "xmlns") { + return fmt.Errorf("missing xmlns namespace — GitHub will not render an SVG without it") + } + + // Well-formedness + the document element must be . + dec := xml.NewDecoder(strings.NewReader(s)) + sawSVGRoot := false + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("not well-formed XML: %w", err) + } + if start, ok := tok.(xml.StartElement); ok && !sawSVGRoot { + if strings.ToLower(start.Name.Local) != "svg" { + return fmt.Errorf("root element is <%s>, must be ", start.Name.Local) + } + sawSVGRoot = true + } + } + if !sawSVGRoot { + return fmt.Errorf("no start element found") + } + return nil +} + +// firstRunes returns up to n runes of s for compact error messages. +func firstRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +// extractSVG pulls an SVG document out of a model response that may be wrapped +// in prose or code fences. Returns the substring from the first . Empty if no SVG is present. +func extractSVG(resp string) string { + s := stripMarkdownFences(resp) + lower := strings.ToLower(s) + open := strings.Index(lower, "= 0 { + open = decl + } + closeTag := strings.LastIndex(lower, "") + if closeTag < 0 { + return strings.TrimSpace(s[open:]) + } + return strings.TrimSpace(s[open : closeTag+len("")]) +} + +// generateSVGDiagram asks the model for one diagram and loops, feeding the +// validation error back, until the output is valid SVG or attempts run out. +func generateSVGDiagram(ctx context.Context, client llm.Client, model string, spec diagramSpec, reqTitle, projectInfo, fileTree string) (string, error) { + base := fmt.Sprintf(`Author a %s diagram for a software project as a single, self-contained, rendered SVG file. + +The diagram must depict: %s. + +PROJECT: %s + +PROJECT MANIFEST (truncated): +%s + +FILE TREE (truncated): +%s + +HARD REQUIREMENTS — this is a software-factory rule: +- Output ONLY raw SVG XML. Start with (an optional declaration first is fine) and end with . +- It MUST be valid, well-formed XML that GitHub renders inline. +- NO Markdown, NO code fences, NO Mermaid syntax, NO commentary before or after. +- Draw the boxes, arrows, lifelines and labels yourself with , , , , etc. Use a readable layout, a viewBox, sensible width/height, and legible labels grounded in the actual components above (do not invent components that aren't in the manifest/tree). +- Keep it self-contained: no external images, fonts, or scripts.`, + spec.kind, spec.brief, reqTitle, + truncateForPrompt(projectInfo, 1500), + truncateForPrompt(fileTree, 1800), + ) + + prompt := base + var lastErr error + for attempt := 1; attempt <= svgMaxAttempts; attempt++ { + resp, err := client.Complete(ctx, llm.CompletionRequest{ + Model: model, + Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, + MaxTokens: 8000, + }) + if err != nil { + return "", fmt.Errorf("llm error on attempt %d: %w", attempt, err) + } + svg := extractSVG(resp.Content) + if verr := validateSVG(svg); verr == nil { + return svg, nil + } else { + lastErr = verr + // Feed the exact failure back so the next attempt is corrective. + prompt = fmt.Sprintf(`%s + +Your previous attempt was REJECTED: %v + +Previous output began with: +%s + +Return ONLY a corrected, valid, self-contained SVG document (no Mermaid, no code fences, no prose).`, + base, verr, firstRunes(strings.TrimSpace(resp.Content), 200)) + } + } + return "", fmt.Errorf("could not produce valid SVG after %d attempts: %w", svgMaxAttempts, lastErr) +} + +// generateProjectDiagrams writes docs/architecture.svg and docs/sequence.svg +// into repoDir, generating valid SVG for any that are missing or invalid (e.g. +// the scribe agent left a Mermaid file). Returns the relative paths written. +func generateProjectDiagrams(ctx context.Context, repoDir, reqTitle, fileTree, projectInfo string, client llm.Client, model string) []string { + var written []string + for _, spec := range factoryDiagrams { + abs := filepath.Join(repoDir, spec.filename) + + // Skip if a valid SVG already exists (agent honoured the rule). + if existing, err := os.ReadFile(abs); err == nil { + if validateSVG(string(existing)) == nil { + log.Printf("[docs] %s already valid SVG, keeping", spec.filename) + continue + } + log.Printf("[docs] %s exists but is not valid SVG — regenerating", spec.filename) + } + + svg, err := generateSVGDiagram(ctx, client, model, spec, reqTitle, projectInfo, fileTree) + if err != nil { + log.Printf("[docs] failed to generate %s: %v", spec.filename, err) + continue + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + log.Printf("[docs] mkdir for %s failed: %v", spec.filename, err) + continue + } + if err := os.WriteFile(abs, []byte(svg+"\n"), 0o644); err != nil { + log.Printf("[docs] write %s failed: %v", spec.filename, err) + continue + } + log.Printf("[docs] wrote %s (%d bytes)", spec.filename, len(svg)) + written = append(written, spec.filename) + } + return written +} + +// diagramsReadmeBlock is the scribe-marked README section linking the SVGs. It +// is inserted only when the README does not already reference the diagrams, so +// hand-written architecture prose is never clobbered. +func diagramsReadmeBlock() string { + return "\n\n## Architecture\n\n" + + "![Architecture](docs/architecture.svg)\n\n" + + "### Primary flow\n\n" + + "![Sequence](docs/sequence.svg)\n" + + "\n" +} + +// ensureReadmeReferencesDiagrams appends the diagram block to README content if +// it does not already link the SVGs. Pure string transform — caller writes it. +func ensureReadmeReferencesDiagrams(readme string, written []string) string { + if len(written) == 0 { + return readme + } + if strings.Contains(readme, "docs/architecture.svg") || strings.Contains(readme, "docs/sequence.svg") { + return readme + } + if strings.Contains(readme, "nxd:diagrams:start") { + return readme + } + return strings.TrimRight(readme, "\n") + "\n" + diagramsReadmeBlock() +} diff --git a/internal/engine/svg_docs_test.go b/internal/engine/svg_docs_test.go new file mode 100644 index 0000000..050d1c3 --- /dev/null +++ b/internal/engine/svg_docs_test.go @@ -0,0 +1,160 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tzone85/nexus-dispatch/internal/llm" +) + +const validArchSVG = `` + + `` + + `CLI` + + `` + + `` + +func TestValidateSVG(t *testing.T) { + cases := []struct { + name string + in string + wantErr bool + }{ + {"valid svg", validArchSVG, false}, + {"valid with xml decl", `` + validArchSVG, false}, + {"empty", " ", true}, + {"mermaid fence", "```mermaid\ngraph TD\nA-->B\n```", true}, + {"raw mermaid", "sequenceDiagram\nA->>B: hi", true}, + {"flowchart keyword", "flowchart LR\nA-->B", true}, + {"plain prose", "Here is your architecture diagram.", true}, + {"svg without xmlns", ``, true}, + {"malformed xml", ``, true}, + {"non-svg root", ``, true}, + {"contains code fence", "```\n" + validArchSVG + "\n```", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validateSVG(c.in) + if c.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !c.wantErr && err != nil { + t.Fatalf("expected nil, got %v", err) + } + }) + } +} + +func TestExtractSVG(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"bare", validArchSVG, validArchSVG}, + {"fenced", "```xml\n" + validArchSVG + "\n```", validArchSVG}, + {"prose around", "Sure! Here:\n" + validArchSVG + "\nHope that helps.", validArchSVG}, + {"no svg", "no diagram here", "no diagram here"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := extractSVG(c.in); got != c.want { + t.Fatalf("extractSVG = %q, want %q", got, c.want) + } + }) + } +} + +// The retry loop must reject an invalid (Mermaid) first response, feed the +// error back, and accept the corrected SVG on the next attempt. +func TestGenerateSVGDiagram_RetriesUntilValid(t *testing.T) { + client := llm.NewReplayClient( + llm.CompletionResponse{Content: "```mermaid\ngraph TD\nA-->B\n```"}, + llm.CompletionResponse{Content: validArchSVG}, + ) + got, err := generateSVGDiagram(context.Background(), client, "m", factoryDiagrams[0], "vatkit", "{}", "cmd/\ninternal/") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != validArchSVG { + t.Fatalf("got %q", got) + } + if client.CallCount() != 2 { + t.Fatalf("expected 2 attempts, got %d", client.CallCount()) + } + // The corrective re-prompt must carry the validation failure back. + if !strings.Contains(client.CallAt(1).Messages[0].Content, "REJECTED") { + t.Fatal("retry prompt did not feed the validation error back") + } +} + +func TestGenerateSVGDiagram_ExhaustsAttempts(t *testing.T) { + client := llm.NewReplayClient( + llm.CompletionResponse{Content: "```mermaid\nA"}, + llm.CompletionResponse{Content: "still mermaid graph TD"}, + llm.CompletionResponse{Content: "nope"}, + ) + if _, err := generateSVGDiagram(context.Background(), client, "m", factoryDiagrams[1], "x", "{}", ""); err == nil { + t.Fatal("expected error after exhausting attempts") + } +} + +func TestGenerateProjectDiagrams_WritesValidFiles(t *testing.T) { + dir := t.TempDir() + client := llm.NewReplayClient( + llm.CompletionResponse{Content: validArchSVG}, + llm.CompletionResponse{Content: validArchSVG}, + ) + written := generateProjectDiagrams(context.Background(), dir, "proj", "tree", "{}", client, "m") + if len(written) != 2 { + t.Fatalf("expected 2 diagrams written, got %d (%v)", len(written), written) + } + for _, rel := range []string{"docs/architecture.svg", "docs/sequence.svg"} { + data, err := os.ReadFile(filepath.Join(dir, rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + if err := validateSVG(string(data)); err != nil { + t.Fatalf("%s not valid svg: %v", rel, err) + } + } +} + +// A pre-existing VALID svg must be left untouched (no LLM call consumed). +func TestGenerateProjectDiagrams_KeepsExistingValid(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/architecture.svg"), []byte(validArchSVG), 0o644); err != nil { + t.Fatal(err) + } + // Only ONE response available — enough for sequence.svg only. If the loop + // tried to regenerate the existing valid architecture.svg it would exhaust + // the client and fail to write sequence.svg. + client := llm.NewReplayClient(llm.CompletionResponse{Content: validArchSVG}) + written := generateProjectDiagrams(context.Background(), dir, "proj", "tree", "{}", client, "m") + if len(written) != 1 || written[0] != "docs/sequence.svg" { + t.Fatalf("expected only sequence.svg regenerated, got %v", written) + } +} + +func TestEnsureReadmeReferencesDiagrams(t *testing.T) { + written := []string{"docs/architecture.svg", "docs/sequence.svg"} + + got := ensureReadmeReferencesDiagrams("# Title\n\nSome prose.", written) + if !strings.Contains(got, "docs/architecture.svg") || !strings.Contains(got, "docs/sequence.svg") { + t.Fatal("expected diagram links appended") + } + + already := "# Title\n\n![Architecture](docs/architecture.svg)\n" + if got := ensureReadmeReferencesDiagrams(already, written); got != already { + t.Fatal("must not duplicate when already referenced") + } + + if got := ensureReadmeReferencesDiagrams("# Title", nil); got != "# Title" { + t.Fatal("no diagrams written → README unchanged") + } +} diff --git a/internal/engine/verification_loop.go b/internal/engine/verification_loop.go new file mode 100644 index 0000000..518fc7d --- /dev/null +++ b/internal/engine/verification_loop.go @@ -0,0 +1,320 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// VerificationResult holds the outcome of a post-completion verification cycle. +type VerificationResult struct { + BuildPasses bool + TestsPassing int + TestsFailing int + TestsTotal int + Gaps []VerificationGap + CleanArtifacts bool + DepsInstalled bool +} + +// VerificationGap describes a specific issue found during verification. +type VerificationGap struct { + Category string // "build", "test", "wiring", "hallucination", "artifact", "dependency", "documentation" + Severity string // "critical", "high", "medium", "low" + File string + Detail string +} + +// RunVerificationLoop executes the post-completion verification cycle. +// It checks build, tests, artifacts, hallucinations, and dependency state. +// Returns gaps found. If gaps exist, they can be fed back as a new requirement. +// +// This implements the evaluate → fix → verify loop: +// +// Cycle 1: Full verification after all stories merge +// Cycle 2: Confirmation pass after fixes (lighter, just build + tests) +func RunVerificationLoop(ctx context.Context, repoDir string, cycle int) VerificationResult { + log.Printf("[verify] starting verification cycle %d for %s", cycle, filepath.Base(repoDir)) + + result := VerificationResult{} + + // Step 1: Ensure dependencies are installed + result.DepsInstalled = ensureDependencies(repoDir) + + // Step 2: Check build + result.BuildPasses = checkBuild(repoDir) + + // Step 3: Run tests + result.TestsPassing, result.TestsFailing, result.TestsTotal = checkTests(repoDir) + + // Step 4: Scan for hallucination artifacts + hallucinations := scanForHallucinations(repoDir) + for _, h := range hallucinations { + result.Gaps = append(result.Gaps, VerificationGap{ + Category: "hallucination", + Severity: "critical", + File: h, + Detail: "LLM reasoning text found in source file", + }) + } + + // Step 5: Check for merge conflict markers + conflicts := validateNoConflictMarkers(repoDir) + for _, c := range conflicts { + result.Gaps = append(result.Gaps, VerificationGap{ + Category: "wiring", + Severity: "critical", + File: c, + Detail: "Unresolved merge conflict markers", + }) + } + + // Step 6: Clean NXD workspace artifacts + result.CleanArtifacts = cleanWorkspaceArtifacts(repoDir) + + // Step 7: Check for missing README + if _, err := os.Stat(filepath.Join(repoDir, "README.md")); os.IsNotExist(err) { + result.Gaps = append(result.Gaps, VerificationGap{ + Category: "documentation", + Severity: "medium", + File: "README.md", + Detail: "No README.md found — project needs documentation", + }) + } + + // Log summary + log.Printf("[verify] cycle %d complete: build=%v, tests=%d/%d passing, gaps=%d", + cycle, result.BuildPasses, result.TestsPassing, result.TestsTotal, len(result.Gaps)) + + for _, g := range result.Gaps { + log.Printf("[verify] gap [%s/%s] %s: %s", g.Category, g.Severity, g.File, g.Detail) + } + + return result +} + +// ensureDependencies runs the appropriate install command for the project. +func ensureDependencies(repoDir string) bool { + if buildFileExists(filepath.Join(repoDir, "package.json")) { + log.Printf("[verify] running npm install ...") + cmd := exec.Command("npm", "install") + cmd.Dir = repoDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Printf("[verify] npm install failed: %v", err) + return false + } + return true + } + if buildFileExists(filepath.Join(repoDir, "go.mod")) { + cmd := exec.Command("go", "mod", "download") + cmd.Dir = repoDir + if err := cmd.Run(); err != nil { + log.Printf("[verify] go mod download failed: %v", err) + return false + } + return true + } + return true // no deps to install +} + +// checkBuild attempts to build the project. +func checkBuild(repoDir string) bool { + if err := validateBuild(context.Background(), repoDir); err != nil { + log.Printf("[verify] build failed: %v", err) + return false + } + log.Printf("[verify] build passed") + return true +} + +// checkTests runs the project's test suite and returns pass/fail/total counts. +func checkTests(repoDir string) (passing, failing, total int) { + var cmd *exec.Cmd + + if buildFileExists(filepath.Join(repoDir, "package.json")) { + cmd = exec.Command("npx", "jest", "--passWithNoTests", "--json") + // Also try vitest + if buildFileExists(filepath.Join(repoDir, "vitest.config.ts")) || buildFileExists(filepath.Join(repoDir, "vitest.config.js")) { + cmd = exec.Command("npx", "vitest", "run", "--reporter=json") + } + } else if buildFileExists(filepath.Join(repoDir, "go.mod")) { + cmd = exec.Command("go", "test", "-count=1", "-json", "./...") + } + + if cmd == nil { + log.Printf("[verify] no test framework detected") + return 0, 0, 0 + } + + cmd.Dir = repoDir + out, _ := cmd.CombinedOutput() + output := string(out) + + if buildFileExists(filepath.Join(repoDir, "go.mod")) { + return parseGoTestJSON(output) + } + + // Parse test results (simplified — count PASS/FAIL lines) + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "\"numPassedTests\"") || strings.Contains(line, "PASS:") { + passing++ + } + if strings.Contains(line, "\"numFailedTests\"") || strings.Contains(line, "FAIL:") { + failing++ + } + } + + // Fallback: parse Jest summary line + if strings.Contains(output, "Tests:") { + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Tests:") && strings.Contains(line, "passed") { + // Parse "Tests: X failed, Y passed, Z total" + _, _ = fmt.Sscanf(line, "Tests: %d failed, %d passed, %d total", &failing, &passing, &total) // partial parse keeps zero counters + break + } + } + } + + total = passing + failing + log.Printf("[verify] tests: %d passing, %d failing, %d total", passing, failing, total) + return passing, failing, total +} + +func parseGoTestJSON(output string) (passing, failing, total int) { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var evt struct { + Action string `json:"Action"` + Test string `json:"Test"` + } + if err := json.Unmarshal([]byte(line), &evt); err != nil || evt.Test == "" { + continue + } + switch evt.Action { + case "pass": + passing++ + case "fail": + failing++ + } + } + total = passing + failing + log.Printf("[verify] tests: %d passing, %d failing, %d total", passing, failing, total) + return passing, failing, total +} + +// scanForHallucinations checks all source files for LLM preamble text. +func scanForHallucinations(repoDir string) []string { + var found []string + //nolint:errcheck // best-effort hallucination scan; callback handles per-path errors + filepath.Walk(repoDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + // Skip non-source files and common dirs + rel, _ := filepath.Rel(repoDir, path) + if strings.Contains(rel, "node_modules") || strings.Contains(rel, ".git") || + strings.Contains(rel, "dist") || strings.Contains(rel, "build") { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if !isSourceExt(ext) { + return nil + } + data, err := os.ReadFile(path) // #nosec G122 G304 -- best-effort read-only scan of the local repo tree; a TOCTOU race only skips a file + if err != nil { + return nil + } + firstLine := strings.TrimSpace(strings.SplitN(string(data), "\n", 2)[0]) + if isHallucinationLine(firstLine) { + found = append(found, rel) + } + return nil + }) + return found +} + +// cleanWorkspaceArtifacts removes NXD temporary files from the repo. +func cleanWorkspaceArtifacts(repoDir string) bool { + artifacts := []string{"WAVE_CONTEXT.md", "REQUIREMENT.md", ".nxd-prompts"} + cleaned := false + for _, name := range artifacts { + path := filepath.Join(repoDir, name) + if info, err := os.Stat(path); err == nil { + if info.IsDir() { + _ = os.RemoveAll(path) // best-effort artifact cleanup + } else { + _ = os.Remove(path) // best-effort artifact cleanup + } + log.Printf("[verify] removed artifact: %s", name) + cleaned = true + } + } + if cleaned { + // Commit the cleanup + addCmd := exec.Command("git", "add", "-A") + addCmd.Dir = repoDir + _ = addCmd.Run() // best-effort; commit below covers the failure case + commitCmd := exec.Command("git", "commit", "-m", "chore: clean NXD workspace artifacts") + commitCmd.Dir = repoDir + _ = commitCmd.Run() // best-effort cleanup commit + } + return !cleaned // true means already clean +} + +// GapsToRequirement converts verification gaps into a follow-up requirement +// that NXD can process to fix the issues. +func GapsToRequirement(gaps []VerificationGap, projectName string) string { + if len(gaps) == 0 { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "# Fix Verification Gaps in %s\n\n", projectName) + b.WriteString("The following issues were found during post-completion verification.\n") + b.WriteString("Each must be fixed and verified.\n\n") + + for i, g := range gaps { + fmt.Fprintf(&b, "## %d. [%s] %s\n", i+1, strings.ToUpper(g.Severity), g.Detail) + if g.File != "" { + fmt.Fprintf(&b, "**File:** `%s`\n", g.File) + } + fmt.Fprintf(&b, "**Category:** %s\n\n", g.Category) + } + + b.WriteString("## Acceptance Criteria\n") + b.WriteString("- All gaps resolved\n") + b.WriteString("- Build passes\n") + b.WriteString("- Tests pass (or test failures documented as pre-existing)\n") + b.WriteString("- No hallucination text in source files\n") + b.WriteString("- No merge conflict markers\n") + b.WriteString("- README.md exists and is up to date\n") + + return b.String() +} + +// ShouldRunFixCycle determines if a second NXD dispatch is needed based on +// verification results. +func ShouldRunFixCycle(result VerificationResult) bool { + if !result.BuildPasses { + return true + } + if result.TestsFailing > 0 { + return true + } + for _, g := range result.Gaps { + if g.Severity == "critical" || g.Severity == "high" { + return true + } + } + return false +} diff --git a/internal/engine/wiring_gemma_test.go b/internal/engine/wiring_gemma_test.go index d4e90ce..e23066d 100644 --- a/internal/engine/wiring_gemma_test.go +++ b/internal/engine/wiring_gemma_test.go @@ -131,6 +131,8 @@ func TestWiring_PlannerToolCalling(t *testing.T) { }) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := NewPlanner(client, cfg, es, ps) result, err := planner.Plan(context.Background(), "r-100", "Build auth system", repoDir) diff --git a/internal/engine/wiring_test.go b/internal/engine/wiring_test.go index b9f3847..64af769 100644 --- a/internal/engine/wiring_test.go +++ b/internal/engine/wiring_test.go @@ -232,13 +232,13 @@ func TestWiring_GreenfieldNoPlaybooks(t *testing.T) { // Markers from each playbook that should NOT appear playBookMarkers := []string{ - "REPRODUCE", // BugHuntingMethodology - "NEVER rewrite", // LegacyCodeSurvival + "REPRODUCE", // BugHuntingMethodology + "NEVER rewrite", // LegacyCodeSurvival "INFRASTRUCTURE DEBUGGING TOOLKIT", // InfrastructureDebugging - "Investigation Report", // CodebaseArchaeology - "BEFORE PLANNING", // CodebaseArchaeology - "BUG HUNTING METHODOLOGY", // BugHuntingMethodology - "LEGACY CODE SURVIVAL GUIDE", // LegacyCodeSurvival + "Investigation Report", // CodebaseArchaeology + "BEFORE PLANNING", // CodebaseArchaeology + "BUG HUNTING METHODOLOGY", // BugHuntingMethodology + "LEGACY CODE SURVIVAL GUIDE", // LegacyCodeSurvival } for _, role := range roles { @@ -443,6 +443,8 @@ func TestWiring_PlannerProducesStories(t *testing.T) { es, ps := newTestStores(t) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := NewPlanner(client, cfg, es, ps) result, err := planner.Plan(context.Background(), "req-100", "Build a REST API", dir) @@ -778,6 +780,8 @@ func TestWiring_OverlappingFilesWarnsForParallel(t *testing.T) { es, ps := newTestStores(t) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false planner := NewPlanner(client, cfg, es, ps) // Should succeed (warning logged, not error) diff --git a/internal/security/coverage_gaps_test.go b/internal/security/coverage_gaps_test.go new file mode 100644 index 0000000..2043e90 --- /dev/null +++ b/internal/security/coverage_gaps_test.go @@ -0,0 +1,241 @@ +package security + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Covers is the self-upskilling dedup: it decides whether the agent re-learns a +// vulnerability class it already ships guidance for. These cases pin the three +// match modes (rule ID, CWE alias, miss). +func TestCovers_RuleIDAndCWEAlias(t *testing.T) { + kb := BaselineKnowledgeBase() + + if !kb.Covers("A03:2021") { + t.Error("must cover by exact rule ID") + } + // A03's CWE field is CWE-89 — a finding classed as CWE-89 is already covered + // even though no rule has that ID... except the standalone CWE rules do. + if !kb.Covers("CWE-89") { + t.Error("must cover a class via a rule's CWE field") + } + if !kb.Covers("CWE-918") { + t.Error("SSRF is baseline (A10's CWE) and must be covered") + } + if kb.Covers("CWE-999999") { + t.Error("unknown class must NOT be covered — that is what triggers learning") + } + if kb.Covers("") { + t.Error("empty class id must not match anything (rules without CWE must not alias it)") + } +} + +func TestCovers_LearnedRuleExtendsCoverage(t *testing.T) { + kb := BaselineKnowledgeBase() + if kb.Covers("CWE-1333") { + t.Fatal("precondition: ReDoS not in baseline") + } + grown := kb.Add(VulnRule{ID: "CWE-1333", CWE: "CWE-1333", Title: "ReDoS", Detection: "x", Remediation: "y", Source: RuleLearned}) + if !grown.Covers("CWE-1333") { + t.Error("a learned rule must extend coverage") + } + if kb.Covers("CWE-1333") { + t.Error("Add must not mutate the receiver") + } +} + +func TestSave_ParentDirCreationFailure(t *testing.T) { + // A regular file where the parent dir should be makes MkdirAll fail. + obstruction := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(obstruction, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + kb := BaselineKnowledgeBase() + err := kb.Save(filepath.Join(obstruction, "sub", "knowledge.json")) + if err == nil || !strings.Contains(err.Error(), "create knowledge dir") { + t.Errorf("want create-dir error, got %v", err) + } +} + +func TestSave_WriteFailure(t *testing.T) { + // Path IS a directory — WriteFile fails after MkdirAll succeeds. + dir := t.TempDir() + kb := BaselineKnowledgeBase() + err := kb.Save(dir) + if err == nil || !strings.Contains(err.Error(), "write knowledge base") { + t.Errorf("want write error, got %v", err) + } +} + +func TestLoadKnowledgeBase_CorruptJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "knowledge.json") + if err := os.WriteFile(path, []byte("{corrupt"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadKnowledgeBase(path); err == nil || !strings.Contains(err.Error(), "parse knowledge base") { + t.Errorf("corrupt KB must be a loud error, never silently replaced by the baseline: %v", err) + } +} + +func TestLoadKnowledgeBase_ReadError(t *testing.T) { + // Path is a directory: ReadFile errors but with something other than + // IsNotExist — must be surfaced, not treated as first-run. + if _, err := LoadKnowledgeBase(t.TempDir()); err == nil || !strings.Contains(err.Error(), "read knowledge base") { + t.Errorf("non-ENOENT read failure must be surfaced: %v", err) + } +} + +func TestDetectLanguages_ManifestSignals(t *testing.T) { + cases := []struct { + name string + files map[string]string + want []string + }{ + {"rust", map[string]string{"Cargo.toml": ""}, []string{"rust"}}, + {"php", map[string]string{"composer.json": "{}"}, []string{"php"}}, + {"ruby", map[string]string{"Gemfile": ""}, []string{"ruby"}}, + {"python", map[string]string{"pyproject.toml": ""}, []string{"python"}}, + {"js-without-tsconfig", map[string]string{"package.json": "{}"}, []string{"javascript"}}, + {"ts-beats-js", map[string]string{"package.json": "{}", "tsconfig.json": "{}"}, []string{"typescript"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DetectLanguages(seedRepo(t, tc.files)) + if len(got) != len(tc.want) { + t.Fatalf("want %v, got %v", tc.want, got) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("want %v, got %v", tc.want, got) + } + } + }) + } +} + +func TestDetectLanguages_ExtensionFallbackAndSkips(t *testing.T) { + repo := t.TempDir() + // A .ts source with no manifest establishes typescript by extension; a .js + // file must then NOT add javascript (the ts/js distinction is respected). + // node_modules content must be skipped entirely. + mustWrite := func(rel, content string) { + t.Helper() + p := filepath.Join(repo, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + mustWrite("tsconfig.json", "{}") + mustWrite("package.json", "{}") + mustWrite("src/app.js", "x") // must not add javascript (ts already set) + mustWrite("deploy.sh", "#!/bin/sh") // shell via extension + mustWrite("node_modules/dep/index.rb", "puts") // must be skipped, no ruby + + got := DetectLanguages(repo) + set := map[string]bool{} + for _, l := range got { + set[l] = true + } + if !set["typescript"] || !set["shell"] { + t.Errorf("want typescript+shell, got %v", got) + } + if set["javascript"] { + t.Errorf("javascript must not be added when typescript is established: %v", got) + } + if set["ruby"] { + t.Errorf("node_modules must be skipped: %v", got) + } +} + +func TestParsers_InvalidJSONIsAnError(t *testing.T) { + garbage := []byte("PANIC not json") + if _, err := parseGosec(garbage, "/repo"); err == nil { + t.Error("parseGosec must surface invalid JSON") + } + if _, err := parseGitleaks(garbage, "/repo"); err == nil { + t.Error("parseGitleaks must surface invalid JSON") + } + if _, err := parseSemgrep(garbage, "/repo"); err == nil { + t.Error("parseSemgrep must surface invalid JSON") + } + if _, err := parseNpmAudit(garbage); err == nil { + t.Error("parseNpmAudit must surface invalid JSON") + } +} + +func TestParseGosec_LineRangeAndMissingCWE(t *testing.T) { + out := []byte(`{"Issues":[{"severity":"MEDIUM","rule_id":"G304","details":"x","file":"a.go","line":"12-14","cwe":{"id":""}}]}`) + fs, err := parseGosec(out, "/repo") + if err != nil || len(fs) != 1 { + t.Fatalf("parse: %v %v", fs, err) + } + if fs[0].Line != 12 { + t.Errorf("range line must take the start, got %d", fs[0].Line) + } + if fs[0].Detail != "" { + t.Errorf("empty CWE id must not render as 'CWE-', got %q", fs[0].Detail) + } +} + +func TestParseNpmAudit_FallsBackToMapKeyForName(t *testing.T) { + out := []byte(`{"vulnerabilities":{"left-pad":{"name":"","severity":"low","range":"*","via":[]}}}`) + fs, err := parseNpmAudit(out) + if err != nil || len(fs) != 1 { + t.Fatalf("parse: %v %v", fs, err) + } + if fs[0].RuleID != "npm:left-pad" { + t.Errorf("empty name must fall back to the map key, got %q", fs[0].RuleID) + } +} + +func TestParseGovulncheck_MalformedLinesSkipped(t *testing.T) { + out := []byte("Vulnerability #1 without colon\nVulnerability #2: \nVulnerability #3: GO-2025-999\n") + fs, err := parseGovulncheck(out) + if err != nil { + t.Fatal(err) + } + if len(fs) != 1 || fs[0].RuleID != "GO-2025-999" { + t.Errorf("malformed lines must be skipped, valid ones kept: %+v", fs) + } +} + +func TestSeverityString_AllValues(t *testing.T) { + want := map[Severity]string{ + SeverityCritical: "critical", + SeverityHigh: "high", + SeverityMedium: "medium", + SeverityLow: "low", + SeverityInfo: "info", + Severity(42): "info", // unknown ranks must not invent labels + } + for sev, label := range want { + if got := sev.String(); got != label { + t.Errorf("Severity(%d).String() = %q, want %q", sev, got, label) + } + } +} + +func TestFormatMarkdown_EmptyReportSaysNone(t *testing.T) { + r := Report{RepoDir: "/x", Languages: []string{"go"}, KBVersion: 1} + md := r.FormatMarkdown() + if !strings.Contains(md, "Scanners run: none") { + t.Errorf("empty scanner lists must render as 'none':\n%s", md) + } + if !strings.Contains(md, "0 total") { + t.Errorf("zero findings must be stated:\n%s", md) + } +} + +func TestRelPath_OutsideRepoKeptVerbatim(t *testing.T) { + if got := relPath("/repo", "/elsewhere/file.go"); !strings.Contains(got, "elsewhere") { + t.Errorf("paths outside the repo must not be mangled, got %q", got) + } + if got := relPath("/repo", "/repo/pkg/a.go"); got != "pkg/a.go" { + t.Errorf("in-repo paths become repo-relative, got %q", got) + } +} diff --git a/internal/security/report.go b/internal/security/report.go index 671b21a..25220f3 100644 --- a/internal/security/report.go +++ b/internal/security/report.go @@ -14,6 +14,7 @@ type Report struct { Languages []string `json:"languages"` ScannersRun []ScannerKind `json:"scanners_run"` Skipped []ScannerKind `json:"skipped"` + Failed []ScannerKind `json:"failed,omitempty"` Findings []Finding `json:"findings"` KBVersion int `json:"kb_version"` } @@ -70,7 +71,12 @@ func (r Report) FormatMarkdown() string { for i, s := range r.Skipped { skip[i] = string(s) } - fmt.Fprintf(&b, "Skipped (not installed): %s\n\n", joinOrNone(skip)) + fmt.Fprintf(&b, "Skipped (not installed): %s\n", joinOrNone(skip)) + fail := make([]string, len(r.Failed)) + for i, s := range r.Failed { + fail[i] = string(s) + } + fmt.Fprintf(&b, "Failed (ran but errored — coverage lost): %s\n\n", joinOrNone(fail)) // Findings, most severe first, then by file for stable output. sorted := make([]Finding, len(r.Findings)) diff --git a/internal/security/scanners.go b/internal/security/scanners.go index c30f63d..b9d6aff 100644 --- a/internal/security/scanners.go +++ b/internal/security/scanners.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "log" "os/exec" "path/filepath" "strconv" @@ -76,10 +77,15 @@ func applicableScanners(langs []string, available map[string]bool) []Scanner { } // RunScanners runs every applicable+available scanner against repoDir and -// returns deduped findings, the scanners that ran, and the applicable scanners -// that were skipped because they are not installed. One scanner failing (parse -// or exec error) is swallowed so a single broken tool never aborts the scan. -func RunScanners(ctx context.Context, repoDir string) (findings []Finding, ran, skipped []ScannerKind) { +// returns deduped findings, the scanners that ran clean, the applicable +// scanners that were skipped because they are not installed, and the scanners +// that ran but errored (exec crash, timeout, parse failure). A scanner failing +// never aborts the scan, but its failure is NOT silently swallowed: it is +// logged and reported in `failed` rather than counted as a clean run — +// otherwise a tool that failed to inspect the code is indistinguishable from +// one that found nothing, and the security gate would report a build as +// scanned-clean when coverage was actually lost. +func RunScanners(ctx context.Context, repoDir string) (findings []Finding, ran, skipped, failed []ScannerKind) { langs := DetectLanguages(repoDir) available := map[string]bool{} for _, s := range allScanners() { @@ -95,14 +101,45 @@ func RunScanners(ctx context.Context, repoDir string) (findings []Finding, ran, skipped = append(skipped, s.Kind) continue } - ran = append(ran, s.Kind) fs, err := s.Run(ctx, repoDir) if err != nil { - continue // graceful: log handled by caller; keep going + // Graceful degradation: keep scanning with the other tools, but make + // the coverage loss visible — a failed scan must never masquerade as + // a clean one. + failed = append(failed, s.Kind) + log.Printf("[security] scanner %s failed (coverage lost for this tool): %v", s.Kind, err) + continue } + ran = append(ran, s.Kind) findings = append(findings, fs...) } - return DedupeFindings(findings), ran, skipped + return DedupeFindings(findings), ran, skipped, failed +} + +// KnownScanners returns the full scanner registry regardless of PATH +// availability or repo languages, so other packages can report on missing +// tools without duplicating the list. +func KnownScanners() []Scanner { + return allScanners() +} + +// InstallHint returns the install command for a scanner binary, or "" when no +// hint is known. Hints target macOS/Homebrew and the Go toolchain. +func InstallHint(bin string) string { + switch bin { + case "gosec": + return "go install github.com/securego/gosec/v2/cmd/gosec@latest" + case "govulncheck": + return "go install golang.org/x/vuln/cmd/govulncheck@latest" + case "gitleaks": + return "brew install gitleaks" + case "semgrep": + return "brew install semgrep" + case "npm": + return "brew install node" + default: + return "" + } } // DetectScanners returns the scanners applicable to repoDir and available on the @@ -242,9 +279,9 @@ func parseSemgrep(out []byte, repoDir string) ([]Finding, error) { func parseNpmAudit(out []byte) ([]Finding, error) { var doc struct { Vulnerabilities map[string]struct { - Name string `json:"name"` - Severity string `json:"severity"` - Range string `json:"range"` + Name string `json:"name"` + Severity string `json:"severity"` + Range string `json:"range"` Via []json.RawMessage `json:"via"` } `json:"vulnerabilities"` } diff --git a/internal/security/scanners_exec_test.go b/internal/security/scanners_exec_test.go new file mode 100644 index 0000000..e5919d7 --- /dev/null +++ b/internal/security/scanners_exec_test.go @@ -0,0 +1,269 @@ +package security + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" +) + +// fakeTool installs an executable shell script named bin into dir that prints +// output and exits with code (verbatim — scanners use non-zero exits to mean +// "findings present", and some distinguish exit 2). The heredoc sentinel is +// collision-resistant so canned output can never terminate it early. +func fakeTool(t *testing.T, dir, bin, output string, code int) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("fake-tool harness is POSIX-shell based") + } + const sentinel = "FAKE_SCANNER_OUTPUT_BOUNDARY_9f2c1d" + if strings.Contains(output, sentinel) { + t.Fatalf("canned output collides with the heredoc sentinel %s", sentinel) + } + script := "#!/bin/sh\ncat <<'" + sentinel + "'\n" + output + "\n" + sentinel + "\nexit " + strconv.Itoa(code) + "\n" + if err := os.WriteFile(filepath.Join(dir, bin), []byte(script), 0o755); err != nil { + t.Fatal(err) + } +} + +// seedRepo creates a repo dir whose manifests establish the given languages. +func seedRepo(t *testing.T, files map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + return dir +} + +const fakeGosecOut = `{"Issues":[{"severity":"HIGH","rule_id":"G101","details":"Potential hardcoded credentials","file":"main.go","line":"7","cwe":{"id":"798"}}]}` + +const fakeGitleaksOut = `[{"Description":"AWS access key","File":"config.env","StartLine":3,"RuleID":"aws-access-key-id"}]` + +const fakeSemgrepOut = `{"results":[{"check_id":"go.lang.security.audit.sqli","path":"db.go","start":{"line":42},"extra":{"message":"SQL built from input","severity":"ERROR","metadata":{"cwe":["CWE-89"],"owasp":["A03:2021 - Injection"]}}}]}` + +const fakeGovulncheckOut = `Scanning your code and 42 packages across 7 dependent modules for known vulnerabilities... + +Vulnerability #1: GO-2024-1234 + A bad thing in some module.` + +const fakeNpmAuditOut = `{"vulnerabilities":{"lodash":{"name":"lodash","severity":"high","range":"<4.17.21","via":[]}}}` + +// installAllFakeScanners provisions every registry binary as a fake and points +// PATH at only that directory. Returns the bin dir for per-test overrides. +func installAllFakeScanners(t *testing.T) string { + t.Helper() + bin := t.TempDir() + fakeTool(t, bin, "gosec", fakeGosecOut, 1) + fakeTool(t, bin, "gitleaks", fakeGitleaksOut, 1) + fakeTool(t, bin, "semgrep", fakeSemgrepOut, 0) + fakeTool(t, bin, "govulncheck", fakeGovulncheckOut, 1) + fakeTool(t, bin, "npm", fakeNpmAuditOut, 1) + // Keep /bin:/usr/bin so the fake scripts can find cat; no real security + // scanner is ever installed there, so LookPath still resolves only fakes. + t.Setenv("PATH", bin+":/bin:/usr/bin") + return bin +} + +func TestRunScanners_EndToEnd_AllToolsParse(t *testing.T) { + installAllFakeScanners(t) + repo := seedRepo(t, map[string]string{ + "go.mod": "module example.com/x\n", + "package.json": "{}", + "tsconfig.json": "{}", + }) + + findings, ran, skipped, failed := RunScanners(context.Background(), repo) + + if len(skipped) != 0 { + t.Errorf("all tools installed — skipped must be empty, got %v", skipped) + } + if len(failed) != 0 { + t.Errorf("all tools parse — failed must be empty, got %v", failed) + } + if len(ran) != 5 { + t.Errorf("go+typescript repo makes all 5 scanners applicable, ran=%v", ran) + } + + byTool := map[string]Finding{} + for _, f := range findings { + byTool[f.Tool] = f + } + if f := byTool["gosec"]; f.RuleID != "G101" || f.Severity != SeverityHigh || f.Line != 7 || f.Detail != "CWE-798" { + t.Errorf("gosec finding mis-parsed: %+v", f) + } + if f := byTool["gitleaks"]; f.Severity != SeverityCritical || f.File != "config.env" { + t.Errorf("gitleaks finding mis-parsed: %+v", f) + } + if f := byTool["semgrep"]; f.Severity != SeverityHigh || f.Detail != "CWE-89" || f.Category == "" { + t.Errorf("semgrep finding mis-parsed: %+v", f) + } + if f := byTool["govulncheck"]; f.RuleID != "GO-2024-1234" { + t.Errorf("govulncheck finding mis-parsed: %+v", f) + } + if f := byTool["npm-audit"]; f.RuleID != "npm:lodash" || f.Severity != SeverityHigh { + t.Errorf("npm-audit finding mis-parsed: %+v", f) + } +} + +func TestRunScanners_FailedToolIsReportedNotSwallowed(t *testing.T) { + bin := installAllFakeScanners(t) + // gosec now emits garbage — a parse failure must land in `failed`, never in + // `ran`: a tool that failed to inspect the code must be distinguishable + // from one that found nothing. + fakeTool(t, bin, "gosec", "PANIC: not json", 1) + repo := seedRepo(t, map[string]string{"go.mod": "module example.com/x\n"}) + + findings, ran, skipped, failed := RunScanners(context.Background(), repo) + + if len(failed) != 1 || failed[0] != ScannerGosec { + t.Fatalf("want failed=[gosec], got %v", failed) + } + for _, k := range ran { + if k == ScannerGosec { + t.Error("a failed scanner must not be counted as ran") + } + } + if len(skipped) != 0 { + t.Errorf("nothing should be skipped, got %v", skipped) + } + // The other applicable tools still contribute findings (graceful degradation). + found := map[string]bool{} + for _, f := range findings { + found[f.Tool] = true + } + if !found["gitleaks"] || !found["govulncheck"] { + t.Errorf("other tools must keep scanning after one fails, got %v", found) + } +} + +func TestRunScanners_MissingToolsSkippedVisibly(t *testing.T) { + bin := t.TempDir() + fakeTool(t, bin, "gitleaks", fakeGitleaksOut, 1) // only gitleaks installed + t.Setenv("PATH", bin+":/bin:/usr/bin") + repo := seedRepo(t, map[string]string{"go.mod": "module example.com/x\n"}) + + _, ran, skipped, failed := RunScanners(context.Background(), repo) + + if len(ran) != 1 || ran[0] != ScannerGitleaks { + t.Errorf("want ran=[gitleaks], got %v", ran) + } + skippedSet := map[ScannerKind]bool{} + for _, k := range skipped { + skippedSet[k] = true + } + // Applicable-but-missing for a Go repo: semgrep (all langs), gosec, govulncheck. + for _, want := range []ScannerKind{ScannerSemgrep, ScannerGosec, ScannerGovulncheck} { + if !skippedSet[want] { + t.Errorf("missing tool %s must be reported as skipped, got %v", want, skipped) + } + } + // npm-audit is NOT applicable (no js/ts) — it must not appear anywhere. + if skippedSet[ScannerNpmAudit] { + t.Errorf("inapplicable scanner must not be listed as skipped: %v", skipped) + } + if len(failed) != 0 { + t.Errorf("nothing ran and errored, failed must be empty: %v", failed) + } +} + +func TestRunScanners_DedupesIdenticalFindings(t *testing.T) { + bin := t.TempDir() + // gitleaks reports the same secret twice (same rule, file, line). + dup := `[{"Description":"AWS access key","File":"config.env","StartLine":3,"RuleID":"aws-access-key-id"}, + {"Description":"AWS access key","File":"config.env","StartLine":3,"RuleID":"aws-access-key-id"}]` + fakeTool(t, bin, "gitleaks", dup, 1) + t.Setenv("PATH", bin+":/bin:/usr/bin") + repo := seedRepo(t, map[string]string{"README.md": "x"}) + + findings, _, _, _ := RunScanners(context.Background(), repo) + if len(findings) != 1 { + t.Errorf("identical findings must be deduped, got %d", len(findings)) + } +} + +func TestScannerRun_UnknownKindIsNoOp(t *testing.T) { + s := Scanner{Kind: ScannerKind("bogus"), Bin: "bogus"} + findings, err := s.Run(context.Background(), t.TempDir()) + if findings != nil || err != nil { + t.Errorf("unknown kind must be a no-op, got %v / %v", findings, err) + } +} + +func TestScannerRun_PerKindDispatch(t *testing.T) { + installAllFakeScanners(t) + repo := seedRepo(t, map[string]string{"go.mod": "module x\n"}) + + // Note: Scanner.Run dispatches on Kind with hardcoded binary names and + // never reads Bin — Bin serves only the LookPath availability checks in + // RunScanners/DetectScanners. Bin values here mirror allScanners() (the + // npm-audit scanner execs "npm", not "npm-audit"). + cases := []struct { + kind ScannerKind + bin string + wantTool string + }{ + {ScannerGosec, "gosec", "gosec"}, + {ScannerGitleaks, "gitleaks", "gitleaks"}, + {ScannerSemgrep, "semgrep", "semgrep"}, + {ScannerGovulncheck, "govulncheck", "govulncheck"}, + {ScannerNpmAudit, "npm", "npm-audit"}, + } + for _, tc := range cases { + fs, err := Scanner{Kind: tc.kind, Bin: tc.bin}.Run(context.Background(), repo) + if err != nil { + t.Errorf("%s: %v", tc.kind, err) + continue + } + if len(fs) != 1 || fs[0].Tool != tc.wantTool { + t.Errorf("%s: want one %s finding, got %+v", tc.kind, tc.wantTool, fs) + } + } +} + +func TestDetectScanners_CombinesLanguageAndAvailability(t *testing.T) { + bin := t.TempDir() + fakeTool(t, bin, "gosec", "", 0) + fakeTool(t, bin, "npm", "", 0) + t.Setenv("PATH", bin+":/bin:/usr/bin") + + // Go-only repo: gosec applies and is installed; npm is installed but not + // applicable; gitleaks/semgrep apply but are missing. + repo := seedRepo(t, map[string]string{"go.mod": "module x\n"}) + got := DetectScanners(repo) + if len(got) != 1 || got[0].Kind != ScannerGosec { + t.Errorf("want [gosec], got %v", got) + } +} + +func TestKnownScanners_RegistryComplete(t *testing.T) { + known := KnownScanners() + if len(known) != 5 { + t.Fatalf("registry drifted: want 5 scanners, got %d", len(known)) + } + bins := map[string]bool{} + for _, s := range known { + bins[s.Bin] = true + } + for _, want := range []string{"gosec", "govulncheck", "gitleaks", "semgrep", "npm"} { + if !bins[want] { + t.Errorf("registry missing %s", want) + } + } +} + +func TestInstallHint_EveryRegistryBinHasOne(t *testing.T) { + for _, s := range KnownScanners() { + if InstallHint(s.Bin) == "" { + t.Errorf("no install hint for %s — the preflight message would be blank", s.Bin) + } + } + if InstallHint("made-up-tool") != "" { + t.Error("unknown binary must return empty hint") + } +} diff --git a/internal/state/events.go b/internal/state/events.go index 473c21c..3bdc148 100644 --- a/internal/state/events.go +++ b/internal/state/events.go @@ -19,7 +19,11 @@ const ( EventReqPlanned EventType = "REQ_PLANNED" EventReqPaused EventType = "REQ_PAUSED" EventReqResumed EventType = "REQ_RESUMED" - EventReqCompleted EventType = "REQ_COMPLETED" + EventReqCompleted EventType = "REQ_COMPLETED" + // EventReqBlocked marks a requirement whose composed mainline stayed red + // after the completion gate exhausted its auto-fix budget. The requirement + // is NOT complete; resume with --godmode after addressing .nxd-fix-gaps.md. + EventReqBlocked EventType = "REQ_BLOCKED" EventReqClassified EventType = "REQ_CLASSIFIED" EventInvestigationCompleted EventType = "INVESTIGATION_COMPLETED" EventReqPendingReview EventType = "REQ_PENDING_REVIEW" @@ -54,7 +58,7 @@ const ( EventStoryDBDeleted EventType = "STORY_DB_DELETED" // Estimate events. - EventReqEstimated EventType = "REQ_ESTIMATED" + EventReqEstimated EventType = "REQ_ESTIMATED" // Agent lifecycle events. EventAgentSpawned EventType = "AGENT_SPAWNED" @@ -69,8 +73,8 @@ const ( EventSupervisorDriftDetected EventType = "SUPERVISOR_DRIFT_DETECTED" // Controller events. - EventControllerAnalysis EventType = "CONTROLLER_ANALYSIS" - EventControllerAction EventType = "CONTROLLER_ACTION" + EventControllerAnalysis EventType = "CONTROLLER_ANALYSIS" + EventControllerAction EventType = "CONTROLLER_ACTION" EventControllerStuckDetected EventType = "CONTROLLER_STUCK_DETECTED" // Cleanup events. diff --git a/internal/state/sqlite.go b/internal/state/sqlite.go index 0b03bc7..3e16ebe 100644 --- a/internal/state/sqlite.go +++ b/internal/state/sqlite.go @@ -191,6 +191,9 @@ func (s *SQLiteStore) Project(evt Event) error { case EventReqCompleted: return s.updateReqStatus(payload, "completed") + case EventReqBlocked: + return s.updateReqStatus(payload, "blocked") + case EventReqClassified: reqID, _ := payload["req_id"].(string) reqType, _ := payload["req_type"].(string) diff --git a/nxd.config.example.yaml b/nxd.config.example.yaml index de480f4..0630e5e 100644 --- a/nxd.config.example.yaml +++ b/nxd.config.example.yaml @@ -76,6 +76,8 @@ planning: - src/core/* max_story_complexity: 5 godmode: false + emit_integration_story: true + emit_scribe_story: true billing: default_rate: 150 currency: USD @@ -146,6 +148,8 @@ qa: value: go vet ./... - kind: test_passes value: go test ./... + disable_completion_gate: false + completion_fix_cycles: 0 security: gate_severity: critical auto_learn: true diff --git a/test/dryrun_test.go b/test/dryrun_test.go index b4319e5..e316bc5 100644 --- a/test/dryrun_test.go +++ b/test/dryrun_test.go @@ -38,6 +38,8 @@ func TestDryRun_PlannerPipeline(t *testing.T) { client := llm.NewDryRunClient(0) cfg := config.DefaultConfig() + cfg.Planning.EmitScribeStory = false + cfg.Planning.EmitIntegrationStory = false reqID := "r-dryrun-001" planner := engine.NewPlanner(client, cfg, es, ps)