Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ Robustness pass from an adversarial principles audit (correctness/flakiness gaps
- **README link:** `ensureReadmeReferencesDiagrams` appends a `<!-- vxd:diagrams:start -->` Architecture section linking both SVGs only if the README doesn't already reference them (hand-written architecture prose is never clobbered). `commitDocumentation` now stages `README.md` + `docs/` and commits both.
- **Tests:** `svg_docs_test.go` (validateSVG/extractSVG tables, retry-until-valid, exhaustion, keeps-existing-valid, README linking) + `doc_generator_test.go::TestGenerateDocumentation_ProducesSVGDiagrams` (end-to-end wiring guard in a temp git repo — fails if the diagram call is ever dropped). **NXD port pending.**

### Comprehensive documentation standard (factory_docs.go, 2026-06-25)
The doc loop ships the FULL software-factory documentation set, not just README + SVGs. `ensureFactoryDocs` (`factory_docs.go`, called from `generateDocumentation` after the SVG step) fills any shortfall the scribe agent left:
- **`docs/training.md`** — getting-started tutorial. `ensureTrainingGuide` generates it from the codebase via the doc model **only if missing** (skips when the agent supplied it; rejects output < 80 bytes).
- **`docs/adr/0001-*.md` + `docs/adr/README.md`** — Architecture Decision Records. `ensureADRs` (`factory_docs_adr.go`) asks the doc model for a JSON array of 3-6 real decisions (grounded in the file tree + manifest), validates each has a title + decision (`parseADRs`), and writes one Markdown file per ADR (`renderADR`, standard Status/Context/Decision/Consequences sections) plus an index table. Skips when `docs/adr/` already holds a numbered ADR (`hasADRFiles`).
- **`docs/README.md`** — documentation index. `ensureDocsIndex` is **fully deterministic** (no LLM): it scans `docs/` and links every guide (`.md`), diagram (`.svg`), and the ADR index. Regenerated last so it reflects everything else, and always present.
- Every backstop is best-effort — a model failure logs and skips, never blocking requirement completion. The **scribe story** (`buildScribeStory`) instructs the agent to produce the whole set up front (its `OwnedFiles` + acceptance criteria now include `docs/adr` + `docs/README.md`); these backstops guarantee it ships even when the agent doesn't.
- Tests: `factory_docs_test.go` (docs index determinism, humanize, training skip/generate, ADR parse/render/slug/skip/generate), the upgraded `TestGenerateDocumentation_ProducesSVGDiagrams` (README + 2 SVGs + training + ADRs + index all produced and committed), `planner_test.go::TestPlanner_EmitsScribeStory` (standards baked into the brief). **NXD port pending.**

### Model ID Compatibility
- **Use undated aliases, not dated snapshots.** Current defaults: `claude-opus-4-8` (tech_lead), `claude-sonnet-4-6` (senior/qa/manager), `claude-haiku-4-5` (cheapest). All three are verified working on the Claude CLI subscription tier.
- **Default execution tiers are all-Anthropic (2026-06-24 fix).** `DefaultConfig` previously set junior/intermediate/supervisor to `{google, gemma-4-27b-it}` — a model that 404s on the Google AI API (it does not exist on `v1beta`). Every low-complexity story spawned a gemini agent that died in ~10s producing no code, then limped forward by escalating to senior. Defaults are now `{anthropic, claude-haiku-4-5}` so a fresh install works with only the Claude CLI configured (no Google AI key/quota). `TestDefaultConfig_NoInvalidJuniorModel` pins this. **A model 404 in the agent runtime surfaces as "agent produced no code changes," NOT as a model error — if a whole tier silently produces nothing, validate the model ID with `gemini -m <id> -p OK` / `claude --model <id> -p OK` first.**
Expand Down
8 changes: 7 additions & 1 deletion internal/engine/doc_generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ Instructions:
return
}

// Commit the documentation update (README + generated docs/ diagrams)
// 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"
Expand Down
29 changes: 25 additions & 4 deletions internal/engine/doc_generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,15 @@ func TestGenerateDocumentation_ProducesSVGDiagrams(t *testing.T) {
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."}, // README
llm.CompletionResponse{Content: validArchSVG}, // architecture.svg
llm.CompletionResponse{Content: validArchSVG}, // sequence.svg
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")
Expand All @@ -63,7 +68,23 @@ func TestGenerateDocumentation_ProducesSVGDiagrams(t *testing.T) {
t.Fatal("README does not reference the architecture diagram")
}

// The diagrams must have been committed, not left dirty.
// 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()
Expand Down
177 changes: 177 additions & 0 deletions internal/engine/factory_docs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package engine

import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"

"github.com/tzone85/vortex-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
}
Loading
Loading