diff --git a/.replworks/ARCHITECTURE.md b/.replworks/ARCHITECTURE.md index c58c925..0c80cf5 100644 --- a/.replworks/ARCHITECTURE.md +++ b/.replworks/ARCHITECTURE.md @@ -83,14 +83,15 @@ Task Ledger Reader and Unverified Mark Scanner → Document Line Classifier - Responsibilities: - Obtain the Canonical Text and Helper Script Text before any write. - Make no write when required source text is `Failed`. - - 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`, then the helper-script state. - - 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). + - Process items in this order: AGENTS.md, then the `.replworks/` directory, the four authoritative documents, `.replworks/docs/`, `.replworks/docs/IDEAS.md`, `.replworks/docs/PITCHING_SCRIPT.md`, then the helper-script state. + - Mark an item `skipped` when it is present. Otherwise create it (AGENTS.md with the Canonical Text, directories, and each document as an empty file). - Create the helper-script directory when absent, replace the helper script with Helper Script Text whether absent or present, and ensure it has execute permission. + - Never modify or delete the human-edited documents after creation; their paths are managed only through create-if-absent operations. - Stop at the first write failure. - Inputs: Request with the canonical document source, Canonical Text fact, Helper Script Text fact, and Workspace presence facts. - Outputs: Outcome with one `(item path, created | skipped | replaced)` entry per processed item, and execution errors. - Ownership boundaries: - - Owns the item order and the created/skipped decision. + - Owns the item order and the created/skipped/replaced decision. - Does not perform I/O itself, print, or compare content. - Holds the Workspace `Write` capability. diff --git a/.replworks/PRODUCT_SPEC.md b/.replworks/PRODUCT_SPEC.md index a7bbb7d..fe89dc4 100644 --- a/.replworks/PRODUCT_SPEC.md +++ b/.replworks/PRODUCT_SPEC.md @@ -55,6 +55,7 @@ - Exit code of each command. - AGENTS.md file and .replworks/ directory created by repl init. - `.replworks/scripts/ask-human.sh` created or replaced by init and sync. +- `.replworks/docs/IDEAS.md` and `.replworks/docs/PITCHING_SCRIPT.md` created empty by init when absent. - AGENTS.md file replaced by repl sync. ## Constraints @@ -78,7 +79,7 @@ ### Commands - EL-001: repl init - - Purpose: create AGENTS.md, the .replworks/ directory, and the four authoritative documents under .replworks/ in the project directory. + - Purpose: create AGENTS.md, the .replworks/ directory, the four authoritative documents, and the two human-edited documents under .replworks/ in the project directory. - Input: none. - Output: one line per created, skipped, or replaced item, each containing the item path. - Exit: 0 on completion, 2 on execution error. @@ -118,13 +119,15 @@ - FR-001: repl init MUST create AGENTS.md with the content of `https://www.repl.net/documents/agents.md` when AGENTS.md does not exist, without prompting for a selection. - 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 except that it MUST replace `.replworks/scripts/ask-human.sh` with the exact published response and ensure that it has execute permission. -- FR-004: repl init MUST NOT create any file or directory other than AGENTS.md, .replworks/, the four documents defined in FR-036, and `.replworks/scripts/ask-human.sh` plus its parent directory. +- FR-004: repl init MUST NOT create any file or directory other than AGENTS.md, .replworks/, the four documents defined in FR-036, `.replworks/scripts/ask-human.sh` plus its parent directory, and the files and directory defined in FR-043. - 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. - FR-037: repl init MUST create `.replworks/scripts/ask-human.sh` from the exact response text at `https://www.repl.net/scripts/ask-human.sh` when the file does not exist, and MUST replace the content of an existing file with that response while ensuring that it has execute permission. - FR-038: repl init MUST NOT prompt for a canonical document selection. +- FR-043: repl init MUST create `.replworks/docs/` when it does not exist and create empty `.replworks/docs/IDEAS.md` and `.replworks/docs/PITCHING_SCRIPT.md` files when they do not exist. Existing entries MUST be skipped and left unchanged. +- FR-044: After either human-edited document exists, no repl-cli command MUST modify or delete it. ### status @@ -261,3 +264,5 @@ - AC-038: Given an AGENTS.md that differs from the canonical 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, when repl init runs without input, 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) +- AC-045: Given a project without `.replworks/docs/`, `repl init` creates the directory and empty `IDEAS.md` and `PITCHING_SCRIPT.md` files, and reports each created path. +- AC-046: Given either human-edited file already exists with content, `repl init` reports it as skipped and preserves its content byte for byte. `repl sync` also leaves both files unchanged. diff --git a/.replworks/TASKS.md b/.replworks/TASKS.md index bc69ed9..5c2b874 100644 --- a/.replworks/TASKS.md +++ b/.replworks/TASKS.md @@ -297,3 +297,12 @@ - Automatic-run sync adds execute permission to an existing helper script when AGENTS.md is replaced without changing its content. - Sync with identical AGENTS.md does not modify the helper script. - Unit and command-level tests pass. + +- [X] T-028 Create human-edited docs files with init + - Satisfies: PRODUCT_SPEC.md § Functional Requirements / init (FR-043, FR-044); § Acceptance Criteria (AC-045, AC-046); ARCHITECTURE.md § Components (C2) + - External boundary: yes + - Acceptance criteria: + - An E2E test exercises init through the live canonical sources and verifies `.replworks/docs/IDEAS.md` and `.replworks/docs/PITCHING_SCRIPT.md` are created empty. + - Init creates `.replworks/docs/` when absent and reports all created paths. + - Existing files with user content are skipped and remain byte for byte unchanged through init and sync. + - Unit tests for the Init Coordinator's create-if-absent behavior pass. diff --git a/internal/command/init.go b/internal/command/init.go index 849379b..78db29c 100644 --- a/internal/command/init.go +++ b/internal/command/init.go @@ -13,7 +13,7 @@ func (r *runner) newInitCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Create AGENTS.md and the REPL Works documents", - Long: "init writes the canonical AGENTS.md and ask-human.sh into the project directory, creates the .replworks directory, and creates the four authoritative documents under it as empty files.", + Long: "init writes the canonical AGENTS.md and ask-human.sh into the project directory, creates the four authoritative documents under .replworks as empty files, and creates IDEAS.md and PITCHING_SCRIPT.md under .replworks/docs as empty human-edited files.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { dir, err := r.projectDir() diff --git a/internal/command/init_e2e_test.go b/internal/command/init_e2e_test.go index 8e0eb63..994fc88 100644 --- a/internal/command/init_e2e_test.go +++ b/internal/command/init_e2e_test.go @@ -64,6 +64,18 @@ func TestInitAgainstTheLiveCanonicalAndScriptSources(t *testing.T) { if string(content) != want { t.Fatal("AGENTS.md does not hold the live canonical AGENTS.md") } + for _, path := range []string{".replworks/docs/IDEAS.md", ".replworks/docs/PITCHING_SCRIPT.md"} { + content, err := os.ReadFile(filepath.Join(dir, path)) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + if len(content) != 0 { + t.Fatalf("%s = %q, want an empty file", path, content) + } + if !strings.Contains(stdout, "created: "+path) { + t.Fatalf("stdout = %q, want creation output for %s", stdout, path) + } + } script, err := os.ReadFile(filepath.Join(dir, ".replworks/scripts/ask-human.sh")) if err != nil { t.Fatalf("reading ask-human.sh: %v", err) @@ -242,6 +254,14 @@ func TestInitAgainstTheLiveSourceSkipsEveryExistingItem(t *testing.T) { t.Fatalf("setup: %v", err) } } + if err := os.Mkdir(filepath.Join(dir, ".replworks/docs"), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + for _, document := range []string{"IDEAS.md", "PITCHING_SCRIPT.md"} { + if err := os.WriteFile(filepath.Join(dir, ".replworks/docs", document), []byte("human content: "+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) } diff --git a/internal/command/init_test.go b/internal/command/init_test.go index addd3fe..734011b 100644 --- a/internal/command/init_test.go +++ b/internal/command/init_test.go @@ -287,8 +287,8 @@ func TestInitCreatesAgentsAndReplworks(t *testing.T) { if err != nil { t.Fatalf("reading .replworks: %v", err) } - if len(inside) != 5 { - t.Fatalf(".replworks holds %d entries, want 5 including scripts", len(inside)) + if len(inside) != 6 { + t.Fatalf(".replworks holds %d entries, want 6 including docs and scripts", len(inside)) } if got := transport.requested(); len(got) != 2 { @@ -625,3 +625,39 @@ func TestInitReplacesExistingAskHumanScriptRegardlessOfInput(t *testing.T) { t.Fatalf("stdout = %q, want replacement output", stdout) } } + +func TestInitAndSyncPreserveHumanEditedDocuments(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".replworks/docs"), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(recordedText(t)), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + wantIdeas := "ideas maintained by a person\n" + wantPitch := "pitch script maintained by a person\n" + if err := os.WriteFile(filepath.Join(dir, ".replworks/docs/IDEAS.md"), []byte(wantIdeas), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".replworks/docs/PITCHING_SCRIPT.md"), []byte(wantPitch), 0o644); err != nil { + t.Fatalf("setup: %v", err) + } + t.Chdir(dir) + client, _ := servePage(t, recordedText(t)) + + for _, command := range []string{"init", "sync"} { + code, _, stderr := runCommand(t, client, command) + if code != 0 || stderr != "" { + t.Fatalf("%s: code = %d, stderr = %q, want success", command, code, stderr) + } + } + for path, want := range map[string]string{ + ".replworks/docs/IDEAS.md": wantIdeas, + ".replworks/docs/PITCHING_SCRIPT.md": wantPitch, + } { + content, err := os.ReadFile(filepath.Join(dir, path)) + if err != nil || string(content) != want { + t.Fatalf("%s = %q, err = %v, want preserved content %q", path, content, err, want) + } + } +} diff --git a/internal/initproject/initproject.go b/internal/initproject/initproject.go index 81816de..a20b8a8 100644 --- a/internal/initproject/initproject.go +++ b/internal/initproject/initproject.go @@ -14,6 +14,9 @@ const ( techStackPath = ".replworks/TECH_STACK.md" architecturePath = ".replworks/ARCHITECTURE.md" tasksPath = ".replworks/TASKS.md" + docsPath = ".replworks/docs" + ideasPath = ".replworks/docs/IDEAS.md" + pitchingPath = ".replworks/docs/PITCHING_SCRIPT.md" scriptsPath = ".replworks/scripts" askHumanPath = ".replworks/scripts/ask-human.sh" ) @@ -26,6 +29,9 @@ var itemPaths = []string{ techStackPath, architecturePath, tasksPath, + docsPath, + ideasPath, + pitchingPath, } // CanonicalSource provides the canonical AGENTS.md. @@ -97,7 +103,7 @@ func (c Coordinator) Run() Outcome { var err error switch path { - case replworksPath: + case replworksPath, docsPath: err = c.Workspace.CreateDirectory(path) case agentsPath: err = c.Workspace.CreateFile(path, canonical.Value()) diff --git a/internal/initproject/initproject_test.go b/internal/initproject/initproject_test.go index 7437542..4c6705a 100644 --- a/internal/initproject/initproject_test.go +++ b/internal/initproject/initproject_test.go @@ -119,6 +119,9 @@ func TestRunCreatesEveryEntry(t *testing.T) { {Path: ".replworks/TECH_STACK.md", Created: true}, {Path: ".replworks/ARCHITECTURE.md", Created: true}, {Path: ".replworks/TASKS.md", Created: true}, + {Path: ".replworks/docs", Created: true}, + {Path: ".replworks/docs/IDEAS.md", Created: true}, + {Path: ".replworks/docs/PITCHING_SCRIPT.md", Created: true}, } if len(outcome.Items) != len(want) { t.Fatalf("items = %+v, want %+v", outcome.Items, want) @@ -136,6 +139,8 @@ func TestRunCreatesEveryEntry(t *testing.T) { ".replworks/TECH_STACK.md", ".replworks/ARCHITECTURE.md", ".replworks/TASKS.md", + ".replworks/docs/IDEAS.md", + ".replworks/docs/PITCHING_SCRIPT.md", } { content, ok := writer.files[path] if !ok { @@ -148,6 +153,9 @@ func TestRunCreatesEveryEntry(t *testing.T) { if !writer.dirs[".replworks"] { t.Fatal(".replworks was not created") } + if !writer.dirs[".replworks/docs"] { + t.Fatal(".replworks/docs was not created") + } if source.calls != 1 { t.Fatalf("canonical source called %d times, want 1", source.calls) } @@ -155,7 +163,10 @@ func TestRunCreatesEveryEntry(t *testing.T) { "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" + "exists .replworks/TASKS.md, create-file .replworks/TASKS.md, " + + "exists .replworks/docs, create-directory .replworks/docs, " + + "exists .replworks/docs/IDEAS.md, create-file .replworks/docs/IDEAS.md, " + + "exists .replworks/docs/PITCHING_SCRIPT.md, create-file .replworks/docs/PITCHING_SCRIPT.md" if got := writer.trace(); got != wantTrace { t.Fatalf("trace = %q, want %q", got, wantTrace) } @@ -187,6 +198,26 @@ func TestRunSkipsExistingEntries(t *testing.T) { } } +func TestRunPreservesExistingHumanEditedDocuments(t *testing.T) { + writer := newFakeWriter(itemPaths...) + writer.files[ideasPath] = "keep ideas\n" + writer.files[pitchingPath] = "keep pitch\n" + + outcome := Coordinator{Source: newSource(), Workspace: writer}.Run() + + if len(outcome.Errors) > 0 { + t.Fatalf("errors = %v, want none", outcome.Errors) + } + if writer.files[ideasPath] != "keep ideas\n" || writer.files[pitchingPath] != "keep pitch\n" { + t.Fatalf("human-edited documents changed: %q, %q", writer.files[ideasPath], writer.files[pitchingPath]) + } + for _, item := range outcome.Items { + if (item.Path == ideasPath || item.Path == pitchingPath) && (item.Created || item.Replaced || item.Deleted) { + t.Fatalf("item = %+v, want existing file skipped", item) + } + } +} + func TestRunCreatesTheMissingEntryOnly(t *testing.T) { writer := newFakeWriter("AGENTS.md") @@ -216,6 +247,9 @@ func TestRunCreatesOnlyTheMissingDocuments(t *testing.T) { ".replworks/PRODUCT_SPEC.md", ".replworks/ARCHITECTURE.md", ".replworks/TASKS.md", + ".replworks/docs", + ".replworks/docs/IDEAS.md", + ".replworks/docs/PITCHING_SCRIPT.md", ) outcome := Coordinator{Source: newSource(), Workspace: writer}.Run()