From 7be1ca43fb0837988ed9fedbd94445884276bf07 Mon Sep 17 00:00:00 2001 From: cable8mm <2672043+cable8mm@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:26:50 +0900 Subject: [PATCH] feat: create the four authoritative documents with init --- .replworks/ARCHITECTURE.md | 4 +- .replworks/PRODUCT_SPEC.md | 9 +- .replworks/TASKS.md | 11 +++ README.md | 2 +- internal/command/init.go | 4 +- internal/command/init_e2e_test.go | 112 +++++++++++++++++++++-- internal/command/init_test.go | 78 ++++++++++++++-- internal/initproject/initproject.go | 29 ++++-- internal/initproject/initproject_test.go | 84 +++++++++++++++-- 9 files changed, 297 insertions(+), 36 deletions(-) diff --git a/.replworks/ARCHITECTURE.md b/.replworks/ARCHITECTURE.md index 6aa28bd..5dcfa3e 100644 --- a/.replworks/ARCHITECTURE.md +++ b/.replworks/ARCHITECTURE.md @@ -82,8 +82,8 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier - Responsibilities: - Obtain the Canonical Text for the selected init document first. - Make no write when the Canonical Text is `Failed`. - - Process items in this order: AGENTS.md, then the `.replworks/` directory. - - Mark an item `skipped` when it is present. Otherwise create it (AGENTS.md with the Canonical Text, the directory empty). + - Process items in this order: AGENTS.md, then the `.replworks/` directory, then `.replworks/PRODUCT_SPEC.md`, `.replworks/TECH_STACK.md`, `.replworks/ARCHITECTURE.md`, `.replworks/TASKS.md`. + - Mark an item `skipped` when it is present. Otherwise create it (AGENTS.md with the Canonical Text, the directory, and each document as an empty file). - Create nothing else. - Stop at the first write failure. - Inputs: Request with the selected document source, Canonical Text fact, Workspace presence facts. diff --git a/.replworks/PRODUCT_SPEC.md b/.replworks/PRODUCT_SPEC.md index 5143ab3..a1ef2c8 100644 --- a/.replworks/PRODUCT_SPEC.md +++ b/.replworks/PRODUCT_SPEC.md @@ -78,7 +78,7 @@ ### Commands - EL-001: repl init - - Purpose: create AGENTS.md and the .replworks/ directory in the project directory. + - Purpose: create AGENTS.md, the .replworks/ directory, and the four authoritative documents under .replworks/ in the project directory. - Input: an interactive selection of one of the two published AGENTS.md documents. - Output: one line per created item and one line per skipped item, each containing the item path. - Exit: 0 on completion, 2 on execution error. @@ -118,10 +118,11 @@ - FR-001: repl init MUST present the user with exactly these choices, in this order: `https://www.repl.net/documents/agents.md` and `https://www.repl.net/documents/agents-auto.md`, then create AGENTS.md with the content of the selected document when AGENTS.md does not exist. - FR-002: repl init MUST create the .replworks/ directory in the project directory when it does not exist. - FR-003: repl init MUST NOT modify an existing file. -- FR-004: repl init MUST NOT create any file or directory other than AGENTS.md and .replworks/. +- FR-004: repl init MUST NOT create any file or directory other than AGENTS.md, .replworks/, and the four documents defined in FR-036. - FR-005: repl init MUST print the path of each item it created. - FR-006: repl init MUST print the path of each item it skipped because the item already exists. - FR-007: repl init MUST exit with code 0 when it completes, including when it skipped items. +- FR-036: repl init MUST create .replworks/PRODUCT_SPEC.md, .replworks/TECH_STACK.md, .replworks/ARCHITECTURE.md, and .replworks/TASKS.md as empty files when they do not exist. ### status @@ -218,7 +219,7 @@ - AC-001: Given an empty directory and a valid selection of `https://www.repl.net/documents/agents.md`, when repl init runs, then AGENTS.md exists with that document's exact content, the .replworks/ directory exists, and the exit code is 0. (FR-001, FR-002, FR-005, FR-007, EL-001) - AC-002: Given a directory with an existing AGENTS.md, when repl init runs, then the content of AGENTS.md is unchanged, the output contains the AGENTS.md path as skipped, and the exit code is 0. (FR-003, FR-006, FR-007, EL-001) -- AC-003: Given an empty directory, when repl init runs, then no file or directory other than AGENTS.md and .replworks/ exists. (FR-004, EL-001) +- AC-003: Given an empty directory, when repl init runs, then no file or directory other than AGENTS.md, .replworks/, and the four documents defined in FR-036 exists. (FR-004, EL-001) - AC-004: Given a TASKS.md with 5 tasks of which 2 are complete, when repl status runs, then the output contains 2, 5, and the task ID of the first incomplete task, and the exit code is 0. (FR-008, FR-009, FR-010, EL-002) - AC-005: Given a TASKS.md in which every task is complete, when repl status runs, then the output contains an indication that no incomplete task exists. (FR-011, EL-002) - AC-006: Given a project directory, when repl status runs, then no file is modified. (FR-012, EL-002) @@ -254,3 +255,5 @@ - AC-036: Given an AGENTS.md and an invalid sync selection followed by a valid selection, when repl sync runs, then repl sync presents the choices again and completes using the selected document. (FR-027, EL-005) - AC-037: Given an AGENTS.md and a sync input that ends before a valid selection, when repl sync runs, then AGENTS.md is unmodified, an error line is printed, and the exit code is 2. (ERR-007, EL-005) - AC-038: Given an AGENTS.md that differs from the selected document, when repl sync writes to a terminal, then removed difference lines contain the red terminal color sequence and added difference lines contain the green terminal color sequence. When output is not a terminal, the difference contains no color sequences. (FR-035, EL-005) +- AC-039: Given an empty directory and a valid selection, when repl init runs, then .replworks/PRODUCT_SPEC.md, .replworks/TECH_STACK.md, .replworks/ARCHITECTURE.md, and .replworks/TASKS.md exist with zero bytes, and each is reported as created. (FR-005, FR-036, EL-001) +- AC-040: Given a .replworks/ directory that contains some but not all of the four documents, when repl init runs, then each missing document is created empty, each existing document is unchanged and reported as skipped, and the exit code is 0. (FR-003, FR-006, FR-036, EL-001) diff --git a/.replworks/TASKS.md b/.replworks/TASKS.md index 14b757f..9270c6a 100644 --- a/.replworks/TASKS.md +++ b/.replworks/TASKS.md @@ -252,6 +252,17 @@ - No test or production code references the removed HTML fixture. - The default test suite and static verification commands pass. +- [X] T-025 Create the four authoritative documents with init + - Satisfies: PRODUCT_SPEC.md § Functional Requirements / init (FR-003, FR-004, FR-005, FR-006, FR-007, FR-036); § User Interface / Commands (EL-001); § Acceptance Criteria (AC-003, AC-039, AC-040); ARCHITECTURE.md § Components (C2) + - External boundary: yes + - Acceptance criteria: + - The live canonical source is observed as in T-005. + - An E2E test exercises init through the live canonical source. Given an empty directory, `.replworks/PRODUCT_SPEC.md`, `.replworks/TECH_STACK.md`, `.replworks/ARCHITECTURE.md`, and `.replworks/TASKS.md` exist with zero bytes, and each is reported as created. + - Given a `.replworks/` directory containing some but not all of the four documents, each missing document is created empty, each existing document is unchanged and reported as skipped, and the exit code is 0. + - No file or directory other than AGENTS.md, `.replworks/`, and the four documents exists afterward. + - Existing init behavior from T-005, T-006, T-007, and T-021 remains passing. + - Unit tests for the Init Coordinator item order, creation, and skip logic pass. + - [X] T-021 Select the AGENTS.md document during init - Satisfies: PRODUCT_SPEC.md § Functional Requirements / init (FR-001 through FR-007); § Error Conditions (ERR-006); § Acceptance Criteria (AC-001, AC-002, AC-003, AC-022, AC-033, AC-034, AC-035); ARCHITECTURE.md § Components (C1, C2, C7) - External boundary: yes diff --git a/README.md b/README.md index bd32682..8cdf634 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ package from the [replworks APT repository](https://apt.repl.net). ## Usage ```sh -repl init # create AGENTS.md and .replworks/ +repl init # create AGENTS.md and the .replworks documents repl status # report task progress from .replworks/TASKS.md repl check # verify the project against the project rules repl sync # replace AGENTS.md with the canonical AGENTS.md diff --git a/internal/command/init.go b/internal/command/init.go index e4145c8..c907f6d 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -16,8 +16,8 @@ import ( func (r *runner) newInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", - Short: "Create AGENTS.md and the .replworks directory", - Long: "init writes the canonical AGENTS.md into the project directory and creates the .replworks directory. Existing entries are left unchanged.", + Short: "Create AGENTS.md and the REPL Works documents", + Long: "init writes the canonical AGENTS.md into the project directory, creates the .replworks directory, and creates the four authoritative documents under it as empty files. Existing entries are left unchanged.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { document, err := selectDocument(cmd) diff --git a/internal/command/init_e2e_test.go b/internal/command/init_e2e_test.go index 1bf4efc..3228d99 100644 --- a/internal/command/init_e2e_test.go +++ b/internal/command/init_e2e_test.go @@ -96,7 +96,30 @@ func TestInitAgainstTheLiveSource(t *testing.T) { t.Fatalf("project directory holds %d entries, want 2", len(entries)) } - for _, want := range []string{"created: AGENTS.md", "created: .replworks"} { + documents := []string{ + ".replworks/PRODUCT_SPEC.md", + ".replworks/TECH_STACK.md", + ".replworks/ARCHITECTURE.md", + ".replworks/TASKS.md", + } + for _, document := range documents { + content, err := os.ReadFile(filepath.Join(dir, document)) + if err != nil { + t.Fatalf("reading %s: %v", document, err) + } + if len(content) != 0 { + t.Fatalf("%s = %q, want an empty file", document, content) + } + } + + for _, want := range []string{ + "created: AGENTS.md", + "created: .replworks", + "created: .replworks/PRODUCT_SPEC.md", + "created: .replworks/TECH_STACK.md", + "created: .replworks/ARCHITECTURE.md", + "created: .replworks/TASKS.md", + } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } @@ -134,7 +157,14 @@ func TestInitAgainstTheLiveSourceKeepsAnExistingAgentsMD(t *testing.T) { t.Fatalf("stat .replworks: %v, want an existing directory", err) } - for _, want := range []string{"skipped: AGENTS.md", "created: .replworks"} { + for _, want := range []string{ + "skipped: AGENTS.md", + "created: .replworks", + "created: .replworks/PRODUCT_SPEC.md", + "created: .replworks/TECH_STACK.md", + "created: .replworks/ARCHITECTURE.md", + "created: .replworks/TASKS.md", + } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } @@ -167,22 +197,31 @@ func TestInitAgainstTheLiveSourceCreatesAGENTSMDWhenReplworksExists(t *testing.T t.Fatal("AGENTS.md does not hold the live canonical AGENTS.md") } - for _, text := range []string{"created: AGENTS.md", "skipped: .replworks"} { + for _, text := range []string{ + "created: AGENTS.md", + "skipped: .replworks", + "created: .replworks/PRODUCT_SPEC.md", + "created: .replworks/TECH_STACK.md", + "created: .replworks/ARCHITECTURE.md", + "created: .replworks/TASKS.md", + } { if !strings.Contains(stdout, text) { t.Fatalf("stdout = %q, want it to contain %q", stdout, text) } } } -func TestInitAgainstTheLiveSourceSkipsBothExistingItems(t *testing.T) { +func TestInitAgainstTheLiveSourceSkipsEveryExistingItem(t *testing.T) { liveCanonical(t) dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, ".replworks"), 0o755); err != nil { t.Fatalf("setup: %v", err) } - if err := os.WriteFile(filepath.Join(dir, ".replworks", "PRODUCT_SPEC.md"), []byte("# spec\n"), 0o644); err != nil { - t.Fatalf("setup: %v", err) + for _, document := range []string{"PRODUCT_SPEC.md", "TECH_STACK.md", "ARCHITECTURE.md", "TASKS.md"} { + if err := os.WriteFile(filepath.Join(dir, ".replworks", document), []byte(document+"\n"), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } } if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("mine\n"), 0o644); err != nil { t.Fatalf("setup: %v", err) @@ -203,7 +242,66 @@ func TestInitAgainstTheLiveSourceSkipsBothExistingItems(t *testing.T) { t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) } - for _, want := range []string{"skipped: AGENTS.md", "skipped: .replworks"} { + for _, want := range []string{ + "skipped: AGENTS.md", + "skipped: .replworks", + "skipped: .replworks/PRODUCT_SPEC.md", + "skipped: .replworks/TECH_STACK.md", + "skipped: .replworks/ARCHITECTURE.md", + "skipped: .replworks/TASKS.md", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout = %q, want it to contain %q", stdout, want) + } + } +} + +func TestInitAgainstTheLiveSourceCreatesOnlyTheMissingDocuments(t *testing.T) { + liveCanonical(t) + + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, ".replworks"), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + existing := "# My own TECH_STACK.md\n" + if err := os.WriteFile(filepath.Join(dir, ".replworks", "TECH_STACK.md"), []byte(existing), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + t.Chdir(dir) + + code, stdout, stderr := runCommand(t, nil, "init") + + if code != 0 { + t.Fatalf("exit code = %d, want 0 (stderr = %q)", code, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + + content, err := os.ReadFile(filepath.Join(dir, ".replworks", "TECH_STACK.md")) + if err != nil { + t.Fatalf("reading TECH_STACK.md: %v", err) + } + if string(content) != existing { + t.Fatalf("TECH_STACK.md = %q, want the existing content byte for byte", content) + } + + for _, document := range []string{"PRODUCT_SPEC.md", "ARCHITECTURE.md", "TASKS.md"} { + info, err := os.Stat(filepath.Join(dir, ".replworks", document)) + if err != nil { + t.Fatalf("stat %s: %v", document, err) + } + if info.Size() != 0 { + t.Fatalf("%s size = %d, want 0", document, info.Size()) + } + } + + for _, want := range []string{ + "skipped: .replworks/TECH_STACK.md", + "created: .replworks/PRODUCT_SPEC.md", + "created: .replworks/ARCHITECTURE.md", + "created: .replworks/TASKS.md", + } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } diff --git a/internal/command/init_test.go b/internal/command/init_test.go index 6c80030..d00acff 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -256,21 +256,46 @@ func TestInitCreatesAgentsAndReplworks(t *testing.T) { if err != nil { t.Fatalf("reading .replworks: %v", err) } - if len(inside) != 0 { - t.Fatalf(".replworks holds %d entries, want none", len(inside)) + if len(inside) != 4 { + t.Fatalf(".replworks holds %d entries, want 4", len(inside)) } if got := transport.requested(); len(got) != 1 { t.Fatalf("requests = %q, want 1", got) } - for _, want := range []string{"created: AGENTS.md", "created: .replworks"} { + documents := []string{ + ".replworks/PRODUCT_SPEC.md", + ".replworks/TECH_STACK.md", + ".replworks/ARCHITECTURE.md", + ".replworks/TASKS.md", + } + for _, document := range documents { + content, err := os.ReadFile(filepath.Join(dir, document)) + if err != nil { + t.Fatalf("reading %s: %v", document, err) + } + if len(content) != 0 { + t.Fatalf("%s = %q, want an empty file", document, content) + } + } + + for _, want := range append([]string{"created: AGENTS.md", "created: .replworks"}, prefixAll("created: ", documents)...) { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } } } +func prefixAll(prefix string, values []string) []string { + prefixed := make([]string, len(values)) + for i, value := range values { + prefixed[i] = prefix + value + } + + return prefixed +} + func TestInitLeavesExistingEntriesUnchanged(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("mine\n"), 0o644); err != nil { @@ -279,6 +304,11 @@ func TestInitLeavesExistingEntriesUnchanged(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, ".replworks"), 0o755); err != nil { t.Fatalf("setup: %v", err) } + for _, document := range []string{"PRODUCT_SPEC.md", "TECH_STACK.md", "ARCHITECTURE.md", "TASKS.md"} { + if err := os.WriteFile(filepath.Join(dir, ".replworks", document), []byte(document+"\n"), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + } t.Chdir(dir) client, _ := servePage(t, string(recordedPage(t))) @@ -293,7 +323,14 @@ func TestInitLeavesExistingEntriesUnchanged(t *testing.T) { t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) } - for _, want := range []string{"skipped: AGENTS.md", "skipped: .replworks"} { + for _, want := range []string{ + "skipped: AGENTS.md", + "skipped: .replworks", + "skipped: .replworks/PRODUCT_SPEC.md", + "skipped: .replworks/TECH_STACK.md", + "skipped: .replworks/ARCHITECTURE.md", + "skipped: .replworks/TASKS.md", + } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } @@ -328,6 +365,23 @@ func TestInitCreatesTheMissingEntryOnly(t *testing.T) { if !strings.Contains(stdout, "created: .replworks") { t.Fatalf("stdout = %q, want it to report the created directory", stdout) } + for _, document := range []string{ + ".replworks/PRODUCT_SPEC.md", + ".replworks/TECH_STACK.md", + ".replworks/ARCHITECTURE.md", + ".replworks/TASKS.md", + } { + info, err := os.Stat(filepath.Join(dir, document)) + if err != nil { + t.Fatalf("stat %s: %v", document, err) + } + if info.Size() != 0 { + t.Fatalf("%s size = %d, want 0", document, info.Size()) + } + if !strings.Contains(stdout, "created: "+document) { + t.Fatalf("stdout = %q, want it to report %s as created", stdout, document) + } + } } func TestInitFailsWhenTheCanonicalSourceIsUnavailable(t *testing.T) { @@ -393,9 +447,14 @@ func TestInitSkipsEntriesOfAnyKind(t *testing.T) { if err := os.Mkdir(filepath.Join(dir, "AGENTS.md"), 0o755); err != nil { t.Fatalf("setup: %v", err) } - if err := os.WriteFile(filepath.Join(dir, ".replworks"), []byte("not a directory\n"), 0o644); err != nil { + if err := os.Mkdir(filepath.Join(dir, ".replworks"), 0o755); err != nil { t.Fatalf("setup: %v", err) } + for _, document := range []string{"PRODUCT_SPEC.md", "TECH_STACK.md", "ARCHITECTURE.md", "TASKS.md"} { + if err := os.Mkdir(filepath.Join(dir, ".replworks", document), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + } t.Chdir(dir) client, _ := servePage(t, string(recordedPage(t))) @@ -413,7 +472,14 @@ func TestInitSkipsEntriesOfAnyKind(t *testing.T) { t.Fatalf("project directory changed:\nbefore: %s\nafter: %s", before, after) } - for _, want := range []string{"skipped: AGENTS.md", "skipped: .replworks"} { + for _, want := range []string{ + "skipped: AGENTS.md", + "skipped: .replworks", + "skipped: .replworks/PRODUCT_SPEC.md", + "skipped: .replworks/TECH_STACK.md", + "skipped: .replworks/ARCHITECTURE.md", + "skipped: .replworks/TASKS.md", + } { if !strings.Contains(stdout, want) { t.Fatalf("stdout = %q, want it to contain %q", stdout, want) } diff --git a/internal/initproject/initproject.go b/internal/initproject/initproject.go index b2d7d63..81bc5ad 100644 --- a/internal/initproject/initproject.go +++ b/internal/initproject/initproject.go @@ -1,4 +1,4 @@ -// Package initproject creates AGENTS.md and the .replworks directory. +// Package initproject creates AGENTS.md and the .replworks directory with its authoritative documents. package initproject import ( @@ -8,10 +8,24 @@ import ( ) const ( - agentsPath = "AGENTS.md" - replworksPath = ".replworks" + agentsPath = "AGENTS.md" + replworksPath = ".replworks" + productSpecPath = ".replworks/PRODUCT_SPEC.md" + techStackPath = ".replworks/TECH_STACK.md" + architecturePath = ".replworks/ARCHITECTURE.md" + tasksPath = ".replworks/TASKS.md" ) +// itemPaths is the order in which init processes entries. +var itemPaths = []string{ + agentsPath, + replworksPath, + productSpecPath, + techStackPath, + architecturePath, + tasksPath, +} + // CanonicalSource provides the canonical AGENTS.md. type CanonicalSource interface { Canonical() fact.Fact[string] @@ -48,7 +62,7 @@ func (c Coordinator) Run() Outcome { var outcome Outcome - for _, path := range []string{agentsPath, replworksPath} { + for _, path := range itemPaths { exists := c.Workspace.Exists(path) if exists.IsFailed() { outcome.Errors = append(outcome.Errors, exists.Reason()) @@ -61,10 +75,13 @@ func (c Coordinator) Run() Outcome { } var err error - if path == replworksPath { + switch path { + case replworksPath: err = c.Workspace.CreateDirectory(path) - } else { + case agentsPath: err = c.Workspace.CreateFile(path, canonical.Value()) + default: + err = c.Workspace.CreateFile(path, "") } if err != nil { outcome.Errors = append(outcome.Errors, err) diff --git a/internal/initproject/initproject_test.go b/internal/initproject/initproject_test.go index a940f3a..e326044 100644 --- a/internal/initproject/initproject_test.go +++ b/internal/initproject/initproject_test.go @@ -82,7 +82,7 @@ func newSource() *fakeSource { return &fakeSource{canonical: fact.Present(canonicalText)} } -func TestRunCreatesBothEntries(t *testing.T) { +func TestRunCreatesEveryEntry(t *testing.T) { source := newSource() writer := newFakeWriter() @@ -91,7 +91,14 @@ func TestRunCreatesBothEntries(t *testing.T) { if len(outcome.Errors) > 0 { t.Fatalf("errors = %v, want none", outcome.Errors) } - want := []Item{{Path: "AGENTS.md", Created: true}, {Path: ".replworks", Created: true}} + want := []Item{ + {Path: "AGENTS.md", Created: true}, + {Path: ".replworks", Created: true}, + {Path: ".replworks/PRODUCT_SPEC.md", Created: true}, + {Path: ".replworks/TECH_STACK.md", Created: true}, + {Path: ".replworks/ARCHITECTURE.md", Created: true}, + {Path: ".replworks/TASKS.md", Created: true}, + } if len(outcome.Items) != len(want) { t.Fatalf("items = %+v, want %+v", outcome.Items, want) } @@ -103,36 +110,58 @@ func TestRunCreatesBothEntries(t *testing.T) { if got := writer.files["AGENTS.md"]; got != canonicalText { t.Fatalf("AGENTS.md content = %q, want the canonical text", got) } + for _, path := range []string{ + ".replworks/PRODUCT_SPEC.md", + ".replworks/TECH_STACK.md", + ".replworks/ARCHITECTURE.md", + ".replworks/TASKS.md", + } { + content, ok := writer.files[path] + if !ok { + t.Fatalf("%s was not created", path) + } + if content != "" { + t.Fatalf("%s content = %q, want empty", path, content) + } + } if !writer.dirs[".replworks"] { t.Fatal(".replworks was not created") } if source.calls != 1 { t.Fatalf("canonical source called %d times, want 1", source.calls) } - wantTrace := "exists AGENTS.md, create-file AGENTS.md, exists .replworks, create-directory .replworks" + wantTrace := "exists AGENTS.md, create-file AGENTS.md, exists .replworks, create-directory .replworks, " + + "exists .replworks/PRODUCT_SPEC.md, create-file .replworks/PRODUCT_SPEC.md, " + + "exists .replworks/TECH_STACK.md, create-file .replworks/TECH_STACK.md, " + + "exists .replworks/ARCHITECTURE.md, create-file .replworks/ARCHITECTURE.md, " + + "exists .replworks/TASKS.md, create-file .replworks/TASKS.md" if got := writer.trace(); got != wantTrace { t.Fatalf("trace = %q, want %q", got, wantTrace) } } func TestRunSkipsExistingEntries(t *testing.T) { - writer := newFakeWriter("AGENTS.md", ".replworks") + writer := newFakeWriter(itemPaths...) outcome := Coordinator{Source: newSource(), Workspace: writer}.Run() if len(outcome.Errors) > 0 { t.Fatalf("errors = %v, want none", outcome.Errors) } - want := []Item{{Path: "AGENTS.md"}, {Path: ".replworks"}} + if len(outcome.Items) != len(itemPaths) { + t.Fatalf("items = %+v, want %d skipped items", outcome.Items, len(itemPaths)) + } for i, item := range outcome.Items { - if item != want[i] { - t.Fatalf("items[%d] = %+v, want %+v", i, item, want[i]) + want := Item{Path: itemPaths[i]} + if item != want { + t.Fatalf("items[%d] = %+v, want %+v", i, item, want) } } if len(writer.files) > 0 || len(writer.dirs) > 0 { t.Fatalf("the project directory was modified: files = %v, dirs = %v", writer.files, writer.dirs) } - if writer.trace() != "exists AGENTS.md, exists .replworks" { + wantTrace := "exists " + strings.Join(itemPaths, ", exists ") + if writer.trace() != wantTrace { t.Fatalf("trace = %q, want only presence checks", writer.trace()) } } @@ -151,7 +180,7 @@ func TestRunCreatesTheMissingEntryOnly(t *testing.T) { if !outcome.Items[1].Created { t.Fatal(".replworks was not created") } - if len(writer.files) > 0 { + if _, ok := writer.files["AGENTS.md"]; ok { t.Fatalf("AGENTS.md was written: %v", writer.files) } if !writer.dirs[".replworks"] { @@ -159,6 +188,43 @@ func TestRunCreatesTheMissingEntryOnly(t *testing.T) { } } +func TestRunCreatesOnlyTheMissingDocuments(t *testing.T) { + writer := newFakeWriter( + "AGENTS.md", + ".replworks", + ".replworks/PRODUCT_SPEC.md", + ".replworks/ARCHITECTURE.md", + ".replworks/TASKS.md", + ) + + outcome := Coordinator{Source: newSource(), Workspace: writer}.Run() + + if len(outcome.Errors) > 0 { + t.Fatalf("errors = %v, want none", outcome.Errors) + } + if len(outcome.Items) != len(itemPaths) { + t.Fatalf("items = %+v, want %d items", outcome.Items, len(itemPaths)) + } + for i, item := range outcome.Items { + want := Item{Path: itemPaths[i]} + if itemPaths[i] == ".replworks/TECH_STACK.md" { + want.Created = true + } + if item != want { + t.Fatalf("items[%d] = %+v, want %+v", i, item, want) + } + } + if got := writer.files[".replworks/TECH_STACK.md"]; got != "" { + t.Fatalf("TECH_STACK.md content = %q, want empty", got) + } + if len(writer.files) != 1 { + t.Fatalf("files = %v, want only the created document", writer.files) + } + if len(writer.dirs) > 0 { + t.Fatalf("dirs = %v, want none", writer.dirs) + } +} + func TestRunFailsWhenTheCanonicalTextCannotBeObtained(t *testing.T) { failure := errors.New("the canonical source is unreachable") writer := newFakeWriter()