diff --git a/CLAUDE.md b/CLAUDE.md index 14ac582..c2eac8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -411,6 +411,14 @@ Robustness pass from an adversarial principles audit (correctness/flakiness gaps - **README link:** `ensureReadmeReferencesDiagrams` appends a `` 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 -p OK` / `claude --model -p OK` first.** diff --git a/internal/engine/doc_generator.go b/internal/engine/doc_generator.go index 29da200..783f2b0 100644 --- a/internal/engine/doc_generator.go +++ b/internal/engine/doc_generator.go @@ -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" diff --git a/internal/engine/doc_generator_test.go b/internal/engine/doc_generator_test.go index cd65866..c3ed069 100644 --- a/internal/engine/doc_generator_test.go +++ b/internal/engine/doc_generator_test.go @@ -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") @@ -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() diff --git a/internal/engine/factory_docs.go b/internal/engine/factory_docs.go new file mode 100644 index 0000000..17af60d --- /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/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 +} diff --git a/internal/engine/factory_docs_adr.go b/internal/engine/factory_docs_adr.go new file mode 100644 index 0000000..838cbf4 --- /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/vortex-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..449386e --- /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/vortex-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/planner.go b/internal/engine/planner.go index 1ee2ba6..815c466 100644 --- a/internal/engine/planner.go +++ b/internal/engine/planner.go @@ -530,22 +530,24 @@ Make reasonable, conventional choices for any route paths or wiring details the 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 ALL of the following: -- 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). Link to docs/ and the training guide so the README is the entry point. -- A "Training" / "Getting Started Tutorial" section (in README or docs/training.md, linked from the README): a step-by-step hands-on walkthrough that takes a new user from zero to a working result, with copy-pasteable commands and expected output. +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. -- Reference both SVGs from the README (e.g. via ![Architecture](docs/architecture.svg)). -- 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/*.svg and training files are new files and may be authored freely.`, requirement) +- 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 tutorial + SVG architecture & sequence diagrams", + 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; a step-by-step Training/Getting-Started tutorial exists (README section or docs/training.md) 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; on a pre-existing README, edits are confined to the vxd:scribe markers and existing content outside them is unchanged."), + 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 vxd: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"}, + 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_test.go b/internal/engine/planner_test.go index 573c32f..9861da5 100644 --- a/internal/engine/planner_test.go +++ b/internal/engine/planner_test.go @@ -185,11 +185,12 @@ func TestPlanner_EmitsScribeStory(t *testing.T) { if scribe.ID != "r-001-scribe-readme" { t.Fatalf("expected scribe id r-001-scribe-readme, got %q", scribe.ID) } - // Scribe owns the README plus the software-factory doc artifacts (training - // guide + rendered SVG architecture/sequence diagrams). + // Scribe owns the README plus the full software-factory doc set (training + // guide, rendered SVG architecture/sequence diagrams, ADRs, docs index). wantOwned := map[string]bool{ "README.md": false, "docs/architecture.svg": false, "docs/sequence.svg": false, "docs/training.md": false, + "docs/adr": false, "docs/README.md": false, } for _, f := range scribe.OwnedFiles { if _, ok := wantOwned[f]; ok { @@ -201,9 +202,10 @@ func TestPlanner_EmitsScribeStory(t *testing.T) { t.Errorf("scribe should own %s, got %v", f, scribe.OwnedFiles) } } - // Standards must be baked into the scribe brief. + // Standards must be baked into the scribe brief: SVG diagrams (no Mermaid), + // a training guide, ADRs, and a docs index. d := scribe.Description + " " + string(scribe.AcceptanceCriteria) - for _, must := range []string{"architecture.svg", "sequence.svg", "Training", "NOT Mermaid"} { + for _, must := range []string{"architecture.svg", "sequence.svg", "training.md", "NOT Mermaid", "docs/adr", "docs/README.md"} { if !strings.Contains(d, must) { t.Errorf("scribe brief missing %q", must) }